From cd2c659e83b5927c6b7fdbe75ab642ae4a3404b7 Mon Sep 17 00:00:00 2001 From: sungjunlee Date: Tue, 2 Jun 2026 11:25:35 +0900 Subject: [PATCH] feat(spec): add report-first capability grill workflow Refs #176 Closes #177 Closes #178 Closes #179 Closes #180 Closes #181 --- skills/spec-charter/SKILL.md | 10 +- skills/spec-grill/SKILL.md | 67 ++- skills/spec-grill/scripts/extract-signals.js | 416 +++++++++++++++++- .../scripts/extract-signals.test.js | 137 ++++++ skills/spec-system-map/SKILL.md | 6 +- .../spec-system-map/templates/system-map.md | 4 + spec/capabilities.md | 15 +- spec/system-map.md | 10 + 8 files changed, 632 insertions(+), 33 deletions(-) diff --git a/skills/spec-charter/SKILL.md b/skills/spec-charter/SKILL.md index 1f60472..409e25c 100644 --- a/skills/spec-charter/SKILL.md +++ b/skills/spec-charter/SKILL.md @@ -35,9 +35,11 @@ Resolve helper scripts from the installed `spec-charter` skill directory, not fr End every mode with a short summary: -- `create`: created files, unresolved assumptions, and whether `spec-system-map` and `spec-grill` are recommended next steps. +- `create`: created files, unresolved assumptions, and a concrete next natural-language action. On brownfield repos, recommend creating `spec/system-map.md` before asking `spec-grill` to review capability boundaries. - `amend`: accepted changes, refused/parked changes, proof cited for status advances, and size-check result. -- `reassess`: required report sections from the Reassess Mode dispatch contract, with one recommended next step. +- `reassess`: required report sections from the Reassess Mode dispatch contract, with one recommended next natural-language action. + +When recommending follow-up spec work, do not require users to memorize downstream arguments such as `map`, `fill`, or `audit`. Prefer plain actions like "create the system map" or "ask spec-grill to review candidate capability boundaries." Include 2-5 candidate boundary names only when they are supported by evidence from README, `spec/system-map.md`, scripts, tests, docs, or recent commit scopes. ## What spec/charter.md Is @@ -71,7 +73,7 @@ Use create mode when neither `spec/charter.md` nor legacy root `CHARTER.md` exis 1. Draft from repo signals: product/user-facing signals (`README.md`, open epics/issues, `CHANGELOG.md`) before development-harness signals (`CLAUDE.md`, `AGENTS.md`). Harness files may inform workflow conventions, local commands, and repo-specific guardrails, but they do not override README, charter, issues, code structure, or user interview answers for product/capability authority unless they explicitly describe product boundaries. When signals conflict, surface the conflict in the interview rather than picking silently. 2. Interview the user to fill and sharpen Problem, Approach, Non-Goals, and initial Objectives. Follow the checklist in `references/create.md`: Problem framing options, the wedge test for Approach, Non-Goals elicitation, and Objective framing that cites `references/objectives.md`. 3. Create `spec/` if needed, then write `spec/charter.md` from `templates/charter.md` with `revision: 1` and today's `last_amended`. The Decisions table may be left empty. Seed 3-5 rows only when prior design docs, ADRs, or notable merged PRs already record direction; whatever lands becomes immutable from revision 2. -4. If the target repo is brownfield, recommend `spec-system-map` and `spec-grill` as the next steps. Brownfield signals include existing source roots (`src/`, `app/`, `lib/`, `packages/`, `skills/`), commit history, tests/scripts/config, open issues, or multiple top-level feature/workflow surfaces. +4. If the target repo is brownfield, recommend `spec-system-map` as the next step when `spec/system-map.md` is absent. After the map exists, recommend asking `spec-grill` to review candidate capability boundaries. Brownfield signals include existing source roots (`src/`, `app/`, `lib/`, `packages/`, `skills/`), commit history, tests/scripts/config, open issues, or multiple top-level feature/workflow surfaces. Objective conventions: @@ -107,6 +109,8 @@ Use reassess mode when the user asks whether `spec/charter.md`, `spec/system-map Reassess never edits files. It diagnoses drift and recommends next actions; accepted fixes must run through `spec-charter amend`, `spec-system-map amend`, `spec-grill `, or a separate user-approved Learning Action. +If reassess finds that `spec/system-map.md` is missing on a brownfield repo, recommend creating the system map before capability grilling. If `spec/system-map.md` exists and `spec/capabilities.md` is missing or thin, recommend asking `spec-grill` to review the candidate capability boundaries. Name concrete candidates only when evidence supports them; otherwise say which evidence is missing. + Dispatch contract: 1. Resolve helper scripts from the installed dev-backlog skill directory; if unavailable, report **Missing Evidence**. diff --git a/skills/spec-grill/SKILL.md b/skills/spec-grill/SKILL.md index 8add0be..490487d 100644 --- a/skills/spec-grill/SKILL.md +++ b/skills/spec-grill/SKILL.md @@ -1,6 +1,6 @@ --- name: spec-grill -argument-hint: "[capability-slug]" +argument-hint: "[natural-language request]" description: "Create or refine spec/capabilities.md by grilling existing repo signals into capability contracts, Behaviors, and Hard Constraints. Use after spec-charter on existing repos, or when users ask for capability specs, component contracts, middle-layer specs, repo capability boundaries, 능력 명세, or grill." compatibility: Requires git. Works on Claude Code and Codex. metadata: @@ -15,10 +15,19 @@ Use this after `spec-charter create` on existing/brownfield repos, or whenever t ## Execution Contract -### Invocation +### Intent Router -- `spec-grill`: greenfield or brownfield capability-spec creation. If `spec/capabilities.md` does not exist, create it from `templates/capabilities.md`, then interview one capability at a time. -- `spec-grill `: refine exactly one existing capability block. If the file is absent, fall back to greenfield mode and surface the absence. +Do not require users to memorize arguments. Interpret the user's request and choose the safest matching route. Power-user aliases such as `map`, `fill`, `audit`, and exact capability slugs are accepted, but they are optional shorthand, not the primary workflow. + +| User intent | Route | Writes? | +|-------------|-------|---------| +| No argument, ambiguous capability request, or "look at the capabilities" | **Grill Report**: diagnose current evidence and recommend next action. | No | +| "Find capability candidates", "map repo capability boundaries", or `map` | **Candidate Boundary Report**: collect raw candidates and classify them as accepted / rejected / merged / split candidates. | No | +| "Add the next missing capability", "fill the missing capability", or `fill` | **Next Capability Proposal**: propose exactly one missing capability and ask for confirmation before editing. | Only after confirmation | +| Mentions a known capability slug or natural-language capability area | **Specific Capability Review**: resolve the mention to one capability or candidate and deep-review only that block or candidate. | No by default | +| "Audit capabilities", "find overlap", "find stale contracts", "find weak predicates", or `audit` | **Capability Audit Report**: report stale, overlapping, weak, or unsupported capability predicates. | No | + +If intent is unclear, prefer report-only. If the user asks for an edit while evidence is weak, emit the report first, identify the missing evidence, and ask before writing. Capability slugs are strict routing handles used by sprint `component:` frontmatter. Keep them lowercase and singular, then put nuance in Goal/Scope prose. @@ -26,7 +35,7 @@ Capability slugs are strict routing handles used by sprint `component:` frontmat Resolve helper scripts from the installed `spec-grill` skill directory, not from the target repo. In a source checkout, that means the local `scripts/` directory beside this `SKILL.md`. Always pass the target repo explicitly (`--repo-root `) so helpers do not inspect the skill directory by accident. -On a brownfield repo with no `spec/capabilities.md`, run `extract-signals.js --repo-root --json` first. The script reports raw capability signals with draft Goal + draft Scope. It never writes `spec/capabilities.md`; admission, merging, splitting, and naming belong to this skill. +On a brownfield repo with no `spec/capabilities.md`, or when candidate evidence is requested, run `extract-signals.js --repo-root --json` first. The script reports raw capability evidence. It never writes `spec/capabilities.md`; admission, merging, splitting, and naming belong to this skill. ### Completion Contract @@ -36,11 +45,53 @@ End every run with a short summary: - predicates rejected or rewritten - constraints added - raw candidates merged/split/refused +- behaviors promoted to constraints +- missing proof or evidence - follow-up Learning Actions if any +### Grill Report Contract + +Use this report shape for no-arg, ambiguous, candidate-discovery, and audit routes unless the user asks for a shorter answer: + +```md +## Grill Report + +### Evidence Read +- + +### Evidence Missing +- + +### Raw Candidates +- - evidence: ; caveat: + +### Accepted / Rejected / Merged / Split Candidates +- Accepted: - +- Rejected: - +- Merged: + -> - +- Split: -> , - + +### Sharp Questions +- : + +### 3-Axis Predicate Findings +- Rejected predicates: - failed +- Rewritten predicates: -> +- Behaviors promoted to constraints: -> +- Missing proof/evidence: - needs + +### Proposed Next Capability +- - + +### Recommended Edit +- +``` + +Separate diagnosis from mutation. The report can recommend edits, but it must not edit `spec/capabilities.md` unless the user clearly asked for editing or confirms the proposed edit. + ## Brownfield Signal Rules -`extract-signals.js` draws from README, `spec/charter.md` with legacy root `CHARTER.md` fallback, `CLAUDE.md`/`AGENTS.md`, top-level source dirs, and recent commit messages. +`extract-signals.js` draws from README, `spec/charter.md` with legacy root `CHARTER.md` fallback, `spec/system-map.md`, `CLAUDE.md`/`AGENTS.md`, top-level source dirs, skill files, script surfaces, docs, tests, and recent commit messages. Use the draft as interview seed only. The script labels signal authority: @@ -49,7 +100,7 @@ Use the draft as interview seed only. The script labels signal authority: - commit scopes are history. - `CLAUDE.md`/`AGENTS.md` are development-harness context. -Harness context can seed questions about conventions and workflow, but it must not create accepted capability boundaries by itself. The script clusters by code organization, while real capabilities are functional contracts; expect grill mode to merge, split, or regroup raw signals rather than adopt them verbatim. +Harness context can seed questions about conventions and workflow, but it must not create accepted capability boundaries by itself. The script clusters evidence from code organization and command surfaces, while real capabilities are functional contracts; expect grill mode to merge, split, or regroup raw signals rather than adopt them verbatim. ## File Shape @@ -102,7 +153,7 @@ Classify positive normal outcomes as Expected Behaviors. Classify bright-line ne ## Writing Rules -On first run, copy `templates/capabilities.md` to `spec/capabilities.md` at the repo root, then walk the interview for one capability. On rerun, edit only the named capability block and leave the rest of the file untouched. +When the user accepts a first capability edit and `spec/capabilities.md` is absent, copy `templates/capabilities.md` to `spec/capabilities.md` at the repo root, then write only the accepted capability. On rerun, edit only the named capability block and leave the rest of the file untouched. After applying an accepted change, do not bump a revision number on `spec/capabilities.md`; `git blame` is the source of truth. Note in the conversation which capability was edited. Echo charter Decisions at capability level only when they explain a Behavior or Hard Constraint; promote cross-cutting capability Decisions through `spec-charter amend`. diff --git a/skills/spec-grill/scripts/extract-signals.js b/skills/spec-grill/scripts/extract-signals.js index b14ba42..447f790 100644 --- a/skills/spec-grill/scripts/extract-signals.js +++ b/skills/spec-grill/scripts/extract-signals.js @@ -17,7 +17,7 @@ * Output: JSON of shape * { * signal_authority: [{ signal, authority, found, note }], - * capabilities: [{ name, signals, candidate_goal, candidate_scope }] + * capabilities: [{ name, signals, evidence, missing_evidence, candidate_goal, candidate_scope }] * } * * Same inputs produce the same draft (deterministic ordering). @@ -33,10 +33,11 @@ const { } = require("../../dev-backlog/scripts/spec-paths.js"); const SOURCE_ROOT_CANDIDATES = ["src", "lib", "app", "packages", "skills"]; +const EVIDENCE_KINDS = ["system_map", "readme", "skill", "scripts", "docs", "tests", "source_dirs", "commits"]; const SUMMARY_DIR_LIMIT = 5; const DEFAULT_COMMIT_LIMIT = 100; -function buildSignalAuthority({ readmeFound, charterFound, charterSource, harnessFiles, sourceRoot, commitsScanned }) { +function buildSignalAuthority({ readmeFound, charterFound, charterSource, systemMapFound, harnessFiles, sourceRoot, commitsScanned }) { return [ { signal: "README.md", @@ -52,6 +53,12 @@ function buildSignalAuthority({ readmeFound, charterFound, charterSource, harnes ? "Accepted project axis found through legacy root CHARTER.md fallback; migrate to spec/charter.md." : "Accepted project axis; Objectives can constrain capability candidates.", }, + { + signal: "spec/system-map.md", + authority: "system-shape", + found: systemMapFound, + note: "High-level boundaries, flows, invariants, and candidate capability handoff evidence.", + }, { signal: "CLAUDE.md/AGENTS.md", authority: "development-harness", @@ -64,6 +71,12 @@ function buildSignalAuthority({ readmeFound, charterFound, charterSource, harnes found: sourceRoot !== null, note: "Code organization evidence; useful as raw candidate surface, not final capability authority.", }, + { + signal: "skill/script/doc/test surfaces", + authority: "repo-surface", + found: sourceRoot !== null, + note: "Command and documentation surfaces can support candidates, but do not admit capabilities by themselves.", + }, { signal: "git commit scopes", authority: "history", @@ -179,6 +192,281 @@ function readOptionalFile(filePath, { readFile = fs.readFileSync, fileExists = f } } +function slugifyCandidate(value) { + return String(value || "") + .toLowerCase() + .replace(/`/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function makeEmptyEvidence() { + return Object.fromEntries(EVIDENCE_KINDS.map((kind) => [kind, []])); +} + +function addEvidence(candidates, name, kind, value) { + const slug = slugifyCandidate(name); + if (!slug || !EVIDENCE_KINDS.includes(kind) || !value) return; + if (!candidates.has(slug)) { + candidates.set(slug, { + name: slug, + signals: new Set(), + evidence: makeEmptyEvidence(), + missing_evidence: new Set(), + }); + } + const candidate = candidates.get(slug); + if (!candidate.evidence[kind].includes(value)) { + candidate.evidence[kind].push(value); + } + candidate.signals.add(value); +} + +function addMissingEvidence(candidates, name, value) { + const slug = slugifyCandidate(name); + if (!slug) return; + if (!candidates.has(slug)) { + candidates.set(slug, { + name: slug, + signals: new Set(), + evidence: makeEmptyEvidence(), + missing_evidence: new Set(), + }); + } + candidates.get(slug).missing_evidence.add(value); +} + +function getMarkdownSection(content, heading) { + if (!content) return null; + const lines = content.split("\n"); + const startPattern = new RegExp(`^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "i"); + const start = lines.findIndex((line) => startPattern.test(line.trim())); + if (start === -1) return null; + const section = []; + for (let i = start + 1; i < lines.length; i += 1) { + if (/^##\s+/.test(lines[i])) break; + section.push(lines[i]); + } + return section.join("\n").trim(); +} + +function collectSystemMapCandidates(systemMap) { + const section = getMarkdownSection(systemMap, "Candidate Capability Boundaries"); + if (!section) return []; + const candidates = []; + for (const line of section.split("\n")) { + const match = line.match(/^-\s+`?([a-z][a-z0-9-]*)`?\s+-\s+(.+)$/); + if (!match) continue; + candidates.push({ + name: match[1], + signal: `system-map:${match[1]} (${match[2].trim()})`, + }); + } + return candidates; +} + +function collectReadmeCandidates(readme) { + if (!readme) return []; + const candidates = []; + let activeHeading = null; + for (const line of readme.split("\n")) { + const heading = line.match(/^#{2,4}\s+(.+?)\s*$/); + if (heading) { + activeHeading = /capabilit|feature|support|command|skill/i.test(heading[1]) + ? heading[1].trim() + : null; + continue; + } + if (!activeHeading) continue; + const bullet = line.match(/^-\s+(?:`([^`]+)`|([A-Za-z][A-Za-z0-9 -]{2,60}))(?:\s+[-:\u2013\u2014]\s+(.+))?/); + if (!bullet) continue; + const rawName = bullet[1] || bullet[2]; + const name = slugifyCandidate(rawName.split(/\s+/).slice(0, 4).join("-")); + if (!name) continue; + candidates.push({ + name, + signal: `README:${activeHeading}: ${line.trim()}`, + }); + } + return candidates; +} + +function listDirs(root, { readdir = fs.readdirSync, statSync = fs.statSync, fileExists = fs.existsSync } = {}) { + if (!fileExists(root)) return []; + return readdir(root) + .filter((entry) => { + if (entry.startsWith(".") || entry.startsWith("_")) return false; + try { + return statSync(path.join(root, entry)).isDirectory(); + } catch { + return false; + } + }) + .sort(); +} + +function collectSkillCandidates(repoRoot, deps = {}) { + const skillsRoot = path.join(repoRoot, "skills"); + return listDirs(skillsRoot, deps).flatMap((entry) => { + const skillPath = path.join(skillsRoot, entry, "SKILL.md"); + const content = readOptionalFile(skillPath, deps); + if (!content) return []; + const name = content.match(/^name:\s*"?([^"\n]+)"?/m)?.[1]?.trim() || entry; + const description = content.match(/^description:\s*"?([^"\n]+)"?/m)?.[1]?.trim() || "skill surface"; + return [{ name, signal: `skill:${entry} (${description.slice(0, 120)})` }]; + }); +} + +function collectScriptCandidates(repoRoot, deps = {}) { + const skillsRoot = path.join(repoRoot, "skills"); + const candidates = []; + for (const skill of listDirs(skillsRoot, deps)) { + const scriptsRoot = path.join(skillsRoot, skill, "scripts"); + for (const entry of listScriptFiles(scriptsRoot, deps)) { + if (/\.test\.js$|\.integration\.test\.js$|\.cli\.test\.js$/.test(entry)) continue; + const base = entry.replace(/\.(test|cli|integration)\.js$/, "").replace(/\.(js|sh)$/, ""); + candidates.push({ + name: base, + signal: `script:skills/${skill}/scripts/${entry}`, + }); + } + } + candidates.push(...collectCliCommandCandidates(repoRoot, deps)); + return candidates; +} + +function listScriptFiles(root, { readdir = fs.readdirSync, statSync = fs.statSync, fileExists = fs.existsSync } = {}) { + if (!fileExists(root)) return []; + return readdir(root) + .filter((entry) => { + try { + return statSync(path.join(root, entry)).isFile() && /\.(?:[cm]?[jt]s|sh)$/.test(entry); + } catch { + return false; + } + }) + .sort(); +} + +function collectCliCommandCandidates(repoRoot, deps = {}) { + const srcRoot = path.join(repoRoot, "src"); + const candidates = []; + for (const packageName of listDirs(srcRoot, deps)) { + const commandsRoot = path.join(srcRoot, packageName, "cli", "commands"); + for (const entry of listScriptFiles(commandsRoot, deps)) { + if (/\.test\.[cm]?[jt]s$/.test(entry)) continue; + const base = entry.replace(/\.[cm]?[jt]s$/, ""); + candidates.push({ + name: base, + signal: `script:src/${packageName}/cli/commands/${entry}`, + }); + } + } + return candidates; +} + +function collectSourceSurfaceCandidates(repoRoot, deps = {}) { + const srcRoot = path.join(repoRoot, "src"); + const candidates = []; + for (const packageName of listDirs(srcRoot, deps)) { + const sourcesRoot = path.join(srcRoot, packageName, "sources"); + for (const entry of listSourceSurfaceEntries(sourcesRoot, deps)) { + const base = entry.replace(/\.[cm]?[jt]s$/, ""); + candidates.push({ + name: base, + signal: `source:src/${packageName}/sources/${entry}`, + }); + } + } + return candidates; +} + +function listSourceSurfaceEntries(root, { readdir = fs.readdirSync, statSync = fs.statSync, fileExists = fs.existsSync } = {}) { + if (!fileExists(root)) return []; + return readdir(root) + .filter((entry) => { + if (entry.startsWith(".") || entry.startsWith("_")) return false; + try { + const stat = statSync(path.join(root, entry)); + return stat.isDirectory() || (stat.isFile() && /\.[cm]?[jt]s$/.test(entry) && !/\.test\.[cm]?[jt]s$/.test(entry)); + } catch { + return false; + } + }) + .sort(); +} + +function collectDocCandidates(repoRoot, deps = {}, knownNames = []) { + const roots = ["docs", "skills"]; + const candidates = []; + const known = knownNames.map((name) => slugifyCandidate(name)).filter(Boolean).sort((a, b) => b.length - a.length); + for (const rootName of roots) { + const root = path.join(repoRoot, rootName); + for (const relPath of listMarkdownFiles(root, deps).slice(0, 100)) { + const normalized = relPath.replace(/\\/g, "/"); + const normalizedSlug = slugifyCandidate(normalized); + const matched = known.find((name) => normalizedSlug.includes(name)); + if (!matched) continue; + candidates.push({ + name: matched, + signal: `doc:${rootName}/${normalized}`, + }); + } + } + return candidates; +} + +function listMarkdownFiles(root, deps = {}, prefix = "") { + const { readdir = fs.readdirSync, statSync = fs.statSync, fileExists = fs.existsSync } = deps; + if (!fileExists(root)) return []; + const files = []; + for (const entry of readdir(root).sort()) { + if (entry.startsWith(".")) continue; + const full = path.join(root, entry); + const rel = path.join(prefix, entry); + let stat; + try { + stat = statSync(full); + } catch { + continue; + } + if (stat.isDirectory()) { + files.push(...listMarkdownFiles(full, deps, rel)); + } else if (stat.isFile() && entry.endsWith(".md")) { + files.push(rel); + } + } + return files; +} + +function collectTestCandidates(repoRoot, deps = {}) { + const skillsRoot = path.join(repoRoot, "skills"); + const candidates = []; + for (const skill of listDirs(skillsRoot, deps)) { + const scriptsRoot = path.join(skillsRoot, skill, "scripts"); + for (const entry of listScriptFiles(scriptsRoot, deps)) { + if (!/\.test\.js$|\.integration\.test\.js$|\.cli\.test\.js$/.test(entry)) continue; + const base = entry.replace(/\.(integration|cli)\.test\.js$/, "").replace(/\.test\.js$/, ""); + candidates.push({ + name: base, + signal: `test:skills/${skill}/scripts/${entry}`, + }); + } + } + candidates.push(...collectSourceTestCandidates(repoRoot, deps)); + return candidates; +} + +function collectSourceTestCandidates(repoRoot, deps = {}) { + const testsRoot = path.join(repoRoot, "tests", "unit", "sources"); + return listScriptFiles(testsRoot, deps) + .filter((entry) => /\.(test|spec)\.[cm]?[jt]s$|^[^.]+\.[cm]?[jt]s$/.test(entry)) + .map((entry) => ({ + name: entry.replace(/\.(test|spec)\.[cm]?[jt]s$/, "").replace(/\.[cm]?[jt]s$/, ""), + signal: `test:tests/unit/sources/${entry}`, + })); +} + function resolveCharterFile(repoRoot, deps = {}) { const resolved = resolveCharterPath({ repoRoot, fileExists: deps.fileExists }); if (!resolved.found) { @@ -222,19 +510,29 @@ function summarizeReadme(readme) { return null; } -function buildCapability({ name, sourceRootName, signals, readmeSummary, charterObjectives }) { +function buildCapability({ name, sourceRootName, signals, evidence, missingEvidence, readmeSummary, charterObjectives }) { const directorySignal = sourceRootName ? signals.find((signal) => signal === `${sourceRootName}/${name}/`) || null : null; const commitSignals = signals.filter((signal) => signal.startsWith("commit-scope:")); + const systemMapSignals = evidence?.system_map || []; + const scriptSignals = evidence?.scripts || []; + const evidenceHint = systemMapSignals[0] || scriptSignals[0] || null; + let candidateGoal = `Draft: what the user observes when the '${name}' capability works. Fill in via grill.`; + if (evidenceHint) { + candidateGoal = `Draft (from evidence): ${evidenceHint} - refine via grill so the Goal names what the user observes when '${name}' works.`; + } else if (readmeSummary) { + candidateGoal = `Draft (from README): ${readmeSummary} - refine via grill so the Goal names what the user observes when '${name}' works.`; + } - const candidateGoal = readmeSummary - ? `Draft (from README): ${readmeSummary} — refine via grill so the Goal names what the user observes when '${name}' works.` - : `Draft: what the user observes when the '${name}' capability works. Fill in via grill.`; - - const candidateScope = directorySignal - ? `Owns the ${directorySignal} surface. Out-of-scope deferred to grill.` - : `Inferred from commit scope '${name}'. Confirm the owning source surface and out-of-scope boundary in grill.`; + let candidateScope = `Inferred from raw evidence for '${name}'. Confirm the owning surface and out-of-scope boundary in grill.`; + if (systemMapSignals[0]) { + candidateScope = `Inferred from ${systemMapSignals[0]}. Confirm ownership, neighboring candidates, and out-of-scope boundary in grill.`; + } else if (directorySignal) { + candidateScope = `Owns the ${directorySignal} surface. Out-of-scope deferred to grill.`; + } else if (commitSignals.length > 0) { + candidateScope = `Inferred from commit scope '${name}'. Confirm the owning source surface and out-of-scope boundary in grill.`; + } const objectiveHint = charterObjectives.length > 0 ? ` Candidate charter objective served: ${charterObjectives[0].id} (${charterObjectives[0].predicate.slice(0, 80)}${charterObjectives[0].predicate.length > 80 ? "..." : ""}). Confirm in grill.` @@ -247,12 +545,15 @@ function buildCapability({ name, sourceRootName, signals, readmeSummary, charter directory: directorySignal, commit_scopes: commitSignals, }, + evidence: evidence || makeEmptyEvidence(), + missing_evidence: missingEvidence || [], + confidence: "candidate-only", candidate_goal: candidateGoal + objectiveHint, candidate_scope: candidateScope, }; } -function mergeCandidates({ sourceRoot, dirNames, scopeCounts }) { +function mergeCandidates({ sourceRoot, dirNames, scopeCounts, evidenceCandidates = [] }) { const merged = new Map(); for (const name of dirNames) { @@ -268,8 +569,27 @@ function mergeCandidates({ sourceRoot, dirNames, scopeCounts }) { merged.set(scope, [`commit-scope:${scope} (${count})`]); } - return [...merged.entries()] - .sort(([a], [b]) => a.localeCompare(b)); + for (const { name, signal } of evidenceCandidates) { + const slug = slugifyCandidate(name); + if (!slug || !signal) continue; + const signals = merged.get(slug) || []; + if (!signals.includes(signal)) signals.push(signal); + merged.set(slug, signals); + } + + return [...merged.entries()].sort(([a, aSignals], [b, bSignals]) => { + const scoreDiff = candidateSignalScore(aSignals) - candidateSignalScore(bSignals); + return scoreDiff || a.localeCompare(b); + }); +} + +function candidateSignalScore(signals) { + if (signals.some((signal) => signal.startsWith("system-map:"))) return 0; + if (signals.some((signal) => signal.startsWith("skill:") || /^[a-z]+\/.+\/$/.test(signal))) return 1; + if (signals.some((signal) => signal.startsWith("script:") || signal.startsWith("test:"))) return 2; + if (signals.some((signal) => signal.startsWith("commit-scope:"))) return 3; + if (signals.some((signal) => signal.startsWith("doc:"))) return 4; + return 5; } function extractSignals({ @@ -285,6 +605,7 @@ function extractSignals({ const readme = readOptionalFile(path.join(repoRoot, "README.md"), deps); const charter = resolveCharterFile(repoRoot, deps); + const systemMap = readOptionalFile(path.join(repoRoot, "spec", "system-map.md"), deps); const claudeMd = readOptionalFile(path.join(repoRoot, "CLAUDE.md"), deps); const agentsMd = readOptionalFile(path.join(repoRoot, "AGENTS.md"), deps); const harnessFiles = [ @@ -297,6 +618,7 @@ function extractSignals({ const scopeCounts = extractCommitScopes(commitMessages); const charterObjectives = readCharterObjectives(repoRoot, deps); const readmeSummary = summarizeReadme(readme); + const groupedEvidence = new Map(); const inventory = { repoRoot: path.resolve(repoRoot), @@ -304,6 +626,7 @@ function extractSignals({ charterFound: charter.found, charterPath: charter.found ? charter.path : null, charterSource: charter.source, + systemMapFound: systemMap !== null, claudeMdFound: harnessFiles.length > 0, harnessFiles, sourceRoot: sourceRoot ? sourceRoot.name : null, @@ -316,18 +639,63 @@ function extractSignals({ readmeFound: readme !== null, charterFound: charter.found, charterSource: charter.source, + systemMapFound: systemMap !== null, harnessFiles, sourceRoot, commitsScanned: commitMessages.length, }); - const candidates = sourceRoot ? mergeCandidates({ sourceRoot, dirNames, scopeCounts }) : []; + for (const name of dirNames) { + if (sourceRoot) addEvidence(groupedEvidence, name, "source_dirs", `${sourceRoot.name}/${name}/`); + } + for (const [scope, count] of scopeCounts.entries()) { + if (count >= 2 || dirNames.includes(scope)) { + addEvidence(groupedEvidence, scope, "commits", `commit-scope:${scope} (${count})`); + } + } + for (const candidate of collectSystemMapCandidates(systemMap)) { + addEvidence(groupedEvidence, candidate.name, "system_map", candidate.signal); + } + for (const candidate of collectReadmeCandidates(readme)) { + addEvidence(groupedEvidence, candidate.name, "readme", candidate.signal); + } + for (const candidate of collectSkillCandidates(repoRoot, deps)) { + addEvidence(groupedEvidence, candidate.name, "skill", candidate.signal); + } + for (const candidate of collectSourceSurfaceCandidates(repoRoot, deps)) { + addEvidence(groupedEvidence, candidate.name, "source_dirs", candidate.signal); + } + for (const candidate of collectScriptCandidates(repoRoot, deps)) { + addEvidence(groupedEvidence, candidate.name, "scripts", candidate.signal); + } + for (const candidate of collectTestCandidates(repoRoot, deps)) { + addEvidence(groupedEvidence, candidate.name, "tests", candidate.signal); + } + for (const candidate of collectDocCandidates(repoRoot, deps, [...groupedEvidence.keys()])) { + addEvidence(groupedEvidence, candidate.name, "docs", candidate.signal); + } + + if (!systemMap) { + for (const name of dirNames) addMissingEvidence(groupedEvidence, name, "spec/system-map.md"); + } + + const evidenceCandidates = [...groupedEvidence.values()].flatMap((candidate) => + EVIDENCE_KINDS.flatMap((kind) => + candidate.evidence[kind].map((signal) => ({ name: candidate.name, signal })), + ), + ); + + const candidates = sourceRoot + ? mergeCandidates({ sourceRoot, dirNames, scopeCounts, evidenceCandidates }) + : mergeCandidates({ sourceRoot: { name: "", path: "" }, dirNames: [], scopeCounts, evidenceCandidates }); const capabilities = candidates.map(([name, signals]) => buildCapability({ name, sourceRootName: sourceRoot ? sourceRoot.name : null, signals, + evidence: groupedEvidence.get(name)?.evidence, + missingEvidence: [...(groupedEvidence.get(name)?.missing_evidence || [])].sort(), readmeSummary, charterObjectives, }), @@ -343,6 +711,7 @@ function formatHumanReport(result) { lines.push("Signals:"); lines.push(` - README.md: ${inventory.readmeFound ? "found" : "missing"}`); lines.push(` - spec/charter.md: ${inventory.charterFound ? `found (${inventory.charterSource})` : "missing"}; objectives: ${inventory.charterObjectiveCount}`); + lines.push(` - spec/system-map.md: ${inventory.systemMapFound ? "found" : "missing"}`); lines.push(` - CLAUDE.md/AGENTS.md: ${inventory.claudeMdFound ? `found (${(inventory.harnessFiles || []).join(", ")})` : "missing"}; authority: development-harness`); lines.push(` - source root: ${inventory.sourceRoot ?? "none detected"} (${inventory.sourceDirCount} dir(s))`); lines.push(` - commits scanned: ${inventory.commitsScanned}; scopes seen: ${inventory.commitScopeCount}`); @@ -359,12 +728,19 @@ function formatHumanReport(result) { for (const cap of capabilities.slice(0, SUMMARY_DIR_LIMIT)) { lines.push(` - ${cap.name}`); lines.push(` signals: ${cap.signals.join(", ")}`); + const evidenceKinds = EVIDENCE_KINDS.filter((kind) => (cap.evidence?.[kind] || []).length > 0); + if (evidenceKinds.length > 0) { + lines.push(` evidence: ${evidenceKinds.join(", ")}`); + } + if ((cap.missing_evidence || []).length > 0) { + lines.push(` missing: ${cap.missing_evidence.join(", ")}`); + } } if (capabilities.length > SUMMARY_DIR_LIMIT) { lines.push(` ... and ${capabilities.length - SUMMARY_DIR_LIMIT} more (use --json for full draft)`); } lines.push(""); - lines.push("Next: invoke `spec-grill` to admit raw signals and interview compact capabilities into spec/capabilities.md."); + lines.push("Next: ask `spec-grill` to review these candidate capability boundaries before editing spec/capabilities.md."); return lines.join("\n"); } @@ -397,6 +773,16 @@ module.exports = { extractCommitScopes, getRecentCommitMessages, readOptionalFile, + slugifyCandidate, + collectSystemMapCandidates, + collectReadmeCandidates, + collectSkillCandidates, + collectScriptCandidates, + collectCliCommandCandidates, + collectSourceSurfaceCandidates, + collectDocCandidates, + collectTestCandidates, + collectSourceTestCandidates, resolveCharterFile, CANONICAL_CHARTER_PATH, LEGACY_CHARTER_PATH, diff --git a/skills/spec-grill/scripts/extract-signals.test.js b/skills/spec-grill/scripts/extract-signals.test.js index 93e8677..f875b47 100644 --- a/skills/spec-grill/scripts/extract-signals.test.js +++ b/skills/spec-grill/scripts/extract-signals.test.js @@ -9,6 +9,16 @@ const { listCapabilityCandidates, extractCommitScopes, readOptionalFile, + slugifyCandidate, + collectSystemMapCandidates, + collectReadmeCandidates, + collectSkillCandidates, + collectScriptCandidates, + collectCliCommandCandidates, + collectSourceSurfaceCandidates, + collectDocCandidates, + collectTestCandidates, + collectSourceTestCandidates, resolveCharterFile, readCharterObjectives, summarizeReadme, @@ -231,6 +241,76 @@ Tamgu Note helps parents discover children's hidden talents through AI-powered a }); }); +describe("evidence collectors", () => { + let repo; + beforeEach(() => { repo = makeRepo(); }); + afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); }); + + it("slugifies candidate handles deterministically", () => { + assert.equal(slugifyCandidate("Sync Pull!"), "sync-pull"); + assert.equal(slugifyCandidate("`backlog-sync`"), "backlog-sync"); + }); + + it("parses system-map candidate boundaries", () => { + const systemMap = `# Map + +## Candidate Capability Boundaries + +- \`backlog-sync\` - evidence: sync flow; owns task mirrors; uncertainty: triage boundary. +- \`triage-grooming\` - evidence: report flow; owns issue review; uncertainty: apply boundary. + +## Where To Go Next +`; + const candidates = collectSystemMapCandidates(systemMap); + assert.deepEqual(candidates.map((c) => c.name), ["backlog-sync", "triage-grooming"]); + assert.match(candidates[0].signal, /sync flow/); + }); + + it("collects README capability-like bullets only under relevant headings", () => { + const readme = `# Project + +## Features +- \`backlog-sync\` - mirror issues locally +- Triage grooming: classify issues + +## License +- MIT +`; + const candidates = collectReadmeCandidates(readme); + assert.deepEqual(candidates.map((c) => c.name), ["backlog-sync", "triage-grooming"]); + }); + + it("collects skill, script, docs, and paired test evidence", () => { + write(repo, "skills/spec-grill/SKILL.md", `--- +name: spec-grill +description: Create capability contracts. +--- +`); + write(repo, "skills/spec-grill/scripts/extract-signals.js", ""); + write(repo, "skills/spec-grill/scripts/extract-signals.test.js", ""); + write(repo, "skills/spec-grill/references/capabilities.md", "# Capability reference\n"); + write(repo, "docs/spec-system-design.md", "# Spec design\n"); + + assert.deepEqual(collectSkillCandidates(repo).map((c) => c.name), ["spec-grill"]); + assert.ok(collectScriptCandidates(repo).some((c) => c.name === "extract-signals")); + assert.ok(collectTestCandidates(repo).some((c) => c.name === "extract-signals")); + assert.deepEqual(collectDocCandidates(repo), []); + assert.ok(collectDocCandidates(repo, {}, ["spec-system"]).some((c) => c.signal.includes("docs/spec-system-design.md"))); + }); + + it("collects source commands, source surfaces, and source tests", () => { + write(repo, "src/kwi/cli/commands/github-pr-export.ts", ""); + write(repo, "src/kwi/cli/commands/github-pr-export.test.ts", ""); + write(repo, "src/kwi/sources/confluence.ts", ""); + write(repo, "src/kwi/sources/jira/index.ts", ""); + write(repo, "tests/unit/sources/confluence.test.ts", ""); + + assert.deepEqual(collectCliCommandCandidates(repo).map((c) => c.name), ["github-pr-export"]); + assert.deepEqual(collectSourceSurfaceCandidates(repo).map((c) => c.name), ["confluence", "jira"]); + assert.deepEqual(collectSourceTestCandidates(repo).map((c) => c.name), ["confluence"]); + }); +}); + describe("buildSignalAuthority", () => { it("labels CLAUDE.md/AGENTS.md as development-harness authority", () => { const authority = buildSignalAuthority({ @@ -375,6 +455,7 @@ revision: 1 assert.equal(result.inventory.readmeFound, true); assert.equal(result.inventory.charterFound, true); assert.equal(result.inventory.charterSource, "canonical"); + assert.equal(result.inventory.systemMapFound, false); assert.equal(result.inventory.claudeMdFound, true); assert.deepEqual(result.inventory.harnessFiles, ["CLAUDE.md"]); assert.equal(result.inventory.sourceRoot, "src"); @@ -390,6 +471,9 @@ revision: 1 assert.ok(ingest.signals.some((s) => s.includes("src/ingest/"))); assert.ok(ingest.signals.some((s) => s.includes("commit-scope:ingest"))); assert.equal(ingest.provenance.directory, "src/ingest/"); + assert.ok(ingest.evidence.source_dirs.includes("src/ingest/")); + assert.ok(ingest.evidence.commits.some((s) => s.includes("commit-scope:ingest"))); + assert.deepEqual(ingest.missing_evidence, ["spec/system-map.md"]); assert.match(ingest.candidate_goal, /logging pipeline/); assert.match(ingest.candidate_goal, /O1/); @@ -458,10 +542,63 @@ revision: 1 const progressSync = result.capabilities.find((c) => c.name === "progress-sync"); assert.ok(progressSync); assert.equal(progressSync.provenance.directory, null); + assert.ok(progressSync.evidence.commits.some((s) => s.includes("commit-scope:progress-sync"))); assert.match(progressSync.candidate_scope, /Confirm the owning source surface/); assert.doesNotMatch(progressSync.candidate_scope, /src\/progress-sync/); }); + it("groups system-map, skill, script, docs, tests, and commits under candidates", () => { + write(repo, "README.md", `# Project + +## Features +- \`backlog-sync\` - mirror issues locally +`); + write(repo, "spec/system-map.md", `# Map + +## Candidate Capability Boundaries + +- \`backlog-sync\` - evidence: sync flow; owns task mirrors; uncertainty: triage boundary. +`); + write(repo, "skills/backlog-sync/SKILL.md", `--- +name: backlog-sync +description: Mirror issues. +--- +`); + write(repo, "skills/backlog-sync/scripts/sync-pull.js", ""); + write(repo, "skills/backlog-sync/scripts/sync-pull.test.js", ""); + write(repo, "docs/backlog-sync-guide.md", "# Backlog sync guide\n"); + write(repo, "src/kwi/cli/commands/github-pr-export.ts", ""); + write(repo, "src/kwi/sources/confluence.ts", ""); + write(repo, "tests/unit/sources/confluence.test.ts", ""); + + const result = extractSignals({ + repoRoot: repo, + exec: () => "feat(backlog-sync): add mirror\nfix(backlog-sync): preserve AC", + }); + + const candidate = result.capabilities.find((c) => c.name === "backlog-sync"); + assert.ok(candidate); + assert.ok(candidate.evidence.system_map.length > 0); + assert.ok(candidate.evidence.readme.length > 0); + assert.ok(candidate.evidence.skill.length > 0); + assert.ok(candidate.evidence.commits.length > 0); + assert.equal(candidate.missing_evidence.length, 0); + + const syncPull = result.capabilities.find((c) => c.name === "sync-pull"); + assert.ok(syncPull); + assert.ok(syncPull.evidence.scripts.length > 0); + assert.ok(syncPull.evidence.tests.length > 0); + + const githubPrExport = result.capabilities.find((c) => c.name === "github-pr-export"); + assert.ok(githubPrExport); + assert.ok(githubPrExport.evidence.scripts.length > 0); + + const confluence = result.capabilities.find((c) => c.name === "confluence"); + assert.ok(confluence); + assert.ok(confluence.evidence.source_dirs.length > 0); + assert.ok(confluence.evidence.tests.length > 0); + }); + it("brownfield-thin: only src/ + commits, no README/CLAUDE", () => { mkdir(repo, "src/worker"); const commits = ["feat(worker): initial scaffold", "fix(worker): race"]; diff --git a/skills/spec-system-map/SKILL.md b/skills/spec-system-map/SKILL.md index 070dba4..0e32227 100644 --- a/skills/spec-system-map/SKILL.md +++ b/skills/spec-system-map/SKILL.md @@ -32,9 +32,10 @@ When no mode is specified, route by file state. Create `spec/` if needed. 1. Read bounded signals: `spec/charter.md` if present, `README.md`, `AGENTS.md`/`CLAUDE.md`, top-level directories, package/config files, and existing docs that appear architecture-related. 2. Draft from `templates/system-map.md`; keep sections short and link out instead of expanding subsystem detail. -3. Include these sections: System Shape, Runtime Boundaries, Core Flows, Storage And External Systems, Project-Wide Invariants, Where To Go Next. +3. Include these sections: System Shape, Runtime Boundaries, Core Flows, Storage And External Systems, Project-Wide Invariants, Candidate Capability Boundaries, Where To Go Next. 4. If the repo is brownfield, explicitly mark uncertain boundaries as assumptions rather than inventing detail. -5. Recommend `spec-grill` when the map reveals durable capability boundaries that are not yet in `spec/capabilities.md`. +5. Use Candidate Capability Boundaries to hand off concrete, short candidates to `spec-grill`. Each candidate should name evidence, the contract surface it appears to own, and the uncertainty `spec-grill` must resolve. +6. Recommend asking `spec-grill` to review the candidate capability boundaries when the map reveals durable boundaries that are not yet in `spec/capabilities.md`. ## Amend Mode @@ -51,6 +52,7 @@ Before finishing, verify: - Every section names current project-wide facts, not aspirational design. - The map links to deeper docs instead of copying them. - No subsystem gets more detail than the whole-system flow needs. +- Candidate Capability Boundaries are short handoff candidates, not a module inventory. - No stale module-level TODOs, endpoint inventories, or runbook commands are included. ## Eval Prompts diff --git a/skills/spec-system-map/templates/system-map.md b/skills/spec-system-map/templates/system-map.md index 0b0b31e..0c9361f 100644 --- a/skills/spec-system-map/templates/system-map.md +++ b/skills/spec-system-map/templates/system-map.md @@ -23,6 +23,10 @@ - - +## Candidate Capability Boundaries + +- `` - evidence: ; owns ; uncertainty: . + ## Where To Go Next - Product direction: [`charter.md`](charter.md) diff --git a/spec/capabilities.md b/spec/capabilities.md index 52c9149..b83026d 100644 --- a/spec/capabilities.md +++ b/spec/capabilities.md @@ -121,6 +121,7 @@ Capability headings are strict routing handles. Use one lowercase slug after `## - `spec-system-map` create + amend modes - `spec/system-map.md` template and dogfood artifact - Boundaries between charter, system map, and capability contracts +- Candidate Capability Boundaries handoff to `spec-grill` - Demotion of module details, endpoint lists, and runbook commands into linked docs **Out-of-scope:** @@ -130,12 +131,13 @@ Capability headings are strict routing handles. Use one lowercase slug after `## ### Expected Behaviors - Create mode writes `spec/system-map.md` and creates `spec/` when needed. -- The map includes System Shape, Runtime Boundaries, Core Flows, Storage And External Systems, Project-Wide Invariants, and Where To Go Next. +- The map includes System Shape, Runtime Boundaries, Core Flows, Storage And External Systems, Project-Wide Invariants, Candidate Capability Boundaries, and Where To Go Next. - Brownfield uncertainty is labeled as an assumption instead of filled with invented details. ### Hard Constraints - Never include exhaustive module inventories, endpoint lists, deployment commands, or temporary TODOs in `spec/system-map.md`. - Never promote a subsystem detail unless it changes a project-wide boundary, flow, storage/external system, or invariant. +- Never let Candidate Capability Boundaries become accepted capability contracts; `spec-grill` owns admission, merge, split, and refusal. ### Learnings @@ -150,12 +152,13 @@ Capability headings are strict routing handles. Use one lowercase slug after `## ## Capability: spec-grill -**Goal:** A user turns existing repo signals into compact `spec/capabilities.md` capability contracts instead of stopping at a project-wide charter. +**Goal:** A user reviews existing repo signals through a report-first grill flow, then turns accepted boundaries into compact `spec/capabilities.md` capability contracts. **In-scope:** -- `spec-grill` greenfield and brownfield capability authoring -- `extract-signals.js` raw candidate seeding from README, charter, source roots, harness files, and commit scopes +- `spec-grill` natural-language intent routing and report-first diagnosis +- `extract-signals.js` raw evidence grouping from README, charter, system map, source roots, skill files, script surfaces, docs, tests, harness files, and commit scopes - Capability admission, Goal/Scope interview, Expected Behaviors, and Hard Constraints +- Grill Report sections: Evidence Read, Evidence Missing, Raw Candidates, Accepted / Rejected / Merged / Split Candidates, Sharp Questions, 3-Axis Predicate Findings, Proposed Next Capability, and Recommended Edit - `templates/capabilities.md` and `references/capabilities.md` **Out-of-scope:** @@ -164,12 +167,14 @@ Capability headings are strict routing handles. Use one lowercase slug after `## - Treating directory names or commit scopes as accepted capabilities without interview admission ### Expected Behaviors -- On brownfield repos without `spec/capabilities.md`, `extract-signals.js --repo-root --json` emits deterministic raw candidates and labels signal authority before any contract is accepted. +- Ambiguous or no-argument `spec-grill` requests emit a report and do not edit files. +- On brownfield repos, `extract-signals.js --repo-root --json` emits deterministic evidence-grouped raw candidates and labels signal authority before any contract is accepted. - `spec-grill ` edits only the named capability block and leaves other capability blocks, Learnings, and Decisions untouched. - Every accepted Behavior and Hard Constraint passes the authority, distributional, and manipulability axes before it is committed. ### Hard Constraints - Never write a capability solely because a same-named directory or commit scope exists; raw signals require admission, merge, split, or refusal. +- Never require users to memorize `map`, `fill`, or `audit`; those are optional shorthand over natural-language intent routing. - Never edit `### Learnings` between magic markers during grill; Learnings cleanup is a separate user-approved Learning Action. ### Learnings diff --git a/spec/system-map.md b/spec/system-map.md index f4c606c..4831fba 100644 --- a/spec/system-map.md +++ b/spec/system-map.md @@ -50,6 +50,16 @@ spec/ - `spec/capabilities.md` remains compact enough to read at session start. - Completed sprint files are immutable history. +## Candidate Capability Boundaries + +- `sprint-execution` - evidence: active sprint flow and completed-sprint invariant; owns sprint planning, in-flight state, and progress context; uncertainty: whether future relay learning writes should remain in this boundary. +- `backlog-sync` - evidence: GitHub Issues -> task mirror flow and explicit sync invariant; owns issue-to-task mirroring and local AC preservation; uncertainty: whether richer triage snapshots should stay separate. +- `triage-grooming` - evidence: advisory grooming flow and Decision Review use of spec evidence; owns issue classification, relationship, stale, alignment, and decision reports; uncertainty: whether closing-PR snapshot enrichment changes this contract. +- `spec-charter` - evidence: canonical `spec/charter.md` invariant and proof-gated direction flow; owns project axis lifecycle; uncertainty: how concrete downstream routing recommendations should be generated. +- `spec-system-map` - evidence: project-wide shape and boundary map flow; owns high-level system structure without module inventory; uncertainty: how much candidate boundary detail is useful before it becomes capability content. +- `spec-grill` - evidence: capability contract flow and compact capability invariant; owns capability admission, predicate pressure tests, and report-first boundary review; uncertainty: how evidence grouping should influence candidate naming. +- `task-progress-reporting` - evidence: progress helper scripts and GitHub Progress issue lifecycle; owns monthly progress issue updates and finalization; uncertainty: whether comment enrichment belongs here or in a separate integration boundary. + ## Where To Go Next - Product direction: [`charter.md`](charter.md)