diff --git a/data/meeting-repos-seed.json b/data/meeting-repos-seed.json new file mode 100644 index 0000000..cc26f7c --- /dev/null +++ b/data/meeting-repos-seed.json @@ -0,0 +1,36 @@ +{ + "meta": { + "provenance": "Curated seed from AF-Member-Intelligence (14 Zoom chat logs, Feb 19 - Apr 30 2026) + Gemini meeting notes (Apr 22 - Jul 1). For member-intel entries the introducer is association-inferred (the member the repo is associated with) pending verification against raw chat lines; source is 'member-intel'. Owners left null where unverified.", + "not_yet_parsed": [ + "2026-05-06 OSS Google Meet transcript", + "2026-05-13 OSS Google Meet transcript", + "2026-05-20 OSS Google Meet transcript", + "2026-05-27 OSS Google Meet transcript", + "2026-06-03 OSS Google Meet transcript", + "2026-06-10 OSS Google Meet transcript", + "2026-06-24 OSS Google Meet transcript", + "2026-07-01 OSS Google Meet transcript", + "2026-07-08 OSS Google Meet transcript (Gemini notes failed)" + ] + }, + "entries": [ + {"canonical": "agenticsorg/OIA-Model", "name": "OIA-Model", "owner": "agenticsorg", "url": "https://github.com/agenticsorg/OIA-Model", "introducer": "nicholas-ruest", "first_seen": "2026-02-19", "source": "member-intel"}, + {"canonical": "globalbusinessadvisors/Synapse-Graph", "name": "Synapse-Graph", "owner": "globalbusinessadvisors", "url": "https://github.com/globalbusinessadvisors/Synapse-Graph", "introducer": "nicholas-ruest", "first_seen": "2026-02-19", "source": "member-intel"}, + {"canonical": "ruvnet/RuVector", "name": "RuVector", "owner": "ruvnet", "url": "https://github.com/ruvnet/RuVector", "introducer": "ruvnet", "first_seen": "2026-02-19", "source": "member-intel"}, + {"canonical": "ruvnet/ruflo", "name": "ruflo", "owner": "ruvnet", "url": "https://github.com/ruvnet/ruflo", "introducer": "ruvnet", "first_seen": "2026-02-19", "source": "member-intel"}, + {"canonical": "ruview", "name": "RuView", "owner": null, "url": null, "introducer": "ruvnet", "first_seen": "2026-02-19", "source": "member-intel"}, + {"canonical": "connectome-os", "name": "Connectome-OS", "owner": null, "url": null, "introducer": "ruvnet", "first_seen": "2026-02-19", "source": "member-intel"}, + {"canonical": "open-claude-code", "name": "open-claude-code", "owner": null, "url": null, "introducer": "ruvnet", "first_seen": "2026-02-19", "source": "member-intel"}, + {"canonical": "pi.ruv.io", "name": "pi.ruv.io", "owner": null, "url": "https://pi.ruv.io", "introducer": "Stuart Kerr", "first_seen": "2026-03-13", "source": "member-intel"}, + {"canonical": "county-title-search-agent", "name": "County title-search agent", "owner": null, "url": null, "introducer": "rcraw", "first_seen": "2026-02-27", "source": "member-intel"}, + {"canonical": "CraftsMan-Labs/SimpleAgents", "name": "SimpleAgents", "owner": "CraftsMan-Labs", "url": "https://github.com/CraftsMan-Labs/SimpleAgents", "introducer": "CraftsMan-Labs", "first_seen": "2026-04-09", "source": "member-intel"}, + {"canonical": "simpleagenttools", "name": "simpleAgentTools (PyPI)", "owner": null, "url": null, "introducer": "CraftsMan-Labs", "first_seen": "2026-04-09", "source": "member-intel"}, + {"canonical": "simpleflow-sdk", "name": "SimpleFlow SDK", "owner": null, "url": null, "introducer": "CraftsMan-Labs", "first_seen": "2026-04-09", "source": "member-intel"}, + {"canonical": "weftos", "name": "WeftOS", "owner": null, "url": null, "introducer": "Stuart Kerr", "first_seen": "2026-04-10", "source": "member-intel"}, + {"canonical": "vectorvroom", "name": "VectorVroom", "owner": null, "url": "https://vectorvroom.shaal.dev", "introducer": "shaal", "first_seen": "2026-04-24", "source": "member-intel"}, + {"canonical": "proffesor-for-testing/agentic-qe", "name": "agentic-qe", "owner": "proffesor-for-testing", "url": "https://github.com/proffesor-for-testing/agentic-qe", "introducer": "Dragan Spiridonov", "first_seen": "2026-04-30", "source": "member-intel"}, + {"canonical": "summarybot", "name": "SummaryBot", "owner": null, "url": null, "introducer": "mrjcleaver", "first_seen": "2026-05-20", "source": "gemini-notes-2026-05-20 (Issue #33)"}, + {"canonical": "video-sync", "name": "Video Sync tool", "owner": null, "url": null, "introducer": "mrjcleaver", "first_seen": "2026-06-10", "source": "gemini-notes-2026-06-10"}, + {"canonical": "manage.agentics.org", "name": "manage.agentics.org", "owner": null, "url": "https://manage.agentics.org", "introducer": "committee", "first_seen": "2026-06-24", "source": "gemini-notes-2026-06-24 (OIA pilot target)"} + ] +} diff --git a/data/meeting-repos.json b/data/meeting-repos.json new file mode 100644 index 0000000..3556ae5 --- /dev/null +++ b/data/meeting-repos.json @@ -0,0 +1,279 @@ +{ + "generated_at": "2026-07-13T00:00:00Z", + "provenance": "Curated seed from AF-Member-Intelligence (14 Zoom chat logs, Feb 19 - Apr 30 2026) + Gemini meeting notes (Apr 22 - Jul 1). For member-intel entries the introducer is association-inferred (the member the repo is associated with) pending verification against raw chat lines; source is 'member-intel'. Owners left null where unverified.", + "report": { + "parsed_sources": 0, + "not_yet_parsed": [ + "2026-05-06 OSS Google Meet transcript", + "2026-05-13 OSS Google Meet transcript", + "2026-05-20 OSS Google Meet transcript", + "2026-05-27 OSS Google Meet transcript", + "2026-06-03 OSS Google Meet transcript", + "2026-06-10 OSS Google Meet transcript", + "2026-06-24 OSS Google Meet transcript", + "2026-07-01 OSS Google Meet transcript", + "2026-07-08 OSS Google Meet transcript (Gemini notes failed)" + ], + "total_repos": 18 + }, + "entries": [ + { + "canonical": "agenticsorg/OIA-Model", + "name": "OIA-Model", + "owner": "agenticsorg", + "url": "https://github.com/agenticsorg/OIA-Model", + "introducer": "nicholas-ruest", + "first_seen": "2026-02-19", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [] + }, + { + "canonical": "connectome-os", + "name": "Connectome-OS", + "owner": null, + "url": null, + "introducer": "ruvnet", + "first_seen": "2026-02-19", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "globalbusinessadvisors/Synapse-Graph", + "name": "Synapse-Graph", + "owner": "globalbusinessadvisors", + "url": "https://github.com/globalbusinessadvisors/Synapse-Graph", + "introducer": "nicholas-ruest", + "first_seen": "2026-02-19", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [] + }, + { + "canonical": "open-claude-code", + "name": "open-claude-code", + "owner": null, + "url": null, + "introducer": "ruvnet", + "first_seen": "2026-02-19", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "ruview", + "name": "RuView", + "owner": null, + "url": null, + "introducer": "ruvnet", + "first_seen": "2026-02-19", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "ruvnet/RuVector", + "name": "RuVector", + "owner": "ruvnet", + "url": "https://github.com/ruvnet/RuVector", + "introducer": "ruvnet", + "first_seen": "2026-02-19", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [] + }, + { + "canonical": "ruvnet/ruflo", + "name": "ruflo", + "owner": "ruvnet", + "url": "https://github.com/ruvnet/ruflo", + "introducer": "ruvnet", + "first_seen": "2026-02-19", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [] + }, + { + "canonical": "county-title-search-agent", + "name": "County title-search agent", + "owner": null, + "url": null, + "introducer": "rcraw", + "first_seen": "2026-02-27", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "pi.ruv.io", + "name": "pi.ruv.io", + "owner": null, + "url": "https://pi.ruv.io", + "introducer": "Stuart Kerr", + "first_seen": "2026-03-13", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "CraftsMan-Labs/SimpleAgents", + "name": "SimpleAgents", + "owner": "CraftsMan-Labs", + "url": "https://github.com/CraftsMan-Labs/SimpleAgents", + "introducer": "CraftsMan-Labs", + "first_seen": "2026-04-09", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [] + }, + { + "canonical": "simpleagenttools", + "name": "simpleAgentTools (PyPI)", + "owner": null, + "url": null, + "introducer": "CraftsMan-Labs", + "first_seen": "2026-04-09", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "simpleflow-sdk", + "name": "SimpleFlow SDK", + "owner": null, + "url": null, + "introducer": "CraftsMan-Labs", + "first_seen": "2026-04-09", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "weftos", + "name": "WeftOS", + "owner": null, + "url": null, + "introducer": "Stuart Kerr", + "first_seen": "2026-04-10", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "vectorvroom", + "name": "VectorVroom", + "owner": null, + "url": "https://vectorvroom.shaal.dev", + "introducer": "shaal", + "first_seen": "2026-04-24", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "proffesor-for-testing/agentic-qe", + "name": "agentic-qe", + "owner": "proffesor-for-testing", + "url": "https://github.com/proffesor-for-testing/agentic-qe", + "introducer": "Dragan Spiridonov", + "first_seen": "2026-04-30", + "mention_count": 1, + "sources": [ + "member-intel" + ], + "flags": [] + }, + { + "canonical": "summarybot", + "name": "SummaryBot", + "owner": null, + "url": null, + "introducer": "mrjcleaver", + "first_seen": "2026-05-20", + "mention_count": 1, + "sources": [ + "gemini-notes-2026-05-20 (Issue #33)" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "video-sync", + "name": "Video Sync tool", + "owner": null, + "url": null, + "introducer": "mrjcleaver", + "first_seen": "2026-06-10", + "mention_count": 1, + "sources": [ + "gemini-notes-2026-06-10" + ], + "flags": [ + "owner-unverified" + ] + }, + { + "canonical": "manage.agentics.org", + "name": "manage.agentics.org", + "owner": null, + "url": "https://manage.agentics.org", + "introducer": "committee", + "first_seen": "2026-06-24", + "mention_count": 1, + "sources": [ + "gemini-notes-2026-06-24 (OIA pilot target)" + ], + "flags": [ + "owner-unverified" + ] + } + ] +} diff --git a/features/repo-registry.feature b/features/repo-registry.feature new file mode 100644 index 0000000..9cc8502 --- /dev/null +++ b/features/repo-registry.feature @@ -0,0 +1,117 @@ +# Agentics Foundation OSS Committee -- Meeting Repo Registry +# Source: Issue #11 (candidate pool for first consideration); +# AF meeting chats/transcripts + AF-Member-Intelligence (Feb-Jul 2026); +# OIA-as-attractor plan (2026-07-13) -- the registry is the warm-lead +# target list of repos to assess against OIA. +# Status: RED (no implementation) +# +# Purpose: turn "repos people mentioned in AF meetings" into a maintained, +# public-safe registry, each entry attributed to who introduced it and when, +# so the committee has a candidate pool it can grade against OIA. + +@issue-11 @registry +Feature: Meeting repo registry + As the OSS committee chair + I need every repo introduced in an AF meeting captured with its introducer and first-seen date + So that the committee has an auditable candidate pool to assess against OIA + + # ────────────────────────────────────────────────────────── + # Definitions (the judgment calls, made explicit) + # - A "mention" is one occurrence of a repo in one source. + # - The "introducer" is the speaker of the EARLIEST mention + # (first to bring it into a meeting), NOT the repo's owner. + # Owner is recorded separately when known. + # - "first_seen" is the date of the earliest mention. + # - A repo is identified by a canonical key: "owner/name" when a + # GitHub URL is known, else a slugged project name. + # ────────────────────────────────────────────────────────── + + # ---- Extraction ---- + + @extraction @url + Scenario: A GitHub URL in a chat line is captured as a mention + Given a source dated "2026-04-09" with the chat line "Rishub: check out https://github.com/CraftsMan-Labs/SimpleAgents" + When the registry is built + Then the registry contains a repo with canonical key "CraftsMan-Labs/SimpleAgents" + And its introducer is "Rishub" + And its first_seen date is "2026-04-09" + And its owner is "CraftsMan-Labs" + + @extraction @lexicon + Scenario: A known project named without a URL is captured via the lexicon + Given a source dated "2026-04-10" with the chat line "Stuart: WeftOS is the one to watch" + And the lexicon maps "WeftOS" to canonical key "weftos" + When the registry is built + Then the registry contains a repo with canonical key "weftos" + And its introducer is "Stuart" + + @extraction @noise + Scenario: The generic word "repo" alone does not create an entry + Given a source dated "2026-06-18" with the line "So you got a repo harness, point it at some random repo" + When the registry is built + Then the registry contains no repo for that line + + # ---- Attribution ---- + + @attribution @earliest-wins + Scenario: The introducer is the earliest speaker, later mentions do not override + Given a source dated "2026-03-13" with the chat line "Stuart: pi.ruv.io overview attached" + And a source dated "2026-04-24" with the chat line "Reuven: pi.ruv.io is my go-to" + When the registry is built + Then the repo "pi.ruv.io" has introducer "Stuart" + And its first_seen date is "2026-03-13" + And its mention_count is 2 + + @attribution @unknown-speaker + Scenario: A mention with no resolvable speaker is kept but flagged + Given a source dated "2026-05-20" with the line "https://github.com/ruvnet/ruflo was merged" + When the registry is built + Then the repo "ruvnet/ruflo" has introducer "unknown" + And the repo is flagged "introducer-unverified" + + # ---- Seed merge (Feb-Apr member-intelligence already extracted) ---- + + @seed @merge + Scenario: A curated seed entry merges with a later automated mention + Given a seed entry for "agenticsorg/OIA-Model" introduced by "nicholas-ruest" on "2026-02-19" + And a source dated "2026-06-24" mentioning "https://github.com/agenticsorg/OIA-Model" + When the registry is built + Then the registry contains exactly one repo "agenticsorg/OIA-Model" + And its introducer is "nicholas-ruest" + And its first_seen date is "2026-02-19" + And its mention_count is at least 2 + + # ---- Deduplication ---- + + @dedupe + Scenario: URL and lexicon references to the same repo collapse to one entry + Given a source dated "2026-04-09" with the chat line "Rishub: https://github.com/CraftsMan-Labs/SimpleAgents" + And the lexicon maps "SimpleAgents" to canonical key "CraftsMan-Labs/SimpleAgents" + And a source dated "2026-04-09" with the chat line "Martin: loving SimpleAgents" + When the registry is built + Then the registry contains exactly one repo "CraftsMan-Labs/SimpleAgents" + And its mention_count is 2 + + # ---- Output contract (public-safe projection) ---- + + @output @public-safe + Scenario: The published registry carries no personal contact information + Given any built registry + When it is written to the public output file + Then each entry has only the fields: canonical, name, owner, url, introducer, first_seen, mention_count, sources, flags + And no entry contains an email address or phone number + + @output @deterministic + Scenario: Rebuilding the same inputs produces the same registry + Given a fixed set of sources and a fixed seed + When the registry is built twice + Then the two registries are byte-identical except for a generated_at timestamp + + # ---- Coverage honesty ---- + + @coverage @honesty + Scenario: Sources that were not parsed are reported, never silently dropped + Given the May-July Google Meet transcripts are not available as parseable text + When the registry is built + Then the build report lists those meetings under "not_yet_parsed" + And the report states the count of sources actually parsed diff --git a/lib/repo-registry.js b/lib/repo-registry.js new file mode 100644 index 0000000..a81c729 --- /dev/null +++ b/lib/repo-registry.js @@ -0,0 +1,146 @@ +// Meeting repo registry parser. +// Implements features/repo-registry.feature. +// +// Core rule: the INTRODUCER is the earliest speaker to bring a repo into a +// meeting (not the repo's owner). Pure functions only (no Date/random) so the +// registry is deterministic; the CLI wrapper stamps generated_at. + +// ── Lexicon of known projects (alias -> canonical). Owners left null when +// unverified; the parser records null rather than guessing. Extend as needed. +export const LEXICON = [ + { canonical: 'agenticsorg/OIA-Model', name: 'OIA-Model', aliases: ['OIA-Model'], owner: 'agenticsorg', url: 'https://github.com/agenticsorg/OIA-Model' }, + { canonical: 'CraftsMan-Labs/SimpleAgents', name: 'SimpleAgents', aliases: ['SimpleAgents'], owner: 'CraftsMan-Labs', url: 'https://github.com/CraftsMan-Labs/SimpleAgents' }, + { canonical: 'ruvnet/RuVector', name: 'RuVector', aliases: ['RuVector'], owner: 'ruvnet', url: 'https://github.com/ruvnet/RuVector' }, + { canonical: 'ruvnet/ruflo', name: 'ruflo', aliases: ['ruflo'], owner: 'ruvnet', url: 'https://github.com/ruvnet/ruflo' }, + { canonical: 'proffesor-for-testing/agentic-qe', name: 'agentic-qe', aliases: ['agentic-qe'], owner: 'proffesor-for-testing', url: 'https://github.com/proffesor-for-testing/agentic-qe' }, + { canonical: 'globalbusinessadvisors/Synapse-Graph', name: 'Synapse-Graph', aliases: ['Synapse-Graph'], owner: 'globalbusinessadvisors', url: 'https://github.com/globalbusinessadvisors/Synapse-Graph' }, + { canonical: 'weftos', name: 'WeftOS', aliases: ['WeftOS'], owner: null, url: null }, + { canonical: 'connectome-os', name: 'Connectome-OS', aliases: ['Connectome-OS'], owner: null, url: null }, + { canonical: 'vectorvroom', name: 'VectorVroom', aliases: ['VectorVroom'], owner: null, url: 'https://vectorvroom.shaal.dev' }, +]; + +const PUBLIC_FIELDS = ['canonical', 'name', 'owner', 'url', 'introducer', 'first_seen', 'mention_count', 'sources', 'flags']; + +const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// "Speaker: message" -> {speaker, text}. Only treats a short leading token +// followed by ": " as a speaker (avoids matching URLs like "https:"). +function parseSpeaker(line) { + const m = line.match(/^\s*([A-Za-z][\w .'-]{0,40}?):\s+(.*)$/); + if (m && !/^https?$/i.test(m[1])) return { speaker: m[1].trim(), text: m[2] }; + return { speaker: null, text: line }; +} + +function normalizeRepoName(name) { + return name.replace(/\.git$/i, '').replace(/[).,;:]+$/, ''); +} + +// Extract every repo mention from one source. Within a single line, the same +// canonical repo counts once (URL + lexicon of the same repo do not double). +function extractMentions(text, { date, source }, lexicon) { + const mentions = []; + const urlRe = /github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/gi; + for (const rawLine of String(text).split(/\r?\n/)) { + const { speaker, text: body } = parseSpeaker(rawLine); + const seenThisLine = new Set(); + const push = (mention) => { + if (seenThisLine.has(mention.canonical)) return; + seenThisLine.add(mention.canonical); + mentions.push(mention); + }; + + // 1. GitHub URLs + let m; + urlRe.lastIndex = 0; + while ((m = urlRe.exec(rawLine)) !== null) { + const owner = m[1]; + const name = normalizeRepoName(m[2]); + push({ canonical: `${owner}/${name}`, name, owner, url: `https://github.com/${owner}/${name}`, speaker, date, source }); + } + + // 2. Lexicon aliases (word-ish boundary; excludes matches inside a URL path) + for (const entry of lexicon) { + for (const alias of entry.aliases) { + const re = new RegExp(`(? extractMentions(src.text, { date: src.date, source: src.source }, lexicon)), + ]; + + const groups = new Map(); + for (const men of mentions) { + if (!groups.has(men.canonical)) groups.set(men.canonical, []); + groups.get(men.canonical).push(men); + } + + const entries = []; + for (const [canonical, list] of groups) { + // earliest date; among ties, prefer a known speaker (seed or named). + const sorted = [...list].sort((a, b) => { + if (a.date !== b.date) return a.date < b.date ? -1 : 1; + const aKnown = a.speaker ? 0 : 1; + const bKnown = b.speaker ? 0 : 1; + return aKnown - bKnown; + }); + const earliest = sorted[0]; + const introducer = earliest.speaker || 'unknown'; + + const pick = (field) => list.map((x) => x[field]).find((v) => v != null) ?? null; + const flags = []; + if (introducer === 'unknown') flags.push('introducer-unverified'); + if (pick('owner') == null) flags.push('owner-unverified'); + + entries.push({ + canonical, + name: pick('name') ?? canonical.split('/').pop(), + owner: pick('owner'), + url: pick('url'), + introducer, + first_seen: earliest.date, + mention_count: list.length, + sources: [...new Set(list.map((x) => x.source))].sort(), + flags, + }); + } + + entries.sort((a, b) => (a.first_seen !== b.first_seen ? (a.first_seen < b.first_seen ? -1 : 1) : a.canonical < b.canonical ? -1 : 1)); + + return { entries, report: { parsed_sources: sources.length, not_yet_parsed: [...notYetParsed] } }; +} + +/** Project entries to the public-safe shape (whitelisted fields, PII-checked). */ +export function toPublicRegistry(entries) { + const clean = entries.map((e) => { + const out = {}; + for (const f of PUBLIC_FIELDS) out[f] = e[f]; + return out; + }); + const blob = JSON.stringify(clean); + if (/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/.test(blob)) throw new Error('public registry contains an email address'); + // Phone = a separator-joined run with >= 10 digits (ISO dates have only 8, so they do not trip this). + for (const run of blob.match(/\+?\d[\d\s().-]{7,}\d/g) || []) { + if (run.replace(/\D/g, '').length >= 10) throw new Error('public registry contains a phone number'); + } + return { entries: clean }; +} diff --git a/scripts/build-repo-registry.mjs b/scripts/build-repo-registry.mjs new file mode 100644 index 0000000..643938b --- /dev/null +++ b/scripts/build-repo-registry.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Build the meeting repo registry -> data/meeting-repos.json (public-safe). +// Implements features/repo-registry.feature. +// +// Inputs: +// - data/meeting-repos-seed.json : curated Feb-Apr seed + not_yet_parsed list +// - optional --sources : directory of meeting transcript/chat text +// files. Each file's date is taken from a leading YYYY-MM-DD in its name. +// Raw transcripts live in PKM (internal) and are NOT committed here; pass +// the dir at runtime. Without it, the registry is built from the seed. +// +// generated_at is the only non-deterministic field (buildRegistry itself is +// pure); pass --date YYYY-MM-DD to make the whole output reproducible. +import { readFile, writeFile, readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { buildRegistry, toPublicRegistry, LEXICON } from '../lib/repo-registry.js'; + +function arg(flag) { + const i = process.argv.indexOf(flag); + return i !== -1 ? process.argv[i + 1] : null; +} + +async function loadSources(dir) { + if (!dir || !existsSync(dir)) return []; + const files = (await readdir(dir)).filter((f) => /\.(txt|md)$/i.test(f)); + const sources = []; + for (const f of files) { + const m = basename(f).match(/(\d{4}-\d{2}-\d{2})/); + if (!m) continue; // skip files with no date in the name + sources.push({ date: m[1], source: basename(f), text: await readFile(join(dir, f), 'utf8') }); + } + return sources; +} + +const seedRaw = JSON.parse(await readFile(new URL('../data/meeting-repos-seed.json', import.meta.url))); +const sources = await loadSources(arg('--sources')); +const generatedAt = arg('--date') ? `${arg('--date')}T00:00:00Z` : new Date().toISOString(); + +const { entries, report } = buildRegistry({ + seed: seedRaw.entries, + sources, + lexicon: LEXICON, + notYetParsed: seedRaw.meta?.not_yet_parsed ?? [], +}); + +const pub = toPublicRegistry(entries); // throws if any PII leaked into a public field + +const out = { + generated_at: generatedAt, + provenance: seedRaw.meta?.provenance ?? null, + report: { ...report, total_repos: entries.length }, + entries: pub.entries, +}; + +await writeFile(new URL('../data/meeting-repos.json', import.meta.url), `${JSON.stringify(out, null, 2)}\n`); +console.log( + `meeting-repos.json: ${entries.length} repos (${report.parsed_sources} sources parsed, ${report.not_yet_parsed.length} not yet parsed).`, +); diff --git a/test/repo-registry.test.js b/test/repo-registry.test.js new file mode 100644 index 0000000..630f9d4 --- /dev/null +++ b/test/repo-registry.test.js @@ -0,0 +1,124 @@ +// Tests for the meeting repo registry parser. +// Implements features/repo-registry.feature. Introducer = earliest speaker. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRegistry, toPublicRegistry, LEXICON } from '../lib/repo-registry.js'; + +const find = (reg, key) => reg.entries.find((e) => e.canonical === key); + +test('@extraction @url: a GitHub URL in a chat line is captured with speaker, owner, date', () => { + const reg = buildRegistry({ + sources: [{ date: '2026-04-09', source: 'chat-0409', text: 'Rishub: check out https://github.com/CraftsMan-Labs/SimpleAgents' }], + }); + const e = find(reg, 'CraftsMan-Labs/SimpleAgents'); + assert.ok(e, 'entry exists'); + assert.equal(e.introducer, 'Rishub'); + assert.equal(e.first_seen, '2026-04-09'); + assert.equal(e.owner, 'CraftsMan-Labs'); +}); + +test('@extraction @lexicon: a known project named without a URL is captured via the lexicon', () => { + const lexicon = [...LEXICON, { canonical: 'weftos', name: 'WeftOS', aliases: ['WeftOS'], owner: null, url: null }]; + const reg = buildRegistry({ + sources: [{ date: '2026-04-10', source: 'chat-0410', text: 'Stuart: WeftOS is the one to watch' }], + lexicon, + }); + const e = find(reg, 'weftos'); + assert.ok(e, 'lexicon entry exists'); + assert.equal(e.introducer, 'Stuart'); +}); + +test('@extraction @noise: the generic word "repo" alone does not create an entry', () => { + const reg = buildRegistry({ + sources: [{ date: '2026-06-18', source: 'transcript-0618', text: 'So you got a repo harness, point it at some random repo' }], + }); + assert.equal(reg.entries.length, 0, 'no entry from generic "repo"'); +}); + +test('@attribution @earliest-wins: introducer is the earliest speaker; later mentions do not override', () => { + const lexicon = [...LEXICON, { canonical: 'pi.ruv.io', name: 'pi.ruv.io', aliases: ['pi.ruv.io'], owner: null, url: 'https://pi.ruv.io' }]; + const reg = buildRegistry({ + sources: [ + { date: '2026-04-24', source: 'chat-0424', text: 'Reuven: pi.ruv.io is my go-to' }, + { date: '2026-03-13', source: 'chat-0313', text: 'Stuart: pi.ruv.io overview attached' }, + ], + lexicon, + }); + const e = find(reg, 'pi.ruv.io'); + assert.equal(e.introducer, 'Stuart'); + assert.equal(e.first_seen, '2026-03-13'); + assert.equal(e.mention_count, 2); +}); + +test('@attribution @unknown-speaker: a mention with no resolvable speaker is kept but flagged', () => { + const reg = buildRegistry({ + sources: [{ date: '2026-05-20', source: 'chat-0520', text: 'https://github.com/ruvnet/ruflo was merged' }], + }); + const e = find(reg, 'ruvnet/ruflo'); + assert.ok(e); + assert.equal(e.introducer, 'unknown'); + assert.ok(e.flags.includes('introducer-unverified')); +}); + +test('@seed @merge: a curated seed entry merges with a later automated mention', () => { + const reg = buildRegistry({ + seed: [{ canonical: 'agenticsorg/OIA-Model', name: 'OIA-Model', owner: 'agenticsorg', url: 'https://github.com/agenticsorg/OIA-Model', introducer: 'nicholas-ruest', first_seen: '2026-02-19', source: 'member-intel' }], + sources: [{ date: '2026-06-24', source: 'chat-0624', text: 'Nick: https://github.com/agenticsorg/OIA-Model' }], + }); + const matches = reg.entries.filter((e) => e.canonical === 'agenticsorg/OIA-Model'); + assert.equal(matches.length, 1, 'exactly one merged entry'); + assert.equal(matches[0].introducer, 'nicholas-ruest'); + assert.equal(matches[0].first_seen, '2026-02-19'); + assert.ok(matches[0].mention_count >= 2); +}); + +test('@dedupe: URL and lexicon references to the same repo collapse to one entry', () => { + const lexicon = [...LEXICON, { canonical: 'CraftsMan-Labs/SimpleAgents', name: 'SimpleAgents', aliases: ['SimpleAgents'], owner: 'CraftsMan-Labs', url: 'https://github.com/CraftsMan-Labs/SimpleAgents' }]; + const reg = buildRegistry({ + sources: [ + { date: '2026-04-09', source: 'chat-a', text: 'Rishub: https://github.com/CraftsMan-Labs/SimpleAgents' }, + { date: '2026-04-09', source: 'chat-b', text: 'Martin: loving SimpleAgents' }, + ], + lexicon, + }); + const matches = reg.entries.filter((e) => e.canonical === 'CraftsMan-Labs/SimpleAgents'); + assert.equal(matches.length, 1); + assert.equal(matches[0].mention_count, 2); +}); + +test('@output @public-safe: published registry carries only whitelisted fields, no PII', () => { + const reg = buildRegistry({ + seed: [{ canonical: 'x/y', name: 'y', owner: 'x', url: 'https://github.com/x/y', introducer: 'someone', first_seen: '2026-01-01', source: 's', contact: 'a@b.com' }], + }); + const pub = toPublicRegistry(reg.entries); + const allowed = ['canonical', 'name', 'owner', 'url', 'introducer', 'first_seen', 'mention_count', 'sources', 'flags']; + for (const e of pub.entries) { + assert.deepEqual(Object.keys(e).sort(), [...allowed].sort()); + } + const blob = JSON.stringify(pub); + assert.ok(!/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/.test(blob), 'no email addresses'); + // Phone = a digit run with >= 10 digits (ISO dates carry 8 and must not count). + const hasPhone = (blob.match(/\+?\d[\d\s().-]{7,}\d/g) || []).some((r) => r.replace(/\D/g, '').length >= 10); + assert.ok(!hasPhone, 'no phone numbers'); +}); + +test('@output @public-safe: the PII guard actually fires when a phone leaks into a whitelisted field', () => { + const entries = [{ canonical: 'x/y', name: '+1 312-953-9668', owner: 'x', url: null, introducer: 'a', first_seen: '2026-01-01', mention_count: 1, sources: ['s'], flags: [] }]; + assert.throws(() => toPublicRegistry(entries), /phone number/); +}); + +test('@output @deterministic: rebuilding the same inputs produces identical entries', () => { + const inputs = { sources: [{ date: '2026-04-09', source: 's', text: 'Rishub: https://github.com/CraftsMan-Labs/SimpleAgents' }] }; + const a = buildRegistry(inputs); + const b = buildRegistry(inputs); + assert.equal(JSON.stringify(a.entries), JSON.stringify(b.entries)); +}); + +test('@coverage @honesty: unparsed sources are reported, never silently dropped', () => { + const reg = buildRegistry({ + sources: [{ date: '2026-04-09', source: 's', text: 'Rishub: https://github.com/CraftsMan-Labs/SimpleAgents' }], + notYetParsed: ['2026-06-10 OSS Meet transcript', '2026-06-24 OSS Meet transcript'], + }); + assert.equal(reg.report.parsed_sources, 1); + assert.deepEqual(reg.report.not_yet_parsed, ['2026-06-10 OSS Meet transcript', '2026-06-24 OSS Meet transcript']); +});