diff --git a/CHANGELOG.md b/CHANGELOG.md index bf26efc..7afb8de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Breaking Changes + +- `Workspace.closeCycle(cycleName, completedDriftCheck, outcome)` now + requires the `outcome` argument. Downstream `Workspace` consumers must + update any direct calls to pass one of `hill-met`, `partial`, or + `not-met`. Treat the first published release containing this API + change as semver-major, or ship an explicit migration note if the + release plan changes before publication. Migration example: + `await workspace.closeCycle('0033-bearing-truthfulness', true, 'hill-met')`. + +### Fixed + +- Tightened MCP runtime behavior around GitHub sync flag parsing, + explicit legend normalization, and design-doc frontmatter escaping. +- Hardened the review-facing docs backlog items so their acceptance + criteria and historical notes match the live contract. + ## v0.3.0 First public release. 29 cycles closed, 127 tests passing. @@ -37,5 +56,3 @@ First public release. 29 cycles closed, 127 tests passing. - docs/GUIDE.md: operator advice with generated signpost inventory. - docs/BEARING.md: generated direction signpost. - docs/VISION.md: generated executive synthesis. - -## Unreleased diff --git a/README.md b/README.md index 52aa1e4..b5c613d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ docs/ cool-ideas/ experiments, wild thoughts bad-code/ tech debt *.md everything else + feedback/ raw feedback awaiting triage + feedback/archive/ processed feedback kept for provenance legends/ named domains retro//.md retrospectives releases/vX.Y.Z/ internal release packets @@ -418,6 +420,21 @@ Rejected work moves to `docs/method/graveyard/` with a note explaining why. The graveyard prevents re-proposing without context. If you want to resurrect something, you must address the note. +## Feedback + +Feedback that is not yet shaped into backlog or design work belongs in +`docs/method/feedback/`. + +Treat feedback docs as an inbox for critique, review notes, and +outside-in observations. When processed, turn accepted feedback into +explicit repo artifacts such as backlog items, design edits, signpost +updates, or release notes. Then move the processed feedback document to +`docs/method/feedback/archive/` with a short disposition note instead +of deleting it. + +The archive preserves provenance, shows what feedback already landed, +and prevents the same point from being rediscovered without context. + --- ## Flow @@ -431,6 +448,9 @@ idea -> inbox/ -> cool-ideas/ -> up-next/ -> asap/ -> ship sync (BEARING / CHANGELOG / release when meaningful) - or -> -> graveyard/ + +feedback -> feedback/ -> backlog/design/signpost/release/no-action + -> feedback/archive/ ``` --- diff --git a/docs/CLI.md b/docs/CLI.md index 44e9ff3..cfbd891 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,8 +1,8 @@ --- -title: "CLI Reference" -generated_at: 2026-04-07T05:30:14.481Z -generator: "method sync ship" -generated_from_commit: "03d889ddbe57ecb6b336ba2a62815f863f85bfd1" +title: CLI Reference +generated_at: 2026-04-08T21:17:50.746Z +generator: method sync ship +generated_from_commit: ecde6ac00e798d68bf69b2c2bfb9044ad44d47e9 provenance_level: artifact_history --- @@ -27,9 +27,9 @@ Capture a raw idea in docs/method/backlog/inbox/. Promote a backlog item into the next numbered design cycle. -### `method close [cycle] [--drift-check yes|no] [--outcome hill-met|partial|not-met]` +### `method close [cycle] [--drift-check yes|no] --outcome hill-met|partial|not-met` -Close an active cycle into docs/method/retro/. +Close an active cycle into docs/method/retro/. `--outcome` is required. ### `method status` diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 383b316..a386c04 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -36,6 +36,25 @@ Why this is good practice: current feature work. - It avoids awkward cleanup and weird Git operations later. +## Process feedback in batches + +If review notes or operator feedback start piling up, capture them in +`docs/method/feedback/` instead of scattering them across chat or +deleting them after triage. + +Treat a feedback doc as raw input, not as a backlog item yet. During a +maintenance pass, read it carefully, split accepted points into the +right durable artifacts, and then move the source document into +`docs/method/feedback/archive/` with a short note about what happened. + +Why this is good practice: + +- It keeps the backlog from becoming a dumping ground for half-processed + critique. +- It preserves the original wording and context behind later backlog or + docs changes. +- It makes "no action" a visible decision instead of silent deletion. + ## Advice is not doctrine Some things are worth saying before they are worth standardizing. diff --git a/docs/MCP.md b/docs/MCP.md index f770e6e..7b191ce 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -1,8 +1,8 @@ --- -title: "MCP Reference" -generated_at: 2026-04-07T05:30:14.482Z -generator: "method sync ship" -generated_from_commit: "03d889ddbe57ecb6b336ba2a62815f863f85bfd1" +title: MCP Reference +generated_at: 2026-04-08T21:17:50.754Z +generator: method sync ship +generated_from_commit: ecde6ac00e798d68bf69b2c2bfb9044ad44d47e9 provenance_level: artifact_history --- @@ -25,6 +25,10 @@ workspace-agnostic — a single instance can serve multiple projects. Get the current status of the METHOD workspace (backlog lanes, active cycles, legend health) +**Parameters:** + +- `summary` (optional) `boolean` — Return a compact structured summary instead of the fully expanded workspace status (default: false) + ### `method_inbox` Capture a new raw idea into the inbox @@ -85,6 +89,20 @@ Automate terminal evidence capture for a cycle ## Error Handling -All tools return `isError: true` with a text message on failure. +All tools return a human-readable text message in `content`. +Machine-readable callers should consume `structuredContent`. + +On success, `structuredContent` includes: + +- `tool` +- `ok: true` +- `result` + +On failure, tools set `isError: true` and `structuredContent` includes: + +- `tool` +- `ok: false` +- `error.message` + Common errors: `workspace is required`, `not a METHOD workspace`, `No active cycles found`. diff --git a/docs/design/0030-backlog-metadata-single-source-of-truth/backlog-metadata-single-source-of-truth.md b/docs/design/0030-backlog-metadata-single-source-of-truth/backlog-metadata-single-source-of-truth.md new file mode 100644 index 0000000..5ca016b --- /dev/null +++ b/docs/design/0030-backlog-metadata-single-source-of-truth/backlog-metadata-single-source-of-truth.md @@ -0,0 +1,96 @@ +--- +title: "Backlog Metadata Single Source Of Truth" +legend: PROCESS +cycle: "0030-backlog-metadata-single-source-of-truth" +source_backlog: "docs/method/backlog/asap/PROCESS_backlog-metadata-single-source-of-truth.md" +--- + +# Backlog Metadata Single Source Of Truth + +Source backlog item: `docs/method/backlog/asap/PROCESS_backlog-metadata-single-source-of-truth.md` +Legend: PROCESS + +## Sponsors + +- Human: Backlog operator who needs lane and legend moves to stay honest + without manually repairing YAML after every triage action. +- Agent: Automation and MCP clients that need one cheap, stable source of + backlog truth instead of inferring metadata from filenames and + directory shape. + +## Hill + +Backlog metadata becomes frontmatter-first. `status`, `pull`, lane +moves, and legend health all resolve `legend` and `lane` from YAML when +present, while compatibility fallbacks keep older cards readable until +they are backfilled. Managed moves should repair missing lane metadata +instead of preserving drift. + +## Playback Questions + +### Human + +- [ ] If a backlog card's filename or directory disagrees with its YAML, + does `method status` report the YAML lane and legend rather than + the path-derived guess? +- [ ] If I move a legacy backlog card between lanes, does METHOD + backfill lane metadata so the card no longer depends on path + inference? + +### Agent + +- [ ] Can I consume backlog lane and legend from `Workspace.status()` + without reverse-engineering filename prefixes or folder names? +- [ ] If a backlog card has stale filename legend text, does `method pull` + still carry the YAML legend into the design packet? + +## Accessibility and Assistive Reading + +- Linear truth / reduced-complexity posture: backlog metadata should be + explicit in one place so humans and agents do not need to cross-check + path, filename, and YAML to learn the current state. +- Non-visual or alternate-reading expectations: metadata must stay + machine-readable and linearly inspectable from the document header. + +## Localization and Directionality + +- Locale / wording / formatting assumptions: legend codes remain stable, + uppercase repo-local identifiers independent of prose wording. +- Logical direction / layout assumptions: no layout dependence; the + solution lives in YAML keys and deterministic CLI/API output. + +## Agent Inspectability and Explainability + +- What must be explicit and deterministic for agents: lane and legend + resolution order, compatibility fallback behavior, and move-time + metadata repair. +- What must be attributable, evidenced, or governed: tests should prove + that frontmatter wins over filenames and directories, and that active + legend health can read design doc frontmatter. + +## Non-goals + +- [ ] Redesign backlog filenames or cycle slug generation in this cycle. +- [ ] Eliminate every legacy fallback path before the repo backlog docs + have been backfilled. +- [ ] Replace the generated design or retro scaffolds; that remains + separate debt under `PROCESS_generated-doc-scaffold-contract`. + +## Backlog Context + +Backlog metadata is still split across filename prefixes and YAML +frontmatter. `captureIdea` writes `legend` and `lane` into frontmatter, +but `collectBacklogItems`, `calculateLegendHealth`, and `pullItem` still +derive legend from the filename stem, while `moveBacklogItem` renames the +file without updating frontmatter metadata. That creates silent drift: +the path can say one thing, the YAML can say another, and status/MCP +surfaces may report whichever source they happen to trust. + +This is bad code because the repo now has multiple authorities for the +same facts. It is especially hostile to agents, which need one cheap, +stable, machine-readable source instead of filename heuristics plus +special-case inference. + +The fix should make frontmatter canonical for backlog metadata, keep +filenames as convenience or display structure, and ensure move/pull/ +status/audit flows preserve and validate the YAML fields. diff --git a/docs/design/0031-generated-doc-scaffold-contract/generated-doc-scaffold-contract.md b/docs/design/0031-generated-doc-scaffold-contract/generated-doc-scaffold-contract.md new file mode 100644 index 0000000..58f1e91 --- /dev/null +++ b/docs/design/0031-generated-doc-scaffold-contract/generated-doc-scaffold-contract.md @@ -0,0 +1,86 @@ +--- +title: "Generated Doc Scaffold Contract" +legend: PROCESS +cycle: "0031-generated-doc-scaffold-contract" +source_backlog: "docs/method/backlog/asap/PROCESS_generated-doc-scaffold-contract.md" +--- + +# Generated Doc Scaffold Contract + +Source backlog item: `docs/method/backlog/asap/PROCESS_generated-doc-scaffold-contract.md` +Legend: PROCESS + +## Sponsors + +- Human: Cycle operator +- Agent: Scaffold repair agent + +## Hill + +`method pull` and `method close` generate design and retro docs that +already satisfy the repo's committed document contract. A fresh cycle +packet should not require manual frontmatter surgery just to survive the +docs suite or honest review. + +## Playback Questions + +### Human + +- [ ] Does `method pull` create a design doc that already includes + required frontmatter fields like `title`, `legend`, `cycle`, and + `source_backlog`? +- [ ] Does `method close` create a retro doc that already includes + required frontmatter fields like `title`, `outcome`, + `drift_check`, and `design_doc`? + +### Agent + +- [ ] Can I rely on scaffolded design docs to satisfy the repo docs + contract without manual patching? +- [ ] Can I rely on scaffolded retro docs to satisfy the repo docs + contract without post-close repairs? + +## Accessibility and Assistive Reading + +- Linear truth / reduced-complexity posture: generated docs should place + critical metadata in YAML so readers do not need to infer validity + from body prose or repair steps. +- Non-visual or alternate-reading expectations: the scaffold contract + must stay explicit and machine-readable from the first lines of the + document. + +## Localization and Directionality + +- Locale / wording / formatting assumptions: scaffold metadata keys stay + stable and deterministic regardless of the prose in the body. +- Logical direction / layout assumptions: no layout-specific behavior; + validity depends on frontmatter and predictable section structure. + +## Agent Inspectability and Explainability + +- What must be explicit and deterministic for agents: which frontmatter + keys are emitted for design and retro docs, and that close-time output + does not require manual cleanup before commit. +- What must be attributable, evidenced, or governed: CLI tests should + prove scaffolded docs meet the repo contract, and the existing cycle + docs produced before the fix should be repaired on-branch. + +## Non-goals + +- [ ] Redesign the substantive content of every historical design or + retro doc. +- [ ] Solve broader signpost truth drift such as `BEARING.md` claims. +- [ ] Redesign witness capture output beyond keeping the generated docs + commit-safe. + +## Backlog Context + +`method pull` and `method close` still generate design and retro +documents with older scaffold shapes that do not match the repo's +current frontmatter contract. The committed docs suite enforces YAML +frontmatter and required fields on real docs, but the command-generated +templates do not yet guarantee those same invariants. + +That means METHOD can generate docs that its own repo doctrine would +reject if they were committed. The scaffolders and the tests need to be +brought back into parity so generated docs are valid by construction. diff --git a/docs/design/0032-mcp-tool-result-contract/mcp-tool-result-contract.md b/docs/design/0032-mcp-tool-result-contract/mcp-tool-result-contract.md new file mode 100644 index 0000000..5eafffc --- /dev/null +++ b/docs/design/0032-mcp-tool-result-contract/mcp-tool-result-contract.md @@ -0,0 +1,87 @@ +--- +title: "MCP Tool Result Contract" +legend: PROCESS +cycle: "0032-mcp-tool-result-contract" +source_backlog: "docs/method/backlog/asap/PROCESS_mcp-tool-result-contract.md" +--- + +# MCP Tool Result Contract + +Source backlog item: `docs/method/backlog/asap/PROCESS_mcp-tool-result-contract.md` +Legend: PROCESS + +## Sponsors + +- Human: MCP client operator +- Agent: Structured-response consumer + +## Hill + +MCP tools expose a stable structured result contract through +`structuredContent`, and `method_status` defaults to a compact summary +mode so callers can answer "where are we?" without pulling the whole +workspace graph into context. Text remains as fallback narration, not as +the primary machine interface. + +## Playback Questions + +### Human + +- [ ] Does `method_status` return summary-shaped structured content by + default, with lane counts instead of the fully expanded backlog? +- [ ] Can I request `method_status` full mode and still receive the + expanded structured workspace state without scraping text? + +### Agent + +- [ ] Do mutation tools (`method_inbox`, `method_pull`, `method_close`, + `method_capture_witness`) return stable field-level structured + content for paths and cycles? +- [ ] On MCP errors, can I inspect a structured error object instead of + only a prose message? + +## Accessibility and Assistive Reading + +- Linear truth / reduced-complexity posture: summary mode should make + the common status query small enough to inspect quickly without + scrolling through every backlog item. +- Non-visual or alternate-reading expectations: structured fields must + carry the contract so agents and alternate readers are not forced to + reverse-engineer human prose. + +## Localization and Directionality + +- Locale / wording / formatting assumptions: the contract should not + depend on English phrasing inside `content[0].text`. +- Logical direction / layout assumptions: none; this is a field-level + protocol contract. + +## Agent Inspectability and Explainability + +- What must be explicit and deterministic for agents: the envelope shape, + success/error distinction, and the difference between summary and full + status mode. +- What must be attributable, evidenced, or governed: tests should assert + `structuredContent` directly for success and error paths rather than + treating text blobs as the contract. + +## Non-goals + +- [ ] Replace every possible prose fallback in MCP responses with zero + text. +- [ ] Rework the underlying workspace domain models beyond what the + structured contract needs. +- [ ] Solve signpost truth drift; that remains separate work under + `PROCESS_bearing-truthfulness`. + +## Backlog Context + +The MCP server still returns tool results as ad hoc text blobs. Even the +test that asks whether an MCP client can interact without parsing +terminal text is effectively answered with string-contains assertions on +`content[0].text`. This is expensive and fragile for agents, especially +when status payloads are large or when callers need stable field-level +results. + +METHOD needs a more explicit structured result contract for MCP tools so +agents can consume repo state and mutations without scraping prose. diff --git a/docs/design/0033-bearing-truthfulness/bearing-truthfulness.md b/docs/design/0033-bearing-truthfulness/bearing-truthfulness.md new file mode 100644 index 0000000..c10ec24 --- /dev/null +++ b/docs/design/0033-bearing-truthfulness/bearing-truthfulness.md @@ -0,0 +1,83 @@ +--- +title: "BEARING Truthfulness" +legend: PROCESS +cycle: "0033-bearing-truthfulness" +source_backlog: "docs/method/backlog/asap/PROCESS_bearing-truthfulness.md" +--- + +# BEARING Truthfulness + +Source backlog item: `docs/method/backlog/asap/PROCESS_bearing-truthfulness.md` +Legend: PROCESS + +## Sponsors + +- Human: Repo signpost reader +- Agent: Signpost-trust consumer + +## Hill + +`renderBearing` stops emitting stale assertions and only reports claims +that are either directly derived from current repo state or tightly +bounded fallback text. The generated signpost should be safe to trust as +a summary surface, even when the underlying backlog or witness posture +changes. + +## Playback Questions + +### Human + +- [ ] Does generated `BEARING.md` stop claiming witness generation is not + automated? +- [ ] When backlog pressure changes, does `BEARING.md` describe concrete + live repo state instead of hardcoded stale complaints? + +### Agent + +- [ ] Can I point to the exact backlog counts that caused each `What + feels wrong?` line? +- [ ] Does `renderBearing` only emit derived or tightly bounded + statements instead of unaudited repo assertions? + +## Accessibility and Assistive Reading + +- Linear truth / reduced-complexity posture: the signpost should stay + short, but every complaint line must map back to explicit repo state. +- Non-visual or alternate-reading expectations: readers should be able + to inspect BEARING linearly without wondering whether a sentence is + aspirational prose or live repo truth. + +## Localization and Directionality + +- Locale / wording / formatting assumptions: counts and derived status + should not rely on ambiguous rhetorical phrasing. +- Logical direction / layout assumptions: none; the problem is content + truth, not layout. + +## Agent Inspectability and Explainability + +- What must be explicit and deterministic for agents: which backlog and + cycle counts feed BEARING, and what fallback text appears when there is + no active pressure to report. +- What must be attributable, evidenced, or governed: tests should prove + the stale witness claim is gone and that "What feels wrong?" lines map + to current backlog counts instead of hardcoded guesses. + +## Non-goals + +- [ ] Run ship sync on `main` from this branch; merged signpost refresh is + still a post-merge maneuver. +- [ ] Make BEARING a full dashboard or replace `method status`. +- [ ] Solve unrelated signpost or legend taxonomy drift. + +## Backlog Context + +`renderBearing` currently emits factual claims that have drifted from the +repo. In particular, it still says witness generation is not automated +even though automated witness capture already landed. A generated +signpost that tells the truth incorrectly is worse than missing +automation: it creates confident drift in the repo's own public surface. + +This needs a pass to make BEARING generation derive more of its claims +from current repo state or explicitly constrain which statements are +allowed to be hardcoded. diff --git a/docs/method/backlog/bad-code/PROCESS_dist-output-pruning.md b/docs/method/backlog/bad-code/PROCESS_dist-output-pruning.md new file mode 100644 index 0000000..34d408a --- /dev/null +++ b/docs/method/backlog/bad-code/PROCESS_dist-output-pruning.md @@ -0,0 +1,49 @@ +--- +title: "Dist Output Pruning" +legend: PROCESS +lane: bad-code +--- + +# Dist Output Pruning + +Build output can retain stale artifacts after source modules are +removed, renamed, or split across refactors. That drift makes the +published `dist/` tree a misleading projection of the live source tree. + +This is bad code because published artifacts stop being a trustworthy +projection of current source. + +## Proposed Solution + +Option 1 (preferred): clean before emit. + +- Update the `package.json` build path to remove `dist/` before `tsc` + runs so every build starts from repo truth. +- Keep the clean step in the same build path humans and CI use, rather + than relying on a separate manual command. + +Option 2: validate `dist/` against source. + +- Add a validation script that checks `dist/**/*.js` and + `dist/**/*.d.ts` against the current `src/**/*.ts` module set. +- Run that validation in CI and release pre-flight so stale checked-in + artifacts fail loudly instead of shipping quietly. + +## Owner + +- Human: Repository operator maintaining release and CI behavior. +- Agent: Implementation agent tightening the build path. + +## Priority + +Medium. This affects artifact truth and release hygiene, even when +runtime behavior happens to stay green. + +## Acceptance Criteria + +- [ ] The standard build path removes stale `dist/` artifacts before + emit or proves they are current. +- [ ] CI or release pre-flight fails when `dist/` no longer matches the + live `src/` module set. +- [ ] The chosen build/validation path is documented in the relevant + operator docs. diff --git a/docs/method/backlog/bad-code/PROCESS_github-comment-sync-identity.md b/docs/method/backlog/bad-code/PROCESS_github-comment-sync-identity.md new file mode 100644 index 0000000..941d4e0 --- /dev/null +++ b/docs/method/backlog/bad-code/PROCESS_github-comment-sync-identity.md @@ -0,0 +1,16 @@ +--- +title: "GitHub Comment Sync Identity" +legend: PROCESS +lane: bad-code +--- + +# GitHub Comment Sync Identity + +GitHub comment synchronization is still additive-only and uses simple +string matching to avoid duplicates instead of tracking stable remote +comment identity. That makes sync behavior approximate and brittle: +similar edits can duplicate, true edits cannot reconcile cleanly, and +agents do not get a durable mapping between repo state and remote review +discussion. + +This needs real comment identity tracking rather than textual heuristics. diff --git a/docs/method/backlog/bad-code/PROCESS_mcp-runtime-input-validation-audit.md b/docs/method/backlog/bad-code/PROCESS_mcp-runtime-input-validation-audit.md new file mode 100644 index 0000000..e649aee --- /dev/null +++ b/docs/method/backlog/bad-code/PROCESS_mcp-runtime-input-validation-audit.md @@ -0,0 +1,39 @@ +--- +title: "MCP Runtime Input Validation Audit" +legend: PROCESS +lane: bad-code +owner: "METHOD maintainers" +priority: medium +acceptance_criteria: + - "Every audited MCP mutation tool rejects invalid runtime inputs before mutating repo state." + - "Validation failures return the canonical MCP error envelope documented in docs/MCP.md and implemented by src/mcp.ts:errorResult." + - "Tests assert the exact envelope shape for at least one invalid-input case per audited tool." +--- + +# MCP Runtime Input Validation Audit + +`method_close` just proved that MCP tool schemas are not enough on their +own. The runtime handler can still accept structurally invalid inputs if +it trusts type assertions instead of validating the actual request. + +This is bad code because the advertised tool contract and the code path +can silently diverge. One tool was repaired here, but the rest of the +MCP surface should be audited for the same schema-vs-runtime gap. + +## Acceptance Criteria + +- [ ] Audit `method_inbox`, `method_pull`, `method_close`, + `method_sync_ship`, `method_sync_github`, and + `method_capture_witness` for schema-vs-runtime gaps before any + repo mutation happens. +- [ ] On validation failure, each audited tool returns the canonical MCP + error envelope documented in `docs/MCP.md` and implemented by + `src/mcp.ts#errorResult`: + `structuredContent.tool`, `structuredContent.ok: false`, + `structuredContent.error.message`, and `isError: true`. +- [ ] Add or update tests so at least one invalid-input case per audited + tool asserts the exact error envelope shape and field names rather + than only checking prose in `content[0].text`. +- [ ] Record which audited tools already conform, which required code + changes, and where the enforcing tests live so the audit is + reviewable without rediscovering scope. diff --git a/docs/method/backlog/bad-code/PROCESS_test-taxonomy-and-fixture-drift.md b/docs/method/backlog/bad-code/PROCESS_test-taxonomy-and-fixture-drift.md new file mode 100644 index 0000000..2d9914f --- /dev/null +++ b/docs/method/backlog/bad-code/PROCESS_test-taxonomy-and-fixture-drift.md @@ -0,0 +1,17 @@ +--- +title: "Test Taxonomy And Fixture Drift" +legend: PROCESS +lane: bad-code +--- + +# Test Taxonomy And Fixture Drift + +Several tests still seed obsolete legend codes like `PROTO`, `VIZ`, +`TUI`, and `FEAT`, and they create markdown fixtures that do not follow +the current frontmatter expectations. The committed docs suite, README, +and backlog conventions now claim a tighter live taxonomy than the tests +exercise. + +That makes the suite partially validate an older METHOD than the one the +repo documents. Fixtures should either reflect the current doctrine or +be clearly marked as compatibility cases rather than silent drift. diff --git a/docs/method/backlog/bad-code/PROCESS_witness-drift-output-capture.md b/docs/method/backlog/bad-code/PROCESS_witness-drift-output-capture.md new file mode 100644 index 0000000..c3bd762 --- /dev/null +++ b/docs/method/backlog/bad-code/PROCESS_witness-drift-output-capture.md @@ -0,0 +1,20 @@ +--- +title: "Witness Drift Output Capture" +legend: PROCESS +lane: bad-code +--- + +# Witness Drift Output Capture + +`captureWitness()` is still not reliably capturing the output of the +drift command into `verification.md`. During cycle `0031`, the standalone +`method drift 0031-generated-doc-scaffold-contract` command returned the +expected clean summary, but the generated witness still recorded an +empty `## Drift Results` block. + +That is real repo-truth drift inside the witness packet itself. The +close flow is claiming automated evidence capture, but one of the key +artifacts is blank even when the source command had meaningful output. + +This should be fixed with a targeted regression test so witness packets +carry the actual drift output they observed at close time. diff --git a/docs/method/backlog/bad-code/PROCESS_workspace-operation-split.md b/docs/method/backlog/bad-code/PROCESS_workspace-operation-split.md new file mode 100644 index 0000000..519f06a --- /dev/null +++ b/docs/method/backlog/bad-code/PROCESS_workspace-operation-split.md @@ -0,0 +1,23 @@ +--- +title: "Workspace Operation Split" +legend: PROCESS +lane: bad-code +--- + +# Workspace Operation Split + +`Workspace` still carries too many responsibilities in one class: +initialization checks, backlog capture, pull/close flow, witness +generation, ship sync, status collection, frontmatter mutation, backlog +moves, cycle resolution, and shell command execution. The repo already +called this out in the `0029-bad-code-cleanup` retro as remaining debt. + +This is not just a style complaint. It makes the runtime harder to test, +harder to evolve, and more likely to grow hidden coupling between +backlog, cycle, signpost, and execution behavior. It also raises the +cost of exposing a cleaner library or MCP surface because too many +operations hide behind one mutable object. + +Follow-on decomposition should extract focused modules for backlog +operations, cycle operations, witness/command execution, and status/ +signpost generation while preserving the honest public API. diff --git a/docs/method/backlog/cool-ideas/PROCESS_architecture-source-inventory-section.md b/docs/method/backlog/cool-ideas/PROCESS_architecture-source-inventory-section.md new file mode 100644 index 0000000..3520a2c --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_architecture-source-inventory-section.md @@ -0,0 +1,12 @@ +--- +title: "Architecture Source Inventory Section" +legend: PROCESS +lane: cool-ideas +--- + +# Architecture Source Inventory Section + +Add generated marker support to `ARCHITECTURE.md` so METHOD can publish a +bounded source-file inventory or module map directly from the live code +tree. This would extend the hybrid signpost pattern beyond CLI/MCP/GUIDE +and make architecture docs cheaper to keep honest after refactors. diff --git a/docs/method/backlog/cool-ideas/PROCESS_backlog-summary-signpost-section.md b/docs/method/backlog/cool-ideas/PROCESS_backlog-summary-signpost-section.md new file mode 100644 index 0000000..e9584ec --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_backlog-summary-signpost-section.md @@ -0,0 +1,13 @@ +--- +title: "Backlog Summary Signpost Section" +legend: PROCESS +lane: cool-ideas +--- + +# Backlog Summary Signpost Section + +Add a generated backlog-summary section to a signpost such as +`docs/GUIDE.md` or another repo-facing doc. The section should provide a +bounded snapshot of lane counts, active pressure, and notable priority +clusters without dumping the full backlog or forcing agents to expand a +large MCP payload just to answer "where are we?". diff --git a/docs/method/backlog/cool-ideas/PROCESS_cycle-packet-markdownlint-gate.md b/docs/method/backlog/cool-ideas/PROCESS_cycle-packet-markdownlint-gate.md new file mode 100644 index 0000000..e43be55 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_cycle-packet-markdownlint-gate.md @@ -0,0 +1,66 @@ +--- +title: "Cycle Packet Markdownlint Gate" +legend: PROCESS +lane: cool-ideas +owner: "METHOD maintainers" +priority: medium +acceptance_criteria: + - "The lint scope is explicitly limited to cycle packet markdown under docs/design/[0-9][0-9][0-9][0-9]-*/**/*.md and docs/method/retro/[0-9][0-9][0-9][0-9]-*/**/*.md." + - "The gate enforces markdownlint rules MD040 and MD031 plus cycle-packet frontmatter key checks." + - "Retro frontmatter key checks apply only to top-level retro docs; witness/**/*.md files are linted for markdown rules only unless they gain their own explicit frontmatter contract." + - "The spec explicitly declares that `method validate markdown` depends on the parent `PROCESS_validate-command.md` contract and that `src/cli.ts` must expose the `validate` command surface." + - "The chosen entrypoint exits 0 on clean packets and non-zero with file/rule-localized failures when any packet violates the contract." +--- + +# Cycle Packet Markdownlint Gate + +This review round caught witness and retro packet issues that a small +markdown lint pass could have surfaced before PR review: unlabeled code +fences, empty placeholder blocks, and wording drift inside committed +artifacts. + +Idea: add a bounded markdown validation step for cycle packets and other +generated repo artifacts so contract-level doc issues fail fast instead +of landing as manual review cleanup. + +## Proposed Contract + +## Prerequisites + +- `PROCESS_validate-command.md` defines the parent `method validate` + surface. This item assumes that contract lands first or in the same + slice. + +- Scope globs: + markdown rules cover + `docs/design/[0-9][0-9][0-9][0-9]-*/**/*.md` and + `docs/method/retro/[0-9][0-9][0-9][0-9]-*/**/*.md`. +- Rules: + `MD040` (fenced code blocks need a language), + `MD031` (fenced code blocks need surrounding blank lines), and custom + packet checks for required frontmatter keys on generated docs. +- Frontmatter checks: + design docs require `title`, `legend`, `cycle`, and `source_backlog`; + top-level retro docs at + `docs/method/retro/[0-9][0-9][0-9][0-9]-*/*.md` require `title`, + `cycle`, `design_doc`, `outcome`, and `drift_check`; witness + artifacts under `**/witness/**/*.md` are linted for markdown rules + only unless they later get their own explicit frontmatter contract. +- Integration point: + this item depends on `PROCESS_validate-command.md`. The intended + surface is `method validate markdown` as a subcommand of the parent + `method validate` CLI implemented in `src/cli.ts`, not a separate + top-level verb. Implementers should either land both contracts in the + same slice or wire this markdown gate into the existing `validate` + command surface before invoking it locally, from pre-commit, or from + a CI job such as `markdown-contract`. +- Pass / fail behavior: + exit `0` when all scoped files satisfy the bounded rule set; exit + non-zero when any scoped file violates one of the named rules or + frontmatter checks, with output that names the file and failing rule. + +## Non-goals + +- [ ] Lint every markdown file in the repository. +- [ ] Replace broader review judgment on cycle quality with style-only + checks. diff --git a/docs/method/backlog/cool-ideas/PROCESS_drift-score-visibility.md b/docs/method/backlog/cool-ideas/PROCESS_drift-score-visibility.md new file mode 100644 index 0000000..d67e384 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_drift-score-visibility.md @@ -0,0 +1,12 @@ +--- +title: "Drift Score Visibility" +legend: PROCESS +lane: cool-ideas +--- + +# Drift Score Visibility + +When the drift detector reports a near miss, also show the similarity +score or comparable transparency signal. That would make the hint more +legible to humans and agents and reduce the need to guess whether a hint +is barely related or almost exact. diff --git a/docs/method/backlog/cool-ideas/PROCESS_drift-threshold-config.md b/docs/method/backlog/cool-ideas/PROCESS_drift-threshold-config.md new file mode 100644 index 0000000..ac97553 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_drift-threshold-config.md @@ -0,0 +1,13 @@ +--- +title: "Drift Threshold Config" +legend: PROCESS +lane: cool-ideas +--- + +# Drift Threshold Config + +Make the drift detector's near-miss similarity threshold configurable +through `.method.json` or another repo config surface. Different repos +will have different tolerance for wording variance, and hardcoding one +threshold in code limits METHOD's usefulness as a reusable workflow +substrate. diff --git a/docs/method/backlog/cool-ideas/PROCESS_github-milestone-lane-sync.md b/docs/method/backlog/cool-ideas/PROCESS_github-milestone-lane-sync.md new file mode 100644 index 0000000..e0611f3 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_github-milestone-lane-sync.md @@ -0,0 +1,12 @@ +--- +title: "GitHub Milestone Lane Sync" +legend: PROCESS +lane: cool-ideas +--- + +# GitHub Milestone Lane Sync + +Let GitHub milestones or equivalent remote planning metadata map onto +METHOD backlog lanes or tags. That would make cross-surface planning +less manual and could give agents a cheaper way to understand remote +priority structure without inferring it from issue text alone. diff --git a/docs/method/backlog/cool-ideas/PROCESS_legend-audit-and-assignment.md b/docs/method/backlog/cool-ideas/PROCESS_legend-audit-and-assignment.md index 9c8fe5d..691e897 100644 --- a/docs/method/backlog/cool-ideas/PROCESS_legend-audit-and-assignment.md +++ b/docs/method/backlog/cool-ideas/PROCESS_legend-audit-and-assignment.md @@ -1,6 +1,7 @@ --- title: "Legend Audit And Assignment" legend: PROCESS +lane: cool-ideas --- Legend management needs first-class tooling: create legends, assign diff --git a/docs/method/backlog/cool-ideas/PROCESS_remote-issue-deletion-sync.md b/docs/method/backlog/cool-ideas/PROCESS_remote-issue-deletion-sync.md new file mode 100644 index 0000000..aa30eea --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_remote-issue-deletion-sync.md @@ -0,0 +1,12 @@ +--- +title: "Remote Issue Deletion Sync" +legend: PROCESS +lane: cool-ideas +--- + +# Remote Issue Deletion Sync + +Support an explicit policy for what METHOD should do when a synced remote +issue is deleted or otherwise disappears. Today that case is mostly a +silent edge, but a real sync layer should decide whether to archive the +local item, warn loudly, or preserve it with a remote-tombstone marker. diff --git a/docs/method/backlog/cool-ideas/PROCESS_retro-conversational-closeout.md b/docs/method/backlog/cool-ideas/PROCESS_retro-conversational-closeout.md index 10772af..52aabdc 100644 --- a/docs/method/backlog/cool-ideas/PROCESS_retro-conversational-closeout.md +++ b/docs/method/backlog/cool-ideas/PROCESS_retro-conversational-closeout.md @@ -1,6 +1,7 @@ --- title: "Retro conversational closeout" legend: PROCESS +lane: cool-ideas --- METHOD's `close` flow currently handles the mechanical part of a retro: diff --git a/docs/method/backlog/cool-ideas/PROCESS_retro-scaffold-refresh-command.md b/docs/method/backlog/cool-ideas/PROCESS_retro-scaffold-refresh-command.md new file mode 100644 index 0000000..a2d2b79 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_retro-scaffold-refresh-command.md @@ -0,0 +1,163 @@ +--- +title: "Retro Scaffold Refresh Command" +legend: PROCESS +lane: cool-ideas +owner: "METHOD maintainers" +priority: low +acceptance_criteria: + - "The command accepts explicit retro packet IDs, glob selectors, and filesystem paths resolved to retro packet roots, plus --dry-run." + - "Refresh rewrites only scaffold-managed retro frontmatter keys (`title`, `cycle`, `design_doc`, `outcome`, `drift_check`) and section bodies that are explicitly marked scaffold-managed or that match a supported placeholder signature for that heading." + - "Human-authored section bodies and all files under witness/ remain byte-for-byte unchanged." + - "Batch mode reports per-packet outcomes and exits 3 when any matched packet fails during write." +--- + +# Retro Scaffold Refresh Command + +When scaffold contracts change, already-generated retro and witness +artifacts currently need manual repair. A narrow refresh command could +re-render committed retro packet scaffolds in place while preserving the +human-written sections that should not be regenerated. + +That would turn scaffold contract upgrades from one-off cleanup work +into a repeatable maintenance move. + +## Proposed Contract + +- Command shape: + `method refresh retro [--dry-run]` +- Accepted inputs: + explicit retro packet IDs like `0033-bearing-truthfulness`, glob + selectors like `003[0-3]-*` matched against retro packet IDs, or + filesystem paths. +- Path resolution algorithm: + for each selector, first match an exact retro packet ID; otherwise, if + the selector contains glob metacharacters, expand it against directory + names directly under `docs/method/retro/`; otherwise treat it as a + filesystem path, normalize it, and walk upward to the nearest ancestor + directory that is a direct child of `docs/method/retro/`. Paths under + `witness/` resolve to their containing retro packet root; paths + outside `docs/method/retro/` or selectors that never resolve to a + retro packet root fail with exit `2`. Deduplicate resolved packet + roots before processing. +- Scaffold-managed frontmatter: + only `title`, `cycle`, `design_doc`, `outcome`, and `drift_check` are + rewritten by the refresh command. Any other frontmatter keys are + preserved unchanged. +- Preserved content: + any non-placeholder body text already written under `## Summary`, + `## Playback Witness`, `## Drift`, `## New Debt`, `## Cool Ideas`, + and `## Backlog Maintenance`, plus every file already stored under the + retro packet's `witness/` directory. +- Placeholder detection and regenerated content: + new scaffolds should emit + `` immediately + under each placeholder-owned heading. The refresh command first + checks for that marker; if it is absent, it normalizes surrounding + whitespace and matches the section body against supported historical + placeholder signatures for that heading: + `## Summary` -> `TBD`, `TODO`, or `To be written.`; + `## Playback Witness` -> `Add artifacts under and link + them here.` plus earlier variants that match + `Add artifacts under <.+> and link .* here.`; + `## Drift`, `## New Debt`, and `## Cool Ideas` -> `- None recorded.`, + `- None.`, or `None recorded.`; + `## Backlog Maintenance` -> the current scaffold checklist block or an + earlier checklist variant that still contains the same scaffold action + list. Bodies outside that marker/signature set are treated as + human-authored and preserved unchanged. Missing required headings are + inserted with the current scaffold placeholder text. +- Expected diff behavior: + `--dry-run` prints `Would refresh: ` lines plus the exact + frontmatter keys and headings it would touch, then exits without + writing. Write mode prints one line per packet using `Refreshed:`, + `Unchanged:`, or `Failed:` prefixes, followed by a summary line with + counts. +- Failure modes and exit codes: + exit `0` when all matched packets are already current or are refreshed + successfully; exit `1` when no packets match the selector; exit `2` + for invalid selectors or malformed retro packet structure before the + write phase; exit `3` for write failures. In batch mode the command + continues processing remaining matched packets after a write failure, + reports aggregate success/failure counts, and the highest applicable + exit code wins. + +## Examples + +### Success case + +```shell +$ method refresh retro 0033-bearing-truthfulness +Refreshed: docs/method/retro/0033-bearing-truthfulness/bearing-truthfulness.md + frontmatter: title, cycle, design_doc, outcome, drift_check + headings: inserted none +Summary: refreshed=1 unchanged=0 failed=0 +Exit 0 +``` + +### Dry-run mode + +```shell +$ method refresh retro 003[0-3]-* --dry-run +Would refresh: docs/method/retro/0030-backlog-metadata-single-source-of-truth/backlog-metadata-single-source-of-truth.md + would update frontmatter: title, cycle +Would refresh: docs/method/retro/0031-generated-doc-scaffold-contract/generated-doc-scaffold-contract.md + would insert headings: none +Summary: would_refresh=2 unchanged=0 +Exit 0 (dry-run, no files modified) +``` + +### Path input resolving from a witness file + +```shell +$ method refresh retro docs/method/retro/0032-mcp-tool-result-contract/witness/verification.md +Resolved packet root: docs/method/retro/0032-mcp-tool-result-contract +Refreshed: docs/method/retro/0032-mcp-tool-result-contract/mcp-tool-result-contract.md +Summary: refreshed=1 unchanged=0 failed=0 +Exit 0 +``` + +### Error case: no matches + +```shell +$ method refresh retro 9999-nonexistent +Error: No retro packets match selector '9999-nonexistent' +Exit 1 +``` + +### Error case: batch write failure + +```shell +$ method refresh retro 003[2-3]-* +Refreshed: docs/method/retro/0032-mcp-tool-result-contract/mcp-tool-result-contract.md +Failed: docs/method/retro/0033-bearing-truthfulness/bearing-truthfulness.md (permission denied) +Summary: refreshed=1 unchanged=0 failed=1 +Exit 3 +``` + +## PASS Criteria + +- [ ] An explicit retro packet ID refreshes only that packet and exits + `0`. +- [ ] A glob selector expands to a deduplicated retro packet set and + reports one outcome line per packet. +- [ ] A filesystem path under `docs/method/retro//...` resolves + to the containing retro packet root; a path outside that tree + exits `2`. +- [ ] `--dry-run` makes no file changes and prints `Would refresh:` + output plus the exact keys/headings it would touch. +- [ ] Write mode rewrites only `title`, `cycle`, `design_doc`, + `outcome`, and `drift_check` in frontmatter. +- [ ] Section bodies are rewritten only when they carry the + `method:scaffold-managed` marker or match a supported placeholder + signature for that heading. +- [ ] Human-authored section bodies and every file under `witness/` + remain byte-for-byte unchanged. +- [ ] Missing required headings are inserted with the current scaffold + placeholder text. +- [ ] Exit `1` is used for no-match selectors with an explicit error + line naming the selector. +- [ ] Exit `2` is used for invalid selectors or malformed retro packet + structure before writes begin. +- [ ] Exit `3` is used when one or more matched packets fail during + write, while other matched packets continue processing and the + final summary reports the failure count. diff --git a/docs/method/backlog/cool-ideas/PROCESS_review-config-hardening.md b/docs/method/backlog/cool-ideas/PROCESS_review-config-hardening.md index 9a2661a..28026ef 100644 --- a/docs/method/backlog/cool-ideas/PROCESS_review-config-hardening.md +++ b/docs/method/backlog/cool-ideas/PROCESS_review-config-hardening.md @@ -1,6 +1,7 @@ --- title: "Review Config Hardening" legend: PROCESS +lane: cool-ideas --- Harden repo review configuration so automated review tools understand diff --git a/docs/method/backlog/cool-ideas/PROCESS_review-state-query.md b/docs/method/backlog/cool-ideas/PROCESS_review-state-query.md new file mode 100644 index 0000000..1546300 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_review-state-query.md @@ -0,0 +1,99 @@ +--- +title: "Review State Query" +legend: PROCESS +lane: cool-ideas +owner: "METHOD maintainers" +priority: medium +acceptance_criteria: + - "The review-state surface reports CI/check status, unresolved review-thread count, review decision, and bot-review pending state for the active PR." + - "The output contract is explicit for both CLI and MCP: `method review-state` supports `--json`, `method_review_state` returns the standard structured-content envelope, and both share the same result schema." + - "The output includes a deterministic merge_ready boolean plus a canonical blocker schema naming exactly which states still prevent merge." + - "Selector precedence is explicit: `method review-state` defaults to `--current-branch`, rejects `--pr` with `--current-branch`, and defines deterministic no-PR and ambiguous-PR outputs." + - "Humans and agents can answer 'is this PR actually mergeable yet?' without manually reconstructing GitHub thread/check state." +--- + +# Review State Query + +METHOD currently says review state is not part of the repo-native +coordination surface. A dedicated query or signpost that summarizes what +is under review, awaiting merge, or blocked in PR state would make the +system cheaper for both humans and agents who currently have to bounce +out to forge context to answer that simple question. + +Recent PR review work showed the sharper version of this problem: a +branch can be technically clean while still being procedurally blocked +by pending bot review, stale `reviewDecision`, unresolved threads, or +checks that have not settled yet. That state is real coordination data, +but today it lives only in ad hoc `gh` inspection. + +## Proposed Contract + +- Surface: + this slice ships both a CLI command and an MCP tool: + `method review-state [--pr |--current-branch] [--json]` for + humans and scripts, plus `method_review_state` for MCP callers. A + generated signpost is out of scope for the initial slice; if a future + signpost is added, it should be a follow-on item with its own bounded + output contract rather than implied by this note. +- Selector rules: + `--pr` and `--current-branch` are mutually exclusive. If neither flag + is provided, `review-state` behaves as `--current-branch`. Passing + both flags is invalid input and should exit non-zero with a clear + error before any GitHub calls begin. +- Current-branch resolution: + when `--current-branch` maps to exactly one PR, inspect that PR. When + it maps to zero PRs, return a deterministic `status: no-pr` result + with `pr_number: null`, `pr_url: null`, `merge_ready: false`, and a + blocker such as `No PR found for current branch`. When it maps to + multiple PRs, return `status: ambiguous-pr`, keep `pr_number` and + `pr_url` null, set `merge_ready: false`, and include a blocker such as + `Multiple PRs match current branch; rerun with --pr `. +- Output shape: + CLI default output is a bounded human-readable summary; `--json` + prints the same result object returned by the MCP tool under + `structuredContent.result`. `method_review_state` must follow the + existing MCP envelope shape: + `structuredContent = { tool, ok, result }` on success and + `structuredContent = { tool, ok: false, error.message }` on failure. +- Required `structuredContent.result` fields and types: + `status: "ready" | "blocked" | "no-pr" | "ambiguous-pr"`; + `pr_number: integer | null`; + `pr_url: string | null`; + `review_decision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | "NONE"`; + `unresolved_thread_count: integer`; + `checks: { passing: Array<{ name: string, status: string, url: string | null }>, pending: Array<{ name: string, status: string, url: string | null }>, failing: Array<{ name: string, status: string, url: string | null }> }`; + `bot_review_state: "none" | "pending" | "approved" | "changes_requested" | "commented"`; + `approval_count: integer`; + `changes_requested_count: integer`; + `merge_ready: boolean`; + `blockers: Array<{ type: "selection" | "review_decision" | "unresolved_threads" | "pending_checks" | "failing_checks" | "bot_review" | "policy_cooldown", message: string, source: string }>`. + The result must include every field even in `no-pr` or + `ambiguous-pr` cases; for `no-pr`, use `pr_number: null`, + `pr_url: null`, `review_decision: "NONE"`, + `unresolved_thread_count: 0`, empty check arrays, + `bot_review_state: "none"`, `approval_count: 0`, + `changes_requested_count: 0`, `merge_ready: false`, and a single + `selection` blocker explaining the missing PR. +- Merge-ready rule: + `merge_ready` is true only when required checks are green, unresolved + review threads are zero, no required reviewer or bot still reports a + blocking state, and no `policy_cooldown` blocker is active. For the + initial slice, `policy_cooldown` means an explicitly observable forge + review cooldown or rate-limit message, such as a CodeRabbit comment or + check message saying review must be retried after a timestamp. Detect + it from the latest bot review/comment/check text; if no such message + is observable via API, do not invent a cooldown blocker. +- Blocker output: + `blockers` is a canonical array of structured objects, not free-form + strings. `type` comes from the fixed set above, while `message` is + human-readable. Example blockers are illustrative messages inside that + schema, for example + `{ type: "bot_review", message: "CodeRabbit review in progress", source: "coderabbitai" }`, + `{ type: "review_decision", message: "reviewDecision still CHANGES_REQUESTED", source: "github" }`, + or + `{ type: "unresolved_threads", message: "2 unresolved review threads", source: "github" }`. + +## Non-goals + +- Auto-merge a PR once the blockers list is empty. +- Replace human judgment about whether a change should merge. diff --git a/docs/method/backlog/cool-ideas/PROCESS_signpost-template-overrides.md b/docs/method/backlog/cool-ideas/PROCESS_signpost-template-overrides.md new file mode 100644 index 0000000..0df8d17 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_signpost-template-overrides.md @@ -0,0 +1,19 @@ +--- +title: "Signpost Template Overrides" +legend: PROCESS +lane: cool-ideas +--- + +# Signpost Template Overrides + +Generated signposts currently hardcode repo voice and section content in +renderer code. For example, `renderBearing` bakes in the "What feels +wrong?" bullets directly, which makes repo-specific customization or +experimentation require code changes instead of configuration or +template-level overrides. + +A lightweight signpost-template system could let repos keep METHOD's +structure while customizing selected generated sections. That would make +signpost generation less rigid, reduce pressure to fork the tool for +small wording changes, and open the door to agent-focused variants such +as concise machine-readable sections alongside human-oriented prose. diff --git a/docs/method/backlog/cool-ideas/PROCESS_spike-command.md b/docs/method/backlog/cool-ideas/PROCESS_spike-command.md new file mode 100644 index 0000000..a9bc4eb --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_spike-command.md @@ -0,0 +1,12 @@ +--- +title: "Spike Command" +legend: PROCESS +lane: cool-ideas +--- + +# Spike Command + +Add a `method spike` command that creates a properly prefixed spike +backlog item or packet with the right scaffolding and expectations. The +behavior-spike convention already exists in doctrine, but the repo still +relies on operators to remember the naming and capture rules by hand. diff --git a/docs/method/backlog/cool-ideas/PROCESS_validate-command.md b/docs/method/backlog/cool-ideas/PROCESS_validate-command.md new file mode 100644 index 0000000..e18162a --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_validate-command.md @@ -0,0 +1,13 @@ +--- +title: "Validate Command" +legend: PROCESS +lane: cool-ideas +--- + +# Validate Command + +Add a `method validate` command that checks repo-visible METHOD +invariants across backlog, design, retro, legends, and generated +signposts. This should be more than a general doctor command: it should +make the documentation and metadata contract cheap to verify for humans, +CI, and agents before drift leaks into committed state. diff --git a/docs/method/backlog/cool-ideas/PROCESS_visual-witness-screenshot-capture.md b/docs/method/backlog/cool-ideas/PROCESS_visual-witness-screenshot-capture.md new file mode 100644 index 0000000..31cf178 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_visual-witness-screenshot-capture.md @@ -0,0 +1,13 @@ +--- +title: "Visual Witness Screenshot Capture" +legend: PROCESS +lane: cool-ideas +--- + +# Visual Witness Screenshot Capture + +Add optional screenshot capture to witness generation for cycles that +have meaningful visual proof. METHOD should stay honest about the fact +that screenshots are supporting evidence rather than the done-claim, but +capturing them automatically would make visual witness packets cheaper +and more reproducible. diff --git a/docs/method/backlog/cool-ideas/PROCESS_witness-file-capture.md b/docs/method/backlog/cool-ideas/PROCESS_witness-file-capture.md new file mode 100644 index 0000000..59fda25 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PROCESS_witness-file-capture.md @@ -0,0 +1,13 @@ +--- +title: "Witness File Capture" +legend: PROCESS +lane: cool-ideas +--- + +# Witness File Capture + +Allow a design or cycle to declare specific files or directories that +should be copied or referenced into the witness packet during automated +capture. Some proofs live in generated artifacts or filesystem shape, +not just command output, and METHOD should be able to name those +artifacts explicitly instead of forcing one-off manual witness assembly. diff --git a/docs/method/backlog/cool-ideas/SYNTH_artifact-history-and-semantic-provenance.md b/docs/method/backlog/cool-ideas/SYNTH_artifact-history-and-semantic-provenance.md index 5bc9b27..ffd5502 100644 --- a/docs/method/backlog/cool-ideas/SYNTH_artifact-history-and-semantic-provenance.md +++ b/docs/method/backlog/cool-ideas/SYNTH_artifact-history-and-semantic-provenance.md @@ -1,6 +1,7 @@ --- title: "Artifact History And Semantic Provenance" legend: SYNTH +lane: cool-ideas --- Define two provenance levels for METHOD instead of treating everything diff --git a/docs/method/backlog/cool-ideas/SYNTH_cycle-witness-command.md b/docs/method/backlog/cool-ideas/SYNTH_cycle-witness-command.md index 9c17542..a8a109d 100644 --- a/docs/method/backlog/cool-ideas/SYNTH_cycle-witness-command.md +++ b/docs/method/backlog/cool-ideas/SYNTH_cycle-witness-command.md @@ -1,6 +1,7 @@ --- title: "Cycle Witness Command" legend: SYNTH +lane: cool-ideas --- Explore a `method witness` command that can package the rerunnable diff --git a/docs/method/backlog/cool-ideas/SYNTH_sync-vision-command.md b/docs/method/backlog/cool-ideas/SYNTH_sync-vision-command.md new file mode 100644 index 0000000..8191bde --- /dev/null +++ b/docs/method/backlog/cool-ideas/SYNTH_sync-vision-command.md @@ -0,0 +1,18 @@ +--- +title: "Sync Vision Command" +legend: SYNTH +lane: cool-ideas +--- + +# Sync Vision Command + +Add a `method sync vision` command that runs the Executive Summary +Protocol and refreshes `docs/VISION.md` as a bounded, provenance-carrying +signpost. It should inventory the governing repo surfaces, generate the +summary from repo truth, and attach a witness reference or manifest so a +later human or agent can inspect how the summary was derived. + +This idea already surfaced in spirit once `method sync ship` automated +other signpost maintenance. A dedicated vision sync would make summary +refresh cheaper, more reproducible, and more agent-friendly than +manually re-running a large read/summarize pass with ad hoc prompts. diff --git a/docs/method/backlog/inbox/PROCESS_i18n-string-extraction.md b/docs/method/backlog/inbox/PROCESS_i18n-string-extraction.md index b081188..67a8c75 100644 --- a/docs/method/backlog/inbox/PROCESS_i18n-string-extraction.md +++ b/docs/method/backlog/inbox/PROCESS_i18n-string-extraction.md @@ -1,6 +1,7 @@ --- title: "i18n String Extraction" legend: PROCESS +lane: inbox --- # i18n String Extraction diff --git a/docs/method/backlog/inbox/PROCESS_interactive-scaffolder.md b/docs/method/backlog/inbox/PROCESS_interactive-scaffolder.md index 626707b..b3c8428 100644 --- a/docs/method/backlog/inbox/PROCESS_interactive-scaffolder.md +++ b/docs/method/backlog/inbox/PROCESS_interactive-scaffolder.md @@ -1,6 +1,7 @@ --- title: "Interactive Scaffolder" legend: PROCESS +lane: inbox --- # Interactive Scaffolder diff --git a/docs/method/backlog/inbox/PROCESS_multi-forge-adapter.md b/docs/method/backlog/inbox/PROCESS_multi-forge-adapter.md index 9e8503e..9ee6daa 100644 --- a/docs/method/backlog/inbox/PROCESS_multi-forge-adapter.md +++ b/docs/method/backlog/inbox/PROCESS_multi-forge-adapter.md @@ -1,6 +1,7 @@ --- title: "Multi-Forge Adapter" legend: PROCESS +lane: inbox --- # Multi-Forge Adapter diff --git a/docs/method/backlog/inbox/PROCESS_semantic-drift-detector.md b/docs/method/backlog/inbox/PROCESS_semantic-drift-detector.md index 4f606d5..6719ef3 100644 --- a/docs/method/backlog/inbox/PROCESS_semantic-drift-detector.md +++ b/docs/method/backlog/inbox/PROCESS_semantic-drift-detector.md @@ -1,6 +1,7 @@ --- title: "Semantic Drift Detector" legend: PROCESS +lane: inbox --- # Semantic Drift Detector @@ -11,7 +12,7 @@ conceptually identical. ## Matching Rules -- **Automatic Match**: Cosine similarity >= 0.80 or LLM confidence >= 0.9. +- **Automatic Match**: Cosine similarity >= 0.80 or LLM confidence >= 0.90. - **Human Review**: Similarity between 0.65 and 0.80 triggers a "Near Miss" hint requiring manual confirmation. - **Non-Match**: Marked as drift if confidence is below 0.65. diff --git a/docs/method/backlog/up-next/PROCESS_live-legend-definition-coverage.md b/docs/method/backlog/up-next/PROCESS_live-legend-definition-coverage.md new file mode 100644 index 0000000..49f7608 --- /dev/null +++ b/docs/method/backlog/up-next/PROCESS_live-legend-definition-coverage.md @@ -0,0 +1,16 @@ +--- +title: "Live Legend Definition Coverage" +legend: PROCESS +lane: up-next +--- + +# Live Legend Definition Coverage + +The backlog is already using `MCP` as a live legend, but +`docs/method/legends/` still only defines `PROCESS` and `SYNTH`. Tests +and signposts currently treat legend docs as the discovered taxonomy, +which means the repo can have real legend-bearing work that is not +actually documented as a first-class legend. + +This is taxonomy drift. If a legend is established enough to organize backlog +work, it should either have a legend doc or stop being used as a legend. diff --git a/docs/method/feedback/README.md b/docs/method/feedback/README.md new file mode 100644 index 0000000..c2f94e1 --- /dev/null +++ b/docs/method/feedback/README.md @@ -0,0 +1,29 @@ +--- +title: "Feedback Inbox" +--- + +# Feedback Inbox + +Store raw feedback documents here until they are processed. + +A feedback doc is not yet backlog. It is source material: review notes, +critique, rough observations, or "this feels off" comments that may +produce zero, one, or many follow-up artifacts. + +Recommended contents: + +- what the feedback is +- where it came from +- when it was captured +- the concrete observations or suggestions + +When processing a feedback doc: + +1. Read it as input, not as already-prioritized work. +2. Turn accepted points into durable repo artifacts such as backlog + items, design edits, signpost updates, or release notes. +3. Add a short disposition note describing what happened. +4. Move the document to `docs/method/feedback/archive/`. + +Do not delete processed feedback by default. The archive is part of the +repo's memory. diff --git a/docs/method/feedback/archive/README.md b/docs/method/feedback/archive/README.md new file mode 100644 index 0000000..71bd6c1 --- /dev/null +++ b/docs/method/feedback/archive/README.md @@ -0,0 +1,48 @@ +--- +title: "Feedback Archive" +--- + +# Feedback Archive + +Processed feedback documents live here. + +Archive a feedback doc after its points have been triaged into durable +repo artifacts or explicitly marked as no action. + +Each archived document should preserve: + +- the original feedback +- a short disposition note +- links or references to any backlog items, docs updates, or other + outputs created from it + +## Archive Entry Template + +Use a minimal, predictable skeleton when archiving processed feedback: + +```markdown +# + +- Date: YYYY-MM-DD +- Author: +- Tags/Status: archived, no-action, converted-to-backlog, etc. + +## Original Feedback + + + +## Disposition + + + +## Related Items / Links + +- + +## Optional Follow-up Actions + +- [ ] +``` + +The goal is provenance, not cleanliness theater. Deleting processed +feedback throws away context that the repo may need later. diff --git a/docs/method/graveyard/MCP_status-summary-mode.md b/docs/method/graveyard/MCP_status-summary-mode.md new file mode 100644 index 0000000..501014e --- /dev/null +++ b/docs/method/graveyard/MCP_status-summary-mode.md @@ -0,0 +1,44 @@ +--- +title: "method_status Summary Mode" +legend: MCP +lane: graveyard +--- + +# MCP: method_status summary mode + +`method_status` returns the full backlog expanded — every item with path, +legend, slug, lane. For git-warp this is 68KB of JSON (215 backlog items). + +Most callers just want lane counts and active cycle names. The full +expansion blows through agent context windows and adds no value for the +common "where are we?" check. + +## Disposition + +Retired as a separate backlog item in cycle `0032-mcp-tool-result-contract`. +The summary-mode idea was absorbed into the broader MCP structured +result contract, but the shipped behavior preserved backward +compatibility: `method_status` still returns full status when `summary` +is omitted, and only returns the compact summary when callers pass +`summary: true`. This graveyard note remains historical context, not the +current API contract. See `docs/MCP.md` and `tests/mcp.test.ts` for the +live behavior. + +## Original Proposal + +The original proposal was to add an optional `summary` parameter with +default `true`. That default was not adopted in the final implementation. + +**Summary mode** returns: +- Lane counts: `{ inbox: 4, asap: 22, "up-next": 15, "cool-ideas": 50, "bad-code": 124 }` +- Active cycle stems +- Retro count +- Legend groups (if any) + +**Full mode** (`summary: false`) returns the current expanded output. + +## Context + +Discovered during dogfooding all 4 MCP servers in git-warp. Graft, Think, +and Bijou all return concise responses. METHOD is the outlier at 68KB for +a status check. diff --git a/docs/method/graveyard/PROCESS_legend-should-be-in-frontmatter-yaml.md b/docs/method/graveyard/PROCESS_legend-should-be-in-frontmatter-yaml.md new file mode 100644 index 0000000..2fadef1 --- /dev/null +++ b/docs/method/graveyard/PROCESS_legend-should-be-in-frontmatter-yaml.md @@ -0,0 +1,16 @@ +--- +title: "Legend Should Be In Frontmatter YAML" +legend: PROCESS +lane: graveyard +--- + +# Legend Should Be In Frontmatter YAML + +Require backlog items to carry their legend in YAML frontmatter instead of relying only on filename prefixes or inferred metadata. Commands that create, move, or audit backlog items should preserve and validate the `legend` field so legend-aware workflows have a single canonical source. + +## Disposition + +Retired as a separate backlog item after cycle +`0030-backlog-metadata-single-source-of-truth`. Backlog items now carry +legend metadata in YAML and METHOD operations preserve or backfill that +frontmatter, so this no longer needs to stay open as standalone work. diff --git a/docs/method/graveyard/README.md b/docs/method/graveyard/README.md new file mode 100644 index 0000000..ca809ba --- /dev/null +++ b/docs/method/graveyard/README.md @@ -0,0 +1,47 @@ +--- +title: "Graveyard" +--- + +# Graveyard + +Retired METHOD backlog items and related docs live here when the repo +should remember them but no longer treat them as active work. + +## Purpose and Scope + +Use the graveyard for backlog items, contracts, or process notes that +were superseded, absorbed by a broader cycle, or explicitly retired. +This directory is not a trash can. Items move here when their history +still matters. + +## Retention and Deletion Policy + +- Keep graveyard entries while they still explain why a path was closed + or absorbed. +- Delete only when the historical value is genuinely gone and no live + artifact depends on the tombstone for context. +- Prefer updating a graveyard doc with final disposition details over + silently removing it. + +## Entry Criteria + +- A backlog item was completed indirectly by a broader cycle. +- A standalone proposal was superseded by a stronger contract. +- A document should remain searchable as historical context but should + not return to active prioritization by default. + +## Exit Criteria + +- A retired idea becomes live work again and is reintroduced as a new + backlog item with fresh scope. +- Multiple tombstones are consolidated into a clearer retrospective or + other durable history artifact. + +## Inventory + +- `MCP_status-summary-mode.md` + Retired because cycle `0032-mcp-tool-result-contract` absorbed the + summary-mode behavior into the broader MCP result contract. +- `PROCESS_legend-should-be-in-frontmatter-yaml.md` + Retired because cycle `0030-backlog-metadata-single-source-of-truth` + absorbed the legend-frontmatter follow-on directly. diff --git a/docs/method/process.md b/docs/method/process.md index 57822a1..5000d2d 100644 --- a/docs/method/process.md +++ b/docs/method/process.md @@ -13,6 +13,9 @@ METHOD cycles run as a calm pull-design-test-playback-close-review-ship-sync loo - Drift is checked explicitly at close, not hand-waved after the fact. Invariant preservation is part of the drift check. - Backlog maintenance happens at cycle boundaries, not continuously. +- Feedback capture can happen anytime, but processing feedback into + backlog, design, signpost, or release work should happen at cycle + boundaries or on a dedicated triage branch. - Repo-level ship surfaces such as `BEARING.md` and `CHANGELOG.md` reflect merged `main` state, not branch-local closeout state. - Review visibility is currently outside METHOD's repo-native @@ -62,6 +65,21 @@ METHOD uses Git for distributed coordination but remains forge-agnostic. 4. **Close:** Run `method close` to write the retro and witness metadata. 5. **Merge:** Open a PR/Review. Once approved, merge to `main`. +### Feedback Processing + +1. **Capture:** Store raw review notes, critique, or outside-in + observations as markdown files under `docs/method/feedback/`. +2. **Classify:** Treat each feedback doc as unprocessed input, not as + backlog or doctrine on its own. +3. **Process:** During a maintenance pass, convert accepted feedback + into explicit repo artifacts such as backlog items, design edits, + signpost updates, or release notes. +4. **Record disposition:** Add a short note to the feedback document + describing what changed, what was rejected, or why no action was + taken. +5. **Archive:** Move the processed document to + `docs/method/feedback/archive/` instead of deleting it. + ### The Ship Sync Maneuver After a cycle branch is merged to `main`, the operator (human or agent) diff --git a/docs/method/retro/0030-backlog-metadata-single-source-of-truth/backlog-metadata-single-source-of-truth.md b/docs/method/retro/0030-backlog-metadata-single-source-of-truth/backlog-metadata-single-source-of-truth.md new file mode 100644 index 0000000..7b127f2 --- /dev/null +++ b/docs/method/retro/0030-backlog-metadata-single-source-of-truth/backlog-metadata-single-source-of-truth.md @@ -0,0 +1,40 @@ +--- +title: "Backlog Metadata Single Source Of Truth" +cycle: "0030-backlog-metadata-single-source-of-truth" +design_doc: "docs/design/0030-backlog-metadata-single-source-of-truth/backlog-metadata-single-source-of-truth.md" +outcome: hill-met +drift_check: yes +--- + +# Backlog Metadata Single Source Of Truth Retro + +## Summary + +This cycle made backlog metadata frontmatter-first across the live +workspace surfaces. `status`, `pull`, lane moves, and legend health now +prefer YAML metadata, managed moves repair missing lane metadata, and +the repo backlog was backfilled so the remaining cards stop depending on +directory-only truth. + +## Playback Witness + +- [Verification Witness](./witness/verification.md) + +## Drift + +- None recorded. + +## New Debt + +- `method close` generated this retro without the required frontmatter, + which directly triggered follow-on cycle `0031-generated-doc-scaffold-contract`. + +## Cool Ideas + +- None recorded. + +## Backlog Maintenance + +- [x] Inbox processed +- [x] Priorities reviewed +- [x] Dead work buried or merged diff --git a/docs/method/retro/0030-backlog-metadata-single-source-of-truth/witness/verification.md b/docs/method/retro/0030-backlog-metadata-single-source-of-truth/witness/verification.md new file mode 100644 index 0000000..064e07c --- /dev/null +++ b/docs/method/retro/0030-backlog-metadata-single-source-of-truth/witness/verification.md @@ -0,0 +1,35 @@ +--- +title: "Verification Witness for Cycle 30" +--- + +# Verification Witness for Cycle 30 + +This witness proves that `Backlog Metadata Single Source Of Truth` now carries the required +behavior and adheres to the repo invariants. + +## Test Results + +```text +> method@0.3.0 test +> vitest run --config vitest.config.ts + + + RUN v4.1.2 . + + + Test Files 12 passed (12) + Tests 131 passed (131) + Start at 12:38:25 + Duration 942ms (transform 1.21s, setup 0ms, import 3.30s, tests 1.15s, environment 1ms) +``` + +## Drift Results + +```text +No drift output captured. + +``` + +## Manual Verification + +- [x] Automated capture completed successfully. diff --git a/docs/method/retro/0031-generated-doc-scaffold-contract/generated-doc-scaffold-contract.md b/docs/method/retro/0031-generated-doc-scaffold-contract/generated-doc-scaffold-contract.md new file mode 100644 index 0000000..d041807 --- /dev/null +++ b/docs/method/retro/0031-generated-doc-scaffold-contract/generated-doc-scaffold-contract.md @@ -0,0 +1,42 @@ +--- +title: "Generated Doc Scaffold Contract" +cycle: "0031-generated-doc-scaffold-contract" +design_doc: "docs/design/0031-generated-doc-scaffold-contract/generated-doc-scaffold-contract.md" +outcome: hill-met +drift_check: yes +--- + +# Generated Doc Scaffold Contract Retro + +## Summary + +This cycle brought METHOD's generated design and retro scaffolds back +into parity with the repo's committed document contract. `method pull` +now emits design docs with the required frontmatter and non-TBD sponsor +roles, `method close` emits retro docs with commit-safe frontmatter, +close-time witness output now sanitizes personal absolute paths, and the +CLI tests now lock those scaffold guarantees in place. The already +generated `0030` retro and `0031` design packet were repaired on-branch +so the repo no longer depends on manual cleanup after generation. + +## Playback Witness + +- [Verification Witness](./witness/verification.md) + +## Drift + +- None recorded. + +## New Debt + +- None recorded. + +## Cool Ideas + +- None recorded. + +## Backlog Maintenance + +- [x] Inbox processed +- [x] Priorities reviewed +- [x] Dead work buried or merged diff --git a/docs/method/retro/0031-generated-doc-scaffold-contract/witness/verification.md b/docs/method/retro/0031-generated-doc-scaffold-contract/witness/verification.md new file mode 100644 index 0000000..50ba90f --- /dev/null +++ b/docs/method/retro/0031-generated-doc-scaffold-contract/witness/verification.md @@ -0,0 +1,35 @@ +--- +title: "Verification Witness for Cycle 31" +--- + +# Verification Witness for Cycle 31 + +This witness proves that `Generated Doc Scaffold Contract` now carries the required +behavior and adheres to the repo invariants. + +## Test Results + +```text +> method@0.3.0 test +> vitest run --config vitest.config.ts + + + RUN v4.1.2 . + + + Test Files 12 passed (12) + Tests 133 passed (133) + Start at 12:45:32 + Duration 777ms (transform 757ms, setup 0ms, import 2.43s, tests 960ms, environment 1ms) +``` + +## Drift Results + +```text +No drift output captured. + +``` + +## Manual Verification + +- [x] Automated capture completed successfully. diff --git a/docs/method/retro/0032-mcp-tool-result-contract/mcp-tool-result-contract.md b/docs/method/retro/0032-mcp-tool-result-contract/mcp-tool-result-contract.md new file mode 100644 index 0000000..ad52154 --- /dev/null +++ b/docs/method/retro/0032-mcp-tool-result-contract/mcp-tool-result-contract.md @@ -0,0 +1,43 @@ +--- +title: "MCP Tool Result Contract" +cycle: "0032-mcp-tool-result-contract" +design_doc: "docs/design/0032-mcp-tool-result-contract/mcp-tool-result-contract.md" +outcome: hill-met +drift_check: yes +--- + +# MCP Tool Result Contract Retro + +## Summary + +This cycle replaced MCP text-blob responses with a stable +`structuredContent` envelope across the server, added default summary +mode to `method_status` with opt-in full mode, and rewrote the MCP tests +to assert field-level results and structured errors directly. The +standalone `MCP_status-summary-mode` follow-on was retired into the +graveyard because its behavior is now absorbed by the broader contract +fix. + +## Playback Witness + +- [Verification Witness](./witness/verification.md) + +## Drift + +- None recorded. + +## Observed Debt + +- `PROCESS_witness-drift-output-capture` remains open: the close-time + verification witness in cycle `0032` re-observed an already-filed + drift-output capture gap from cycle `0031`. + +## Cool Ideas + +- None recorded. + +## Backlog Maintenance + +- [x] Inbox processed +- [x] Priorities reviewed +- [x] Dead work buried or merged diff --git a/docs/method/retro/0032-mcp-tool-result-contract/witness/verification.md b/docs/method/retro/0032-mcp-tool-result-contract/witness/verification.md new file mode 100644 index 0000000..015837a --- /dev/null +++ b/docs/method/retro/0032-mcp-tool-result-contract/witness/verification.md @@ -0,0 +1,35 @@ +--- +title: "Verification Witness for Cycle 32" +--- + +# Verification Witness for Cycle 32 + +This witness proves that `MCP Tool Result Contract` now carries the required +behavior and adheres to the repo invariants. + +## Test Results + +```text +> method@0.3.0 test +> vitest run --config vitest.config.ts + + + RUN v4.1.2 . + + + Test Files 12 passed (12) + Tests 135 passed (135) + Start at 12:59:12 + Duration 801ms (transform 794ms, setup 0ms, import 2.62s, tests 1.14s, environment 1ms) +``` + +## Drift Results + +```text +No drift output captured. + +``` + +## Manual Verification + +- [x] Automated capture completed successfully. diff --git a/docs/method/retro/0033-bearing-truthfulness/bearing-truthfulness.md b/docs/method/retro/0033-bearing-truthfulness/bearing-truthfulness.md new file mode 100644 index 0000000..c25fd14 --- /dev/null +++ b/docs/method/retro/0033-bearing-truthfulness/bearing-truthfulness.md @@ -0,0 +1,43 @@ +--- +title: "BEARING Truthfulness" +cycle: "0033-bearing-truthfulness" +design_doc: "docs/design/0033-bearing-truthfulness/bearing-truthfulness.md" +outcome: hill-met +drift_check: yes +--- + +# BEARING Truthfulness Retro + +## Summary + +This cycle made `renderBearing` stop speaking beyond repo truth. The +"What feels wrong?" section is now derived from live backlog and active +cycle counts with a bounded fallback when nothing acute is recorded, +instead of hardcoded complaints that drift over time. The stale witness +automation claim is gone from generated BEARING output, but the checked +in `docs/BEARING.md` artifact itself still refreshes only during ship +sync after merge on `main`. + +## Playback Witness + +- [Verification Witness](./witness/verification.md) + +## Drift + +- None recorded. + +## Observed Debt + +- `PROCESS_witness-drift-output-capture` remains open: close-time + verification witnesses in cycle `0033` still re-observed the existing + drift-output capture gap. + +## Cool Ideas + +- None recorded. + +## Backlog Maintenance + +- [x] Inbox processed +- [x] Priorities reviewed +- [x] Dead work buried or merged diff --git a/docs/method/retro/0033-bearing-truthfulness/witness/verification.md b/docs/method/retro/0033-bearing-truthfulness/witness/verification.md new file mode 100644 index 0000000..a2ac00c --- /dev/null +++ b/docs/method/retro/0033-bearing-truthfulness/witness/verification.md @@ -0,0 +1,35 @@ +--- +title: "Verification Witness for Cycle 33" +--- + +# Verification Witness for Cycle 33 + +This witness proves that `BEARING Truthfulness` now carries the required +behavior and adheres to the repo invariants. + +## Test Results + +```text +> method@0.3.0 test +> vitest run --config vitest.config.ts + + + RUN v4.1.2 . + + + Test Files 12 passed (12) + Tests 139 passed (139) + Start at 13:02:41 + Duration 814ms (transform 889ms, setup 0ms, import 2.77s, tests 1.25s, environment 1ms) +``` + +## Drift Results + +```text +No drift output captured. + +``` + +## Manual Verification + +- [x] Automated capture completed successfully. diff --git a/src/cli-args.ts b/src/cli-args.ts index 081a5d9..d55971e 100644 --- a/src/cli-args.ts +++ b/src/cli-args.ts @@ -6,7 +6,7 @@ export type ParsedCommand = | { command: 'init'; path: string } | { command: 'inbox'; idea: string; legend?: string; title?: string } | { command: 'pull'; item: string } - | { command: 'close'; cycle?: string; driftCheck?: 'yes' | 'no'; outcome?: Outcome } + | { command: 'close'; cycle?: string; driftCheck?: 'yes' | 'no'; outcome: Outcome } | { command: 'drift'; cycle?: string } | { command: 'status' } | { command: 'mcp' } @@ -90,7 +90,7 @@ export function usage(topic?: string): string { return 'Usage: method pull \n\nPromote a backlog item into the next numbered design cycle.'; } if (topic === 'close') { - return 'Usage: method close [cycle] [--drift-check yes|no] [--outcome hill-met|partial|not-met]\n\nClose an active cycle into docs/method/retro/.'; + return 'Usage: method close [cycle] [--drift-check yes|no] --outcome hill-met|partial|not-met\n\nClose an active cycle into docs/method/retro/. `--outcome` is required.'; } if (topic === 'status') { return 'Usage: method status\n\nShow backlog lanes, active cycles, and legend health.'; @@ -219,11 +219,15 @@ function parseCloseArgs(args: readonly string[]): ParsedCommand { throw new MethodError(`Unknown option: ${value}`); } if (cycle !== undefined) { - throw new MethodError('Usage: method close [cycle] [--drift-check yes|no] [--outcome hill-met|partial|not-met]'); + throw new MethodError(usage('close')); } cycle = value; } + if (outcome === undefined) { + throw new MethodError(usage('close')); + } + return { command: 'close', cycle, driftCheck, outcome }; } diff --git a/src/cli.ts b/src/cli.ts index 77d9098..8a9861b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -72,6 +72,9 @@ export async function runCli( const completedDriftCheck = parsed.driftCheck === undefined ? await promptConfirm({ title: 'Drift check complete?', defaultValue: false }) : parsed.driftCheck === 'yes'; + if (!completedDriftCheck) { + throw new Error('Cannot close a cycle without completing the drift check.'); + } const cycle = await workspace.closeCycle(parsed.cycle, completedDriftCheck, parsed.outcome); stdout.write(`${alert(`Closed ${cycle.name}`, { variant: 'success', ctx })}\n`); stdout.write(`${relative(root, cycle.retroDoc)}\n`); diff --git a/src/index.ts b/src/index.ts index f011e90..3deb314 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ import { import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import GitPlumbing from '@git-stunts/plumbing'; -import { basename, dirname, relative, resolve } from 'node:path'; +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'; import { type BacklogItem, type Cycle, @@ -53,6 +53,7 @@ function resolvePaths(root: string, paths: PathsConfig): ResolvedPaths { }; } +const LEGEND_CODE_PATTERN = /^[A-Z][A-Z0-9]*$/u; const LEGEND_PATTERN = /^(?[A-Z][A-Z0-9]*)_(?.+)$/; const CYCLE_PATTERN = /^(?\d{4})-(?[a-z0-9][a-z0-9-]*)$/; @@ -140,7 +141,7 @@ export class Workspace { let prefix = ''; if (legend !== undefined) { const normalized = legend.trim().toUpperCase(); - if (!/^[A-Z][A-Z0-9]*$/u.test(normalized)) { + if (!LEGEND_CODE_PATTERN.test(normalized)) { throw new MethodError('Legend codes must be uppercase letters and numbers.'); } prefix = `${normalized}_`; @@ -169,7 +170,7 @@ export class Workspace { throw new MethodError(`${relative(this.root, backlogItem)} is missing a heading.`); } - const { legend, slug } = splitLegend(fileStem(backlogItem)); + const { legend, slug } = this.readBacklogItem(backlogItem, inferBacklogLane(this.paths.backlog, backlogItem)); const cycleName = `${String(this.nextCycleNumber()).padStart(4, '0')}-${slug}`; const designDir = resolve(this.paths.design, cycleName); const designDoc = resolve(designDir, `${slug}.md`); @@ -181,6 +182,7 @@ export class Workspace { writeFileSync( designDoc, renderDesignDoc({ + cycleName, title, legend, source: relative(this.root, backlogItem), @@ -199,7 +201,7 @@ export class Workspace { }; } - async closeCycle(cycleName: string | undefined, completedDriftCheck: boolean, outcome?: Outcome): Promise { + async closeCycle(cycleName: string | undefined, completedDriftCheck: boolean, outcome: Outcome): Promise { if (!completedDriftCheck) { throw new MethodError('Cannot close a cycle without completing the drift check.'); } @@ -239,7 +241,7 @@ export class Workspace { const changelogPath = resolve(this.root, 'CHANGELOG.md'); const bearingPath = resolve(this.root, 'docs/BEARING.md'); const status = this.status(); - const closedCycles = this.allCycles().filter((cycle) => existsSync(cycle.retroDoc)); + const closedCycles = this.closedCycles(); const commitSha = await this.currentCommitSha(); const newShips = this.findNewShips(closedCycles); @@ -288,8 +290,8 @@ export class Workspace { mkdirSync(dirname(witnessPath), { recursive: true }); - const testResult = await this.execCommand('npm', ['test']); - const driftResult = await this.execCommand('tsx', ['src/cli.ts', 'drift', cycle.name]); + const testResult = sanitizeWitnessOutput(await this.execCommand('npm', ['test']), this.root); + const driftResult = sanitizeWitnessOutput(await this.execCommand('tsx', ['src/cli.ts', 'drift', cycle.name]), this.root); const content = renderWitnessDoc({ cycle, @@ -311,13 +313,13 @@ export class Workspace { root: [], }; - for (const lane of LANES) { - backlog[lane] = this.collectBacklogItems(resolve(this.paths.backlog, lane), lane); + const backlogItems = this.collectBacklogItems(); + for (const item of backlogItems) { + backlog[item.lane].push(item); } - backlog.root = this.collectBacklogItems(this.paths.backlog, 'root', false); const activeCycles = this.openCycles(); - const legendHealth = this.calculateLegendHealth(activeCycles); + const legendHealth = this.calculateLegendHealth(backlogItems, activeCycles); return { root: this.root, @@ -341,10 +343,12 @@ export class Workspace { moveBacklogItem(path: string, targetLane: Lane | 'graveyard'): string { const fullPath = resolve(this.root, path); + assertWorkspacePath(this.root, fullPath, 'Backlog path'); if (!existsSync(fullPath)) { throw new MethodError(`Backlog item not found: ${path}`); } + const metadata = this.readBacklogItem(fullPath, inferBacklogLane(this.paths.backlog, fullPath)); const fileName = basename(fullPath); let targetDir: string; if (targetLane === 'graveyard') { @@ -357,16 +361,19 @@ export class Workspace { mkdirSync(targetDir, { recursive: true }); const targetPath = resolve(targetDir, fileName); - - if (fullPath === targetPath) { - return relative(this.root, targetPath); - } - if (existsSync(targetPath)) { + if (fullPath !== targetPath && existsSync(targetPath)) { throw new MethodError(`Destination already exists: ${relative(this.root, targetPath)}`); } - renameSync(fullPath, targetPath); + if (fullPath !== targetPath) { + renameSync(fullPath, targetPath); + } + const frontmatterUpdates: Record = { lane: targetLane }; + if (metadata.legend !== undefined) { + frontmatterUpdates.legend = metadata.legend; + } + fmUpdateFrontmatter(targetPath, frontmatterUpdates); return relative(this.root, targetPath); } @@ -374,6 +381,10 @@ export class Workspace { return this.allCycles().filter((cycle) => existsSync(cycle.designDoc) && !existsSync(cycle.retroDoc)); } + closedCycles(): Cycle[] { + return this.allCycles().filter((cycle) => existsSync(cycle.retroDoc)); + } + private findNewShips(closedCycles: Cycle[]): Cycle[] { const changelogPath = resolve(this.root, 'CHANGELOG.md'); if (!existsSync(changelogPath)) { @@ -403,6 +414,7 @@ export class Workspace { private resolveBacklogItem(item: string): string { const direct = resolve(this.root, item); if (existsSync(direct)) { + assertWorkspacePath(this.root, direct, 'Backlog path'); return direct; } @@ -480,32 +492,36 @@ export class Workspace { throw new MethodError(`Could not find active cycle ${JSON.stringify(cycleName)}.`); } - private collectBacklogItems(dir: string, lane: Lane | 'root', recursive = true): BacklogItem[] { - if (!existsSync(dir)) { + private collectBacklogItems(): BacklogItem[] { + if (!existsSync(this.paths.backlog)) { return []; } - const files = recursive ? collectMarkdownFiles(dir) : readdirSync(dir, { withFileTypes: true }) - .filter(e => e.isFile() && e.name.endsWith('.md')) - .map(e => resolve(dir, e.name)); - - return files.map(file => { - const stem = fileStem(file); - const { legend, slug } = splitLegend(stem); - return { - stem, - lane, - path: relative(this.root, file), - legend, - slug - }; - }); + + return collectMarkdownFiles(this.paths.backlog).map((file) => + this.readBacklogItem(file, inferBacklogLane(this.paths.backlog, file))); + } + + private readBacklogItem(path: string, fallbackLane: Lane | 'root'): BacklogItem { + const stem = fileStem(path); + const filenameMetadata = splitLegend(stem); + const frontmatter = fmReadFrontmatter(path); + const hasExplicitLegend = Object.prototype.hasOwnProperty.call(frontmatter, 'legend'); + + return { + stem, + lane: normalizeBacklogLane(frontmatter.lane) ?? fallbackLane, + path: relative(this.root, path), + legend: hasExplicitLegend + ? normalizeLegend(frontmatter.legend) + : filenameMetadata.legend, + slug: filenameMetadata.slug, + }; } - private calculateLegendHealth(activeCycles: readonly Cycle[]): LegendHealth[] { + private calculateLegendHealth(backlogItems: readonly BacklogItem[], activeCycles: readonly Cycle[]): LegendHealth[] { const counts = new Map(); - for (const file of collectMarkdownFiles(this.paths.backlog)) { - const { legend } = splitLegend(fileStem(file)); - const key = legend ?? 'untagged'; + for (const item of backlogItems) { + const key = item.legend ?? 'untagged'; const current = counts.get(key) ?? { backlog: 0, active: 0 }; current.backlog += 1; counts.set(key, current); @@ -566,6 +582,14 @@ function collectMarkdownFiles(root: string, maxDepth = 10): string[] { return files.sort((left, right) => left.localeCompare(right)); } +function assertWorkspacePath(root: string, fullPath: string, label: string): void { + const relativePath = relative(root, fullPath); + if (relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))) { + return; + } + throw new MethodError(`${label} must stay inside the workspace: ${fullPath}`); +} + function splitLegend(stem: string): { legend?: string; slug: string } { const match = LEGEND_PATTERN.exec(stem); @@ -580,12 +604,17 @@ export const readHeading = fmReadHeading; export const readBody = fmReadBody; function readDesignLegend(path: string): string | undefined { + const frontmatter = fmReadFrontmatter(path); + if (Object.prototype.hasOwnProperty.call(frontmatter, 'legend')) { + return normalizeLegend(frontmatter.legend); + } + for (const line of readFileSync(path, 'utf8').split(/\r?\n/u)) { if (!line.startsWith('Legend: ')) { continue; } const value = line.slice('Legend: '.length).trim(); - return value === 'none' ? undefined : value; + return normalizeLegend(value); } return undefined; } @@ -602,3 +631,45 @@ function fileStem(path: string): string { const name = basename(path); return name.endsWith('.md') ? name.slice(0, -3) : name; } + +function inferBacklogLane(backlogRoot: string, path: string): Lane | 'root' { + const [firstSegment] = relative(backlogRoot, path).split(/[\\/]/u); + if (firstSegment !== undefined && (LANES as readonly string[]).includes(firstSegment)) { + return firstSegment as Lane; + } + return 'root'; +} + +function normalizeBacklogLane(value: string | undefined): Lane | 'root' | undefined { + const normalized = value?.trim(); + if (normalized === undefined || normalized.length === 0) { + return undefined; + } + if (normalized === 'root') { + return normalized; + } + if ((LANES as readonly string[]).includes(normalized)) { + return normalized as Lane; + } + return undefined; +} + +function normalizeLegend(value: string | undefined): string | undefined { + const normalized = value?.trim().toUpperCase(); + return normalized === undefined + || normalized.length === 0 + || normalized === 'NONE' + || !LEGEND_CODE_PATTERN.test(normalized) + ? undefined + : normalized; +} + +function sanitizeWitnessOutput(value: string, root: string): string { + return value + .replaceAll(root, '.') + .replace(/\/Users\/[^/\s]+/gu, '') + .replace(/\/home\/[^/\s]+/gu, '') + .replace(/\/root\b/gu, '') + .replace(/\/mnt\/[^/\s]+/gu, '') + .replace(/C:\\Users\\[^\\\s]+/gu, ''); +} diff --git a/src/mcp.ts b/src/mcp.ts index 635ddfc..df3cbd8 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,12 +1,20 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { relative } from 'node:path'; +import { basename, relative } from 'node:path'; import { Workspace } from './index.js'; -import { GitHubAdapter } from './adapters/github.js'; -import type { Outcome } from './domain.js'; +import { GitHubAdapter, type GitHubSyncResult } from './adapters/github.js'; +import type { Cycle, Outcome, WorkspaceStatus } from './domain.js'; const workspaceProperty = { workspace: { type: 'string' as const, description: 'Absolute path to the METHOD workspace root directory' } }; +interface McpStatusSummary { + root: string; + laneCounts: Record; + activeCycles: Array<{ name: string; slug: string }>; + retroCount: number; + legendHealth: WorkspaceStatus['legendHealth']; +} + export interface McpToolDef { name: string; description: string; @@ -21,7 +29,14 @@ export const MCP_TOOLS: McpToolDef[] = [ { name: 'method_status', description: 'Get the current status of the METHOD workspace (backlog lanes, active cycles, legend health)', - inputSchema: { type: 'object', properties: { ...workspaceProperty }, required: ['workspace'] }, + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + summary: { type: 'boolean', description: 'Return a compact structured summary instead of the fully expanded workspace status (default: false)' }, + }, + required: ['workspace'], + }, }, { name: 'method_inbox', @@ -104,7 +119,20 @@ export function createMcpServer() { if (request.params.name === 'method_status') { const status = workspace.status(); - return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] }; + const summary = validateOptionalBoolean(args.summary, 'summary') ?? false; + if (summary) { + const summaryResult = summarizeStatus(status, workspace.closedCycles()); + return successResult( + 'method_status', + renderStatusSummaryText(summaryResult), + { mode: 'summary', summary: summaryResult }, + ); + } + return successResult( + 'method_status', + `Returned full workspace status for ${workspace.root}.`, + { mode: 'full', status }, + ); } if (request.params.name === 'method_inbox') { @@ -113,44 +141,93 @@ export function createMcpServer() { args.legend as string | undefined, args.title as string | undefined, ); - return { content: [{ type: 'text', text: `Captured to ${relative(workspace.root, path)}` }] }; + const relativePath = relative(workspace.root, path); + const persistedItem = readPersistedInboxResult(workspace, relativePath); + return successResult( + 'method_inbox', + `Captured to ${relativePath}`, + persistedItem, + ); } if (request.params.name === 'method_pull') { const cycle = workspace.pullItem(args.item as string); - return { content: [{ type: 'text', text: `Pulled into ${cycle.name}\nDesign: ${relative(workspace.root, cycle.designDoc)}` }] }; + return successResult( + 'method_pull', + `Pulled into ${cycle.name}`, + { + cycle: relativizeCycle(workspace, cycle), + }, + ); } if (request.params.name === 'method_drift') { const report = workspace.detectDrift(args.cycle as string | undefined); - return { - content: [{ type: 'text', text: report.output }], - isError: report.exitCode !== 0, - }; + return toolResult( + 'method_drift', + report.output, + { + exitCode: report.exitCode, + output: report.output, + clean: report.exitCode === 0, + }, + report.exitCode !== 0, + ); } if (request.params.name === 'method_close') { + const cycleName = validateOptionalString(args.cycle, 'cycle'); + const driftCheck = validateBoolean(args.driftCheck, 'driftCheck'); + const outcome = validateOutcome(args.outcome); const cycle = await workspace.closeCycle( - args.cycle as string | undefined, - args.driftCheck as boolean, - args.outcome as Outcome, + cycleName, + driftCheck, + outcome, + ); + return successResult( + 'method_close', + `Closed ${cycle.name}`, + { + cycle: relativizeCycle(workspace, cycle), + }, ); - return { content: [{ type: 'text', text: `Closed ${cycle.name}\nRetro: ${relative(workspace.root, cycle.retroDoc)}` }] }; } if (request.params.name === 'method_sync_ship') { const result = await workspace.shipSync(); - const text = [ - `Updated: ${result.updated.join(', ')}`, - ...result.newShips.map((c) => `- Shipped ${c.name}`), - result.newShips.length === 0 ? 'No new ships.' : '', - ].join('\n'); - return { content: [{ type: 'text', text }] }; + const newShips = result.newShips.map((cycle) => relativizeCycle(workspace, cycle)); + const text = result.newShips.length === 0 + ? 'No new ships.' + : `Shipped ${result.newShips.map((cycle) => cycle.name).join(', ')}`; + return successResult( + 'method_sync_ship', + text, + { + updated: result.updated, + newShips, + }, + ); } if (request.params.name === 'method_sync_github') { - const push = (args.push as boolean | undefined) || (!args.push && !args.pull); - const pull = (args.pull as boolean | undefined) || false; + const validatedPush = validateOptionalBoolean(args.push, 'push'); + const validatedPull = validateOptionalBoolean(args.pull, 'pull'); + const pull = validatedPull ?? false; + const push = validatedPush ?? !pull; + + if (!push && !pull) { + return toolResult( + 'method_sync_github', + 'No changes.', + { + pushRequested: false, + pullRequested: false, + pushResults: [], + pullResults: [], + }, + false, + ); + } const token = workspace.config.github_token; const repoFull = workspace.config.github_repo; @@ -170,55 +247,206 @@ export function createMcpServer() { repo: repo!, }); - const log: string[] = []; let hasError = false; + let pushResults: GitHubSyncResult[] = []; + let pullResults: GitHubSyncResult[] = []; if (push) { - const results = await adapter.pushBacklog(); - for (const r of results) { - if (r.skipped) continue; - if (r.error) { - log.push(`Error pushing ${r.path}: ${r.error}`); - hasError = true; - } else { - const issueLabel = r.issue?.number ?? ''; - const verb = r.action === 'create' ? 'Created' : 'Updated'; - log.push(`${verb} GitHub Issue #${issueLabel} for ${r.path}`); - } - } + pushResults = await adapter.pushBacklog(); + hasError = hasError || pushResults.some((result) => result.error !== undefined); } if (pull) { - const results = await adapter.pullBacklog(); - for (const r of results) { - if (r.skipped) continue; - if (r.error) { - log.push(`Error pulling ${r.path}: ${r.error}`); - hasError = true; - } else { - const issueLabel = r.issue?.number ?? ''; - log.push(`Pulled remote changes from GitHub Issue #${issueLabel} into ${r.path}`); - } - } + pullResults = await adapter.pullBacklog(); + hasError = hasError || pullResults.some((result) => result.error !== undefined); } - return { - content: [{ type: 'text', text: log.join('\n') || 'No changes.' }], - isError: hasError, - }; + return toolResult( + 'method_sync_github', + summarizeGitHubSync(pushResults, pullResults), + { + pushRequested: push, + pullRequested: pull, + pushResults, + pullResults, + }, + hasError, + ); } if (request.params.name === 'method_capture_witness') { const path = await workspace.captureWitness(args.cycle as string | undefined); - return { content: [{ type: 'text', text: `Captured witness to ${relative(workspace.root, path)}` }] }; + const relativePath = relative(workspace.root, path); + return successResult( + 'method_capture_witness', + `Captured witness to ${relativePath}`, + { path: relativePath }, + ); } throw new Error(`Unknown tool: ${request.params.name}`); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - return { content: [{ type: 'text', text: message }], isError: true }; + return errorResult(request.params.name, message); } }); return server; } + +function summarizeStatus(status: WorkspaceStatus, closedCycles: Cycle[]): McpStatusSummary { + const laneCounts = Object.fromEntries( + Object.entries(status.backlog).map(([lane, items]) => [lane, items.length]), + ); + + return { + root: status.root, + laneCounts, + activeCycles: status.activeCycles.map((cycle) => ({ name: cycle.name, slug: cycle.slug })), + retroCount: closedCycles.length, + legendHealth: status.legendHealth, + }; +} + +function renderStatusSummaryText(summary: McpStatusSummary): string { + const laneCounts = Object.entries(summary.laneCounts) + .map(([lane, count]) => `${lane}=${count}`) + .join(', '); + const activeCycles = summary.activeCycles.length === 0 + ? '-' + : summary.activeCycles.map((cycle) => cycle.name).join(', '); + const legendHealth = summary.legendHealth.length === 0 + ? '-' + : summary.legendHealth + .map(({ legend, backlog, active }) => `${legend} backlog=${backlog} active=${active}`) + .join('; '); + return [ + `Status summary for ${summary.root}.`, + `Lane counts: ${laneCounts}`, + `Active cycles: ${activeCycles}`, + `Retros: ${summary.retroCount}`, + `Legend health: ${legendHealth}`, + ].join('\n'); +} + +function readPersistedInboxResult(workspace: Workspace, relativePath: string) { + const frontmatter = workspace.readFrontmatter(relativePath); + const stem = fileStem(relativePath); + const { legend, slug } = splitBacklogStem(stem); + const persistedLegend = normalizePersistedLegend(frontmatter.legend); + + return { + path: relativePath, + lane: frontmatter.lane ?? 'inbox', + legend: persistedLegend ?? legend, + title: frontmatter.title, + stem, + slug, + }; +} + +function relativizeCycle(workspace: Workspace, cycle: { name: string; number: number; slug: string; designDoc: string; retroDoc: string }) { + return { + ...cycle, + designDoc: relative(workspace.root, cycle.designDoc), + retroDoc: relative(workspace.root, cycle.retroDoc), + }; +} + +function summarizeGitHubSync(pushResults: GitHubSyncResult[], pullResults: GitHubSyncResult[]): string { + const changed = [...pushResults, ...pullResults].filter((result) => !result.skipped); + if (changed.length === 0) { + return 'No changes.'; + } + const failures = changed.filter((result) => result.error !== undefined).length; + return `GitHub sync processed ${changed.length} item(s) with ${failures} error(s).`; +} + +function successResult(tool: string, text: string, result: Record) { + return toolResult(tool, text, result, false); +} + +function errorResult(tool: string, message: string) { + return { + content: [{ type: 'text' as const, text: message }], + structuredContent: { + tool, + ok: false, + error: { message }, + }, + isError: true, + }; +} + +function toolResult(tool: string, text: string, result: Record, isError: boolean) { + return { + content: [{ type: 'text' as const, text }], + structuredContent: { + tool, + ok: !isError, + result, + }, + isError, + }; +} + +function fileStem(path: string): string { + const name = basename(path); + return name.endsWith('.md') ? name.slice(0, -3) : name; +} + +function splitBacklogStem(stem: string): { legend?: string; slug: string } { + const match = /^(?[A-Z][A-Z0-9]*)_(?.+)$/u.exec(stem); + if (match?.groups === undefined) { + return { slug: stem }; + } + return { legend: match.groups.legend, slug: match.groups.slug }; +} + +function normalizePersistedLegend(value: string | undefined): string | undefined { + const normalized = value?.trim().toUpperCase(); + return normalized === undefined + || normalized.length === 0 + || normalized === 'NONE' + || !/^[A-Z][A-Z0-9]*$/u.test(normalized) + ? undefined + : normalized; +} + +function validateOptionalString(value: unknown, name: string): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + throw new Error(`${name} must be a string when provided.`); + } + const normalized = value.trim(); + if (normalized.length === 0) { + throw new Error(`${name} must not be empty.`); + } + return normalized; +} + +function validateOptionalBoolean(value: unknown, name: string): boolean | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'boolean') { + throw new Error(`${name} must be a boolean.`); + } + return value; +} + +function validateBoolean(value: unknown, name: string): boolean { + if (typeof value !== 'boolean') { + throw new Error(`${name} must be a boolean.`); + } + return value; +} + +function validateOutcome(value: unknown): Outcome { + if (value === 'hill-met' || value === 'partial' || value === 'not-met') { + return value; + } + throw new Error('outcome must be one of: hill-met, partial, not-met.'); +} diff --git a/src/renderers.ts b/src/renderers.ts index 4020334..869c2e5 100644 --- a/src/renderers.ts +++ b/src/renderers.ts @@ -1,4 +1,4 @@ -import { relative } from 'node:path'; +import * as path from 'node:path'; import type { Cycle, Outcome, WorkspaceStatus } from './domain.js'; import { readHeading } from './frontmatter.js'; @@ -13,6 +13,7 @@ export function titleCase(value: string): string { export function renderBearing(status: WorkspaceStatus, closedCycles: Cycle[], commitSha: string): string { const latestShips = [...closedCycles].reverse().slice(0, 3); const nextUp = [...status.backlog.asap, ...status.backlog['up-next']].slice(0, 2); + const frictionLines = deriveBearingFriction(status); const priority = nextUp.length > 0 ? nextUp.map(i => `\`${i.stem}\``).join(' or ') : 'TBD'; @@ -45,18 +46,44 @@ export function renderBearing(status: WorkspaceStatus, closedCycles: Cycle[], co '', '## What feels wrong?', '', - '- Backlog maintenance is still largely manual.', - '- Witness generation is not yet automated.', + ...frictionLines, '', ].join('\n'); } +function deriveBearingFriction(status: WorkspaceStatus): string[] { + const lines: string[] = []; + + if (status.backlog.inbox.length > 0) { + lines.push(`- Inbox still holds ${status.backlog.inbox.length} untriaged item(s).`); + } + if (status.backlog.asap.length > 0) { + lines.push(`- ${status.backlog.asap.length} ASAP backlog item(s) are still unresolved.`); + } + if (status.backlog['bad-code'].length > 0) { + lines.push(`- ${status.backlog['bad-code'].length} bad-code item(s) remain tracked.`); + } + if (status.activeCycles.length > 0) { + lines.push(`- ${status.activeCycles.length} active cycle(s) are still open.`); + } + + return lines.length > 0 + ? lines + : ['- No acute coordination pain is currently recorded.']; +} + export function renderWitnessDoc(options: { cycle: Cycle; testResult: string; driftResult: string; }): string { const title = readHeading(options.cycle.designDoc) || titleCase(options.cycle.slug); + const testResult = options.testResult.trim().length === 0 + ? 'No test output captured.' + : options.testResult; + const driftResult = options.driftResult.trim().length === 0 + ? 'No drift output captured.' + : options.driftResult; return [ '---', `title: "Verification Witness for Cycle ${options.cycle.number}"`, @@ -69,14 +96,14 @@ export function renderWitnessDoc(options: { '', '## Test Results', '', - '```', - options.testResult.trim(), + '```text', + testResult, '```', '', '## Drift Results', '', - '```', - options.driftResult.trim(), + '```text', + driftResult, '```', '', '## Manual Verification', @@ -87,22 +114,31 @@ export function renderWitnessDoc(options: { } export function renderDesignDoc(options: { + cycleName: string; title: string; legend?: string; source: string; backlogBody: string; }): string { const legendValue = options.legend ?? 'none'; + const sourcePath = normalizeRepoPath(options.source); return [ + '---', + `title: ${yamlString(options.title)}`, + `legend: ${yamlString(legendValue)}`, + `cycle: ${yamlString(options.cycleName)}`, + `source_backlog: ${yamlString(sourcePath)}`, + '---', + '', `# ${options.title}`, '', - `Source backlog item: \`${options.source}\``, + `Source backlog item: \`${sourcePath}\``, `Legend: ${legendValue}`, '', '## Sponsors', '', - '- Human: TBD', - '- Agent: TBD', + '- Human: Backlog operator', + '- Agent: Implementation agent', '', '## Hill', '', @@ -147,16 +183,25 @@ export function renderDesignDoc(options: { export function renderRetroDoc(options: { cycle: Cycle; root: string; - outcome?: Outcome; + outcome: Outcome; witnessDir: string; }): string { + if (options.outcome !== 'hill-met' && options.outcome !== 'partial' && options.outcome !== 'not-met') { + throw new Error('Outcome is required and must be one of: hill-met, partial, not-met.'); + } const title = readHeading(options.cycle.designDoc) || titleCase(options.cycle.slug); + const designDoc = normalizeRepoPath(path.relative(options.root, options.cycle.designDoc)); + const witnessDir = normalizeRepoPath(options.witnessDir); return [ - `# ${title} Retro`, + '---', + `title: ${yamlString(title)}`, + `cycle: ${yamlString(options.cycle.name)}`, + `design_doc: ${yamlString(designDoc)}`, + `outcome: ${options.outcome}`, + 'drift_check: yes', + '---', '', - `Design: \`${relative(options.root, options.cycle.designDoc)}\``, - `Outcome: ${options.outcome ?? 'TBD'}`, - 'Drift check: yes', + `# ${title} Retro`, '', '## Summary', '', @@ -164,7 +209,7 @@ export function renderRetroDoc(options: { '', '## Playback Witness', '', - `Add artifacts under \`${options.witnessDir}\` and link them here.`, + `Add artifacts under \`${witnessDir}\` and link them here.`, '', '## Drift', '', @@ -186,3 +231,16 @@ export function renderRetroDoc(options: { '', ].join('\n'); } + +function yamlString(value: string): string { + return `"${value + .replace(/\\/gu, '\\\\') + .replace(/"/gu, '\\"') + .replace(/\r\n/gu, '\\r\\n') + .replace(/\r/gu, '\\r') + .replace(/\n/gu, '\\n')}"`; +} + +function normalizeRepoPath(value: string): string { + return value.replace(/\\/gu, '/'); +} diff --git a/tests/api.test.ts b/tests/api.test.ts index e1dbf1a..8f6a0cc 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, relative as pathRelative } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { initWorkspace, Workspace } from '../src/index.js'; @@ -64,6 +64,257 @@ describe('Method API', () => { expect(statusAfter.activeCycles[0].name).toBe(cycle.name); }); + it("If a backlog card's filename or directory disagrees with its YAML, does `method status` report the YAML lane and legend rather than the path-derived guess?", () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_frontmatter-wins.md'), + [ + '---', + 'title: "Frontmatter Wins"', + 'legend: SYNTH', + 'lane: asap', + '---', + '', + '# Frontmatter Wins', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const status = workspace.status(); + expect(status.backlog.inbox).toEqual([]); + expect(status.backlog.asap).toContainEqual({ + stem: 'PROCESS_frontmatter-wins', + lane: 'asap', + path: 'docs/method/backlog/inbox/PROCESS_frontmatter-wins.md', + legend: 'SYNTH', + slug: 'frontmatter-wins', + }); + expect(status.legendHealth).toContainEqual({ + legend: 'SYNTH', + backlog: 1, + active: 0, + }); + expect(status.legendHealth.find((entry) => entry.legend === 'PROCESS')).toBeUndefined(); + }); + + it('If I move a legacy backlog card between lanes, does METHOD backfill lane metadata so the card no longer depends on path inference?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_legacy-item.md'), + '# Legacy Item\n\nBody\n', + 'utf8', + ); + + const moved = workspace.moveBacklogItem( + 'docs/method/backlog/inbox/PROCESS_legacy-item.md', + 'up-next', + ); + + expect(moved).toBe('docs/method/backlog/up-next/PROCESS_legacy-item.md'); + expect(workspace.readFrontmatter(moved)).toMatchObject({ + lane: 'up-next', + legend: 'PROCESS', + }); + }); + + it('If I move an item to the lane it is already in, does METHOD still repair stale or missing frontmatter metadata?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_same-lane-repair.md'), + [ + '---', + 'title: "Same Lane Repair"', + 'lane: inbox', + '---', + '', + '# Same Lane Repair', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const moved = workspace.moveBacklogItem( + 'docs/method/backlog/up-next/PROCESS_same-lane-repair.md', + 'up-next', + ); + + expect(moved).toBe('docs/method/backlog/up-next/PROCESS_same-lane-repair.md'); + expect(workspace.readFrontmatter(moved)).toMatchObject({ + lane: 'up-next', + legend: 'PROCESS', + }); + }); + + it('Rejects moving backlog paths that escape the workspace root.', () => { + const root = createTempRoot(); + const outsideRoot = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + const outsidePath = join(outsideRoot, 'outside.md'); + writeFileSync(outsidePath, '# Outside\n\nBody\n', 'utf8'); + + expect(() => workspace.moveBacklogItem(outsidePath, 'graveyard')).toThrow(/workspace/u); + expect(existsSync(outsidePath)).toBe(true); + }); + + it('Rejects pulling backlog items through traversal paths that escape the workspace root.', () => { + const root = createTempRoot(); + const outsideRoot = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + const outsidePath = join(outsideRoot, 'outside.md'); + writeFileSync(outsidePath, '# Outside\n\nBody\n', 'utf8'); + const traversalPath = pathRelative(root, outsidePath); + + expect(() => workspace.pullItem(traversalPath)).toThrow(/workspace/u); + expect(existsSync(outsidePath)).toBe(true); + }); + + it('If a backlog card has stale filename legend text, does `method pull` still carry the YAML legend into the design packet?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/asap/PROCESS_frontmatter-pull.md'), + [ + '---', + 'title: "Frontmatter Pull"', + 'legend: SYNTH', + 'lane: asap', + '---', + '', + '# Frontmatter Pull', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const cycle = workspace.pullItem('PROCESS_frontmatter-pull'); + expect(cycle.name).toBe('0001-frontmatter-pull'); + expect(readFileSync(cycle.designDoc, 'utf8')).toContain('Legend: SYNTH'); + }); + + it('If frontmatter explicitly clears a legacy filename legend with `NONE`, does METHOD stop falling back to the filename prefix?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/asap/PROCESS_frontmatter-clears-legend.md'), + [ + '---', + 'title: "Frontmatter Clears Legend"', + 'legend: NONE', + 'lane: asap', + '---', + '', + '# Frontmatter Clears Legend', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const status = workspace.status(); + expect(status.backlog.asap).toContainEqual({ + stem: 'PROCESS_frontmatter-clears-legend', + lane: 'asap', + path: 'docs/method/backlog/asap/PROCESS_frontmatter-clears-legend.md', + legend: undefined, + slug: 'frontmatter-clears-legend', + }); + + const cycle = workspace.pullItem('PROCESS_frontmatter-clears-legend'); + const designDoc = readFileSync(cycle.designDoc, 'utf8'); + expect(designDoc).toContain('legend: "none"'); + expect(designDoc).toContain('Legend: none'); + }); + + it('Invalid explicit frontmatter legends do not override backlog filename truth or leak into pulled design docs.', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/asap/PROCESS_invalid-frontmatter-legend.md'), + [ + '---', + 'title: "Invalid Frontmatter Legend"', + 'legend: human review', + 'lane: asap', + '---', + '', + '# Invalid Frontmatter Legend', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const status = workspace.status(); + expect(status.backlog.asap).toContainEqual({ + stem: 'PROCESS_invalid-frontmatter-legend', + lane: 'asap', + path: 'docs/method/backlog/asap/PROCESS_invalid-frontmatter-legend.md', + legend: undefined, + slug: 'invalid-frontmatter-legend', + }); + + const cycle = workspace.pullItem('PROCESS_invalid-frontmatter-legend'); + const designDoc = readFileSync(cycle.designDoc, 'utf8'); + expect(designDoc).toContain('legend: "none"'); + expect(designDoc).toContain('Legend: none'); + }); + + it('Can I consume backlog lane and legend from `Workspace.status()` without reverse-engineering filename prefixes or folder names?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + const cycleDir = join(root, 'docs/design/0001-frontmatter-design'); + const designDoc = join(cycleDir, 'frontmatter-design.md'); + mkdirSync(cycleDir, { recursive: true }); + writeFileSync( + designDoc, + [ + '---', + 'title: "Frontmatter Design"', + 'legend: PROCESS', + '---', + '', + '# Frontmatter Design', + '', + '## Hill', + '', + 'TBD', + ].join('\n'), + 'utf8', + ); + + const status = workspace.status(); + expect(status.legendHealth).toContainEqual({ + legend: 'PROCESS', + backlog: 0, + active: 1, + }); + }); + it('All existing `tests/cli.test.ts` pass, proving no regressions in the command-line adapter.', () => { // This playback claim is verified by running the full test suite. }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a9a05ec..b28ed55 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -60,6 +60,7 @@ describe('method CLI', () => { expect(exitCode).toBe(0); expect(stdout.output).toContain('Usage: method close'); expect(stdout.output).toContain('Close an active cycle into docs/method/retro/.'); + expect(stdout.output).toContain('--outcome hill-met|partial|not-met'); }); it('captures backlog ideas in inbox', async () => { @@ -95,7 +96,7 @@ describe('method CLI', () => { expectFile(root, 'docs/method/backlog/inbox/backfill-the-missing-inbox-lane.md'); }); - it('promotes backlog items into numbered cycles', async () => { + it('Does `method pull` create a design doc that already includes required frontmatter fields like `title`, `legend`, `cycle`, and `source_backlog`?', async () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); writeFileSync( @@ -115,14 +116,21 @@ describe('method CLI', () => { expect(stdout.output).toContain('Pulled into 0001-strand-lifecycle'); expectFile(root, 'docs/design/0001-strand-lifecycle/strand-lifecycle.md'); const designDoc = readFile(root, 'docs/design/0001-strand-lifecycle/strand-lifecycle.md'); + expect(designDoc).toMatch(/^---\n[\s\S]+?\n---\n/u); + expect(designDoc).toMatch(/^title:\s+"Strand Lifecycle"$/mu); + expect(designDoc).toMatch(/^legend:\s+"PROTO"$/mu); + expect(designDoc).toMatch(/^cycle:\s+"0001-strand-lifecycle"$/mu); + expect(designDoc).toMatch(/^source_backlog:\s+"docs\/method\/backlog\/asap\/PROTO_strand-lifecycle\.md"$/mu); expect(designDoc).toContain('Legend: PROTO'); + expect(designDoc).toContain('- Human: Backlog operator'); + expect(designDoc).toContain('- Agent: Implementation agent'); expect(designDoc).toContain('Mechanical state transitions for strands.'); expect(designDoc).toContain('## Accessibility and Assistive Reading'); expect(designDoc).toContain('## Localization and Directionality'); expect(designDoc).toContain('## Agent Inspectability and Explainability'); }); - it('writes retros and witness directories on close', async () => { + it('Does `method close` create a retro doc that already includes required frontmatter fields like `title`, `outcome`, `drift_check`, and `design_doc`?', async () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); mkdirSync(join(root, 'docs/design/0001-method-cli'), { recursive: true }); @@ -143,7 +151,87 @@ describe('method CLI', () => { expect(stdout.output).toContain('Closed 0001-method-cli'); expectFile(root, 'docs/method/retro/0001-method-cli/method-cli.md'); expectFile(root, 'docs/method/retro/0001-method-cli/witness'); - expect(readFile(root, 'docs/method/retro/0001-method-cli/method-cli.md')).toContain('Outcome: partial'); + const retroDoc = readFile(root, 'docs/method/retro/0001-method-cli/method-cli.md'); + expect(retroDoc).toMatch(/^---\n[\s\S]+?\n---\n/u); + expect(retroDoc).toMatch(/^title:\s+"Method CLI"$/mu); + expect(retroDoc).toMatch(/^outcome:\s+partial$/mu); + expect(retroDoc).toMatch(/^drift_check:\s+yes$/mu); + expect(retroDoc).toMatch(/^design_doc:\s+"docs\/design\/0001-method-cli\/method-cli\.md"$/mu); + expect(retroDoc).not.toContain('Design: `docs/design/0001-method-cli/method-cli.md`'); + expect(retroDoc).not.toContain('Outcome: partial'); + expect(retroDoc).not.toContain('Drift check: yes'); + }); + + it('Can I rely on scaffolded design docs to satisfy the repo docs contract without manual patching?', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + writeFileSync( + join(root, 'docs/method/backlog/asap/PROCESS_scaffold-contract.md'), + [ + '---', + 'title: "Scaffold Contract"', + 'legend: PROCESS', + 'lane: asap', + '---', + '', + '# Scaffold Contract', + '', + 'Backlog body.', + ].join('\n'), + 'utf8', + ); + + const exitCode = await runCli(['pull', 'PROCESS_scaffold-contract'], { + cwd: root, + stdout: new MemoryWriter(), + stderr: new MemoryWriter(), + }); + + expect(exitCode).toBe(0); + const designDoc = readFile(root, 'docs/design/0001-scaffold-contract/scaffold-contract.md'); + expect(designDoc).toMatch(/^legend:\s+"PROCESS"$/mu); + expect(designDoc).toMatch(/^cycle:\s+"0001-scaffold-contract"$/mu); + expect(designDoc).not.toContain('- Human: TBD'); + expect(designDoc).not.toContain('- Agent: TBD'); + }); + + it('Can I rely on scaffolded retro docs to satisfy the repo docs contract without post-close repairs?', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + mkdirSync(join(root, 'docs/design/0001-scaffold-retro'), { recursive: true }); + writeFileSync( + join(root, 'docs/design/0001-scaffold-retro/scaffold-retro.md'), + [ + '---', + 'title: "Scaffold Retro"', + 'legend: PROCESS', + 'cycle: "0001-scaffold-retro"', + 'source_backlog: "docs/method/backlog/asap/PROCESS_scaffold-retro.md"', + '---', + '', + '# Scaffold Retro', + '', + 'Legend: PROCESS', + '', + '## Playback Questions', + '', + '- [ ] TBD', + ].join('\n'), + 'utf8', + ); + + const exitCode = await runCli(['close', '0001-scaffold-retro', '--drift-check', 'yes', '--outcome', 'hill-met'], { + cwd: root, + stdout: new MemoryWriter(), + stderr: new MemoryWriter(), + }); + + expect(exitCode).toBe(0); + const retroDoc = readFile(root, 'docs/method/retro/0001-scaffold-retro/scaffold-retro.md'); + expect(retroDoc).toMatch(/^outcome:\s+hill-met$/mu); + expect(retroDoc).toMatch(/^drift_check:\s+yes$/mu); + expect(retroDoc).toMatch(/^design_doc:\s+"docs\/design\/0001-scaffold-retro\/scaffold-retro\.md"$/mu); + expect(retroDoc).not.toContain('Outcome: TBD'); }); it('shows backlog, active cycles, and legend health in status', async () => { @@ -188,7 +276,7 @@ describe('method CLI', () => { writeFileSync(join(root, 'docs/design/0001-method-cli/method-cli.md'), '# Method CLI\n\nLegend: none\n', 'utf8'); const stderr = new MemoryWriter(); - const exitCode = await runCli(['close', '--drift-check', 'no'], { + const exitCode = await runCli(['close', '--drift-check', 'no', '--outcome', 'partial'], { cwd: root, stdout: new MemoryWriter(), stderr, @@ -198,6 +286,24 @@ describe('method CLI', () => { expect(stderr.output.toLowerCase()).toContain('drift check'); }); + it('refuses close when outcome is omitted', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + mkdirSync(join(root, 'docs/design/0001-method-cli'), { recursive: true }); + writeFileSync(join(root, 'docs/design/0001-method-cli/method-cli.md'), '# Method CLI\n\nLegend: none\n', 'utf8'); + + const stderr = new MemoryWriter(); + const exitCode = await runCli(['close', '--drift-check', 'yes'], { + cwd: root, + stdout: new MemoryWriter(), + stderr, + }); + + expect(exitCode).toBe(1); + expect(stderr.output).toContain('Usage: method close'); + expect(stderr.output).toContain('--outcome hill-met|partial|not-met'); + }); + it('Can I run the detector and get a concise list of playback questions that have no matching test evidence, with the design file and question text called out directly?', async () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); diff --git a/tests/docs.test.ts b/tests/docs.test.ts index 058f4f4..c6894d3 100644 --- a/tests/docs.test.ts +++ b/tests/docs.test.ts @@ -354,6 +354,9 @@ describe('METHOD docs', () => { 'outcome', 'drift_check', 'lane', + 'owner', + 'priority', + 'acceptance_criteria', 'github_issue_id', 'github_issue_url', 'github_labels', @@ -714,6 +717,7 @@ describe('METHOD docs', () => { expect(cli).toContain('--push'); expect(cli).toContain('--pull'); expect(cli).toContain('Ship Sync'); + expect(cli).toContain('method close [cycle] [--drift-check yes|no] --outcome hill-met|partial|not-met'); // MCP doc names every tool expect(mcp).toContain('method_status'); @@ -724,6 +728,10 @@ describe('METHOD docs', () => { expect(mcp).toContain('method_sync_ship'); expect(mcp).toContain('method_sync_github'); expect(mcp).toContain('method_capture_witness'); + expect(mcp).toMatch(/### `method_status`[\s\S]*- `summary` \(optional\) `boolean`/u); + expect(mcp).toMatch(/Machine-readable callers should consume `structuredContent`\./u); + expect(mcp).toMatch(/On success, `structuredContent` includes:[\s\S]*- `tool`[\s\S]*- `ok: true`[\s\S]*- `result`/u); + expect(mcp).toMatch(/On failure, tools set `isError: true` and `structuredContent` includes:[\s\S]*- `tool`[\s\S]*- `ok: false`[\s\S]*- `error\.message`/u); // README references both docs expect(readme).toContain('docs/CLI.md'); @@ -745,4 +753,11 @@ describe('METHOD docs', () => { expect(releasesGuide).toContain('Migration'); expect(releasesGuide).toContain('No migration required.'); }); + + it('keeps `CHANGELOG.md` Unreleased notes ahead of tagged releases', () => { + const changelog = readRepoFile('CHANGELOG.md'); + const sections = [...changelog.matchAll(/^##\s+(.+)$/gmu)].map((match) => match[1] ?? ''); + + expect(sections[0]).toBe('Unreleased'); + }); }); diff --git a/tests/github-adapter.test.ts b/tests/github-adapter.test.ts index bec486d..9b503d7 100644 --- a/tests/github-adapter.test.ts +++ b/tests/github-adapter.test.ts @@ -171,5 +171,6 @@ describe('GitHub Adapter Two-way Sync', () => { expect(existsSync(graveyardPath), 'file should exist in graveyard').toBe(true); expect(existsSync(inboxPath), 'file should no longer exist in inbox').toBe(false); expect(readFileSync(graveyardPath, 'utf8')).toContain('Closed Item'); + expect(workspace.readFrontmatter('docs/method/graveyard/FEAT_closed.md').lane).toBe('graveyard'); }); }); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8199dee..c99acf1 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,7 +1,8 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { GitHubAdapter } from '../src/adapters/github.js'; import { createMcpServer } from '../src/mcp.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; @@ -14,6 +15,7 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }); } tempRoots.length = 0; + vi.restoreAllMocks(); }); function createTempRoot(): string { @@ -22,6 +24,22 @@ function createTempRoot(): string { return root; } +function createCallToolHarness() { + let callToolHandler: any; + const mockServer = { + setRequestHandler: vi.fn((schema, handler) => { + if (schema === CallToolRequestSchema) { + callToolHandler = handler; + } + }), + }; + + vi.spyOn(Server.prototype, 'setRequestHandler').mockImplementation(mockServer.setRequestHandler); + createMcpServer(); + expect(callToolHandler).toBeDefined(); + return callToolHandler; +} + describe('MCP Server', () => { it('Does `src/mcp.ts` export a functional MCP server using `@modelcontextprotocol/sdk`?', () => { const server = createMcpServer(); @@ -60,66 +78,378 @@ describe('MCP Server', () => { expect(tool.inputSchema.required, `${tool.name} must require workspace`).toContain('workspace'); expect(tool.inputSchema.properties.workspace, `${tool.name} must have workspace property`).toBeDefined(); } + const statusTool = result.tools.find((tool: any) => tool.name === 'method_status'); + expect(statusTool.inputSchema.properties.summary).toBeDefined(); vi.restoreAllMocks(); }); - it('Can an MCP client connect to `method` and interact with the backlog without parsing terminal text?', () => { - // This is a human/client-level playback question, but we verify it's possible through our agent tests. - // The structured responses demonstrated in other tests prove this is feasible. + it('Does `method_status` preserve the legacy full-status default when the caller omits the new summary flag?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + workspace.captureIdea('test idea from mcp', 'PROCESS', 'Test Idea From MCP'); + const callToolHandler = createCallToolHarness(); + + const statusResult = await callToolHandler({ params: { name: 'method_status', arguments: { workspace: root } } }); + expect(statusResult.isError).toBeFalsy(); + expect(statusResult.structuredContent.tool).toBe('method_status'); + expect(statusResult.structuredContent.ok).toBe(true); + expect(statusResult.structuredContent.result.mode).toBe('full'); + expect(statusResult.structuredContent.result.status.backlog.inbox).toContainEqual({ + stem: 'PROCESS_test-idea-from-mcp', + lane: 'inbox', + path: 'docs/method/backlog/inbox/PROCESS_test-idea-from-mcp.md', + legend: 'PROCESS', + slug: 'test-idea-from-mcp', + }); + expect(statusResult.structuredContent.result.status.legendHealth).toContainEqual({ + legend: 'PROCESS', + backlog: 1, + active: 0, + }); + expect(statusResult.content[0].text).toContain('Returned full workspace status'); + vi.restoreAllMocks(); }); - it('Do unit tests verify the MCP server integration and its tools?', async () => { + it('Can I request `method_status` summary mode explicitly and get compact lane counts without the full backlog payload?', async () => { const root = createTempRoot(); initWorkspace(root); + const workspace = new Workspace(root); + workspace.captureIdea('test idea from mcp', undefined, 'Test Idea From MCP'); + const callToolHandler = createCallToolHarness(); - let callToolHandler: any; - const mockServer = { - setRequestHandler: vi.fn((schema, handler) => { - if (schema === CallToolRequestSchema) { - callToolHandler = handler; - } - }), - }; + const statusResult = await callToolHandler({ + params: { name: 'method_status', arguments: { workspace: root, summary: true } }, + }); - vi.spyOn(Server.prototype, 'setRequestHandler').mockImplementation(mockServer.setRequestHandler); + expect(statusResult.isError).toBeFalsy(); + expect(statusResult.structuredContent.tool).toBe('method_status'); + expect(statusResult.structuredContent.ok).toBe(true); + expect(statusResult.structuredContent.result.mode).toBe('summary'); + expect(statusResult.structuredContent.result.summary.laneCounts.inbox).toBe(1); + expect(statusResult.structuredContent.result.summary.retroCount).toBe(0); + expect(statusResult.structuredContent.result.summary.activeCycles).toEqual([]); + expect(statusResult.structuredContent.result.summary.legendHealth).toContainEqual({ + legend: 'untagged', + backlog: 1, + active: 0, + }); + expect(statusResult.structuredContent.result.summary.backlog).toBeUndefined(); + expect(statusResult.content[0].text).toContain('Lane counts:'); + expect(statusResult.content[0].text).toContain('Legend health:'); + vi.restoreAllMocks(); + }); - createMcpServer(); - expect(callToolHandler).toBeDefined(); + it('Does `method_status` reject non-boolean summary flags instead of silently expanding to full mode?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const callToolHandler = createCallToolHarness(); - // Call method_status - const statusResult = await callToolHandler({ params: { name: 'method_status', arguments: { workspace: root } } }); - expect(statusResult.isError).toBeFalsy(); - expect(statusResult.content[0].text).toContain('"inbox": []'); + const result = await callToolHandler({ + params: { + name: 'method_status', + arguments: { + workspace: root, + summary: 'true', + }, + }, + }); - // Call method_inbox - const inboxResult = await callToolHandler({ params: { name: 'method_inbox', arguments: { workspace: root, idea: 'test idea from mcp' } } }); - expect(inboxResult.isError).toBeFalsy(); - expect(inboxResult.content[0].text).toContain('Captured to docs/method/backlog/inbox/test-idea-from-mcp.md'); + expect(result.isError).toBe(true); + expect(result.structuredContent.tool).toBe('method_status'); + expect(result.structuredContent.error.message).toContain('summary must be a boolean'); + }); - // Check status again - const statusAfterInbox = await callToolHandler({ params: { name: 'method_status', arguments: { workspace: root } } }); - expect(statusAfterInbox.content[0].text).toContain('test-idea-from-mcp'); + it('Do mutation tools (`method_inbox`, `method_pull`, `method_close`, `method_capture_witness`) return stable field-level structured content for persisted paths and cycles?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const callToolHandler = createCallToolHarness(); + const originalMethodTest = process.env.METHOD_TEST; + process.env.METHOD_TEST = 'true'; + try { + const inboxResult = await callToolHandler({ + params: { + name: 'method_inbox', + arguments: { workspace: root, idea: 'Test idea from mcp', legend: 'process' }, + }, + }); + expect(inboxResult.isError).toBeFalsy(); + expect(inboxResult.structuredContent.tool).toBe('method_inbox'); + expect(inboxResult.structuredContent.result.path).toBe('docs/method/backlog/inbox/PROCESS_test-idea-from-mcp.md'); + expect(inboxResult.structuredContent.result.lane).toBe('inbox'); + expect(inboxResult.structuredContent.result.legend).toBe('PROCESS'); + expect(inboxResult.structuredContent.result.title).toBe('Test idea from mcp'); + expect(inboxResult.structuredContent.result.stem).toBe('PROCESS_test-idea-from-mcp'); + expect(inboxResult.structuredContent.result.slug).toBe('test-idea-from-mcp'); - // Call method_pull - const pullResult = await callToolHandler({ params: { name: 'method_pull', arguments: { workspace: root, item: 'test-idea-from-mcp' } } }); - expect(pullResult.isError).toBeFalsy(); - expect(pullResult.content[0].text).toContain('Pulled into 0001-test-idea-from-mcp'); + const pullResult = await callToolHandler({ + params: { name: 'method_pull', arguments: { workspace: root, item: 'PROCESS_test-idea-from-mcp' } }, + }); + expect(pullResult.isError).toBeFalsy(); + expect(pullResult.structuredContent.tool).toBe('method_pull'); + expect(pullResult.structuredContent.result.cycle.name).toBe('0001-test-idea-from-mcp'); + expect(pullResult.structuredContent.result.cycle.designDoc).toBe( + 'docs/design/0001-test-idea-from-mcp/test-idea-from-mcp.md', + ); - // Check status again - const statusAfterPull = await callToolHandler({ params: { name: 'method_status', arguments: { workspace: root } } }); - expect(statusAfterPull.content[0].text).toContain('0001-test-idea-from-mcp'); + const captureResult = await callToolHandler({ + params: { name: 'method_capture_witness', arguments: { workspace: root, cycle: '0001-test-idea-from-mcp' } }, + }); + expect(captureResult.isError).toBeFalsy(); + expect(captureResult.structuredContent.tool).toBe('method_capture_witness'); + expect(captureResult.structuredContent.result.path).toBe( + 'docs/method/retro/0001-test-idea-from-mcp/witness/verification.md', + ); - // Call method_capture_witness - const captureResult = await callToolHandler({ params: { name: 'method_capture_witness', arguments: { workspace: root, cycle: '0001-test-idea-from-mcp' } } }); - expect(captureResult.isError).toBeFalsy(); - expect(captureResult.content[0].text).toContain('Captured witness to docs/method/retro/0001-test-idea-from-mcp/witness/verification.md'); + const closeResult = await callToolHandler({ + params: { + name: 'method_close', + arguments: { + workspace: root, + cycle: '0001-test-idea-from-mcp', + driftCheck: true, + outcome: 'hill-met', + }, + }, + }); + expect(closeResult.isError).toBeFalsy(); + expect(closeResult.structuredContent.tool).toBe('method_close'); + expect(closeResult.structuredContent.result.cycle.name).toBe('0001-test-idea-from-mcp'); + expect(closeResult.structuredContent.result.cycle.retroDoc).toBe( + 'docs/method/retro/0001-test-idea-from-mcp/test-idea-from-mcp.md', + ); + } finally { + if (originalMethodTest === undefined) { + delete process.env.METHOD_TEST; + } else { + process.env.METHOD_TEST = originalMethodTest; + } + vi.restoreAllMocks(); + } + }); + + it('Does `method_inbox` normalize persisted legends before returning structured content?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const persistedPath = join(root, 'docs/method/backlog/inbox/PROCESS_tampered-legend.md'); + writeFileSync( + persistedPath, + [ + '---', + 'title: Tampered Legend', + 'lane: inbox', + 'legend: foo-bar', + '---', + '', + 'Body', + '', + ].join('\n'), + 'utf8', + ); + + const captureIdea = vi.spyOn(Workspace.prototype, 'captureIdea').mockReturnValue(persistedPath); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_inbox', + arguments: { + workspace: root, + idea: 'ignored by mock', + }, + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.tool).toBe('method_inbox'); + expect(result.structuredContent.result.path).toBe('docs/method/backlog/inbox/PROCESS_tampered-legend.md'); + expect(result.structuredContent.result.legend).toBe('PROCESS'); + expect(captureIdea).toHaveBeenCalled(); + }); + + it('On MCP errors, can I inspect a structured error object instead of only a prose message?', async () => { + const callToolHandler = createCallToolHarness(); - // Call without workspace — should error const noWorkspaceResult = await callToolHandler({ params: { name: 'method_status', arguments: {} } }); expect(noWorkspaceResult.isError).toBe(true); + expect(noWorkspaceResult.structuredContent.tool).toBe('method_status'); + expect(noWorkspaceResult.structuredContent.ok).toBe(false); + expect(noWorkspaceResult.structuredContent.error.message).toContain('workspace is required'); expect(noWorkspaceResult.content[0].text).toContain('workspace is required'); vi.restoreAllMocks(); }); + + it('Does `method_close` reject invalid runtime argument types instead of trusting MCP schema declarations?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + workspace.captureIdea('close validation', 'PROCESS', 'Close Validation'); + workspace.pullItem('PROCESS_close-validation'); + const callToolHandler = createCallToolHarness(); + + const badDriftCheck = await callToolHandler({ + params: { + name: 'method_close', + arguments: { + workspace: root, + cycle: '0001-close-validation', + driftCheck: 'false', + outcome: 'hill-met', + }, + }, + }); + expect(badDriftCheck.isError).toBe(true); + expect(badDriftCheck.structuredContent.error.message).toContain('driftCheck must be a boolean'); + + const badOutcome = await callToolHandler({ + params: { + name: 'method_close', + arguments: { + workspace: root, + cycle: '0001-close-validation', + driftCheck: true, + outcome: 'done', + }, + }, + }); + expect(badOutcome.isError).toBe(true); + expect(badOutcome.structuredContent.error.message).toContain('outcome must be one of'); + + const badCycle = await callToolHandler({ + params: { + name: 'method_close', + arguments: { + workspace: root, + cycle: 42, + driftCheck: true, + outcome: 'hill-met', + }, + }, + }); + expect(badCycle.isError).toBe(true); + expect(badCycle.structuredContent.error.message).toContain('cycle must be a string'); + + const blankCycle = await callToolHandler({ + params: { + name: 'method_close', + arguments: { + workspace: root, + cycle: ' ', + driftCheck: true, + outcome: 'hill-met', + }, + }, + }); + expect(blankCycle.isError).toBe(true); + expect(blankCycle.structuredContent.error.message).toContain('cycle must not be empty'); + + vi.restoreAllMocks(); + }); + + it('Does `method_sync_github` respect explicit false flags instead of treating them as omitted defaults?', async () => { + const root = createTempRoot(); + initWorkspace(root); + writeFileSync( + join(root, '.method.json'), + JSON.stringify({ github_token: 'test-token', github_repo: 'owner/repo' }), + 'utf8', + ); + + const pushBacklog = vi.spyOn(GitHubAdapter.prototype, 'pushBacklog').mockResolvedValue([]); + const pullBacklog = vi.spyOn(GitHubAdapter.prototype, 'pullBacklog').mockResolvedValue([]); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_sync_github', + arguments: { + workspace: root, + push: false, + pull: false, + }, + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.tool).toBe('method_sync_github'); + expect(result.structuredContent.result.pushRequested).toBe(false); + expect(result.structuredContent.result.pullRequested).toBe(false); + expect(pushBacklog).not.toHaveBeenCalled(); + expect(pullBacklog).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('No changes.'); + }); + + it('Does `method_sync_github` allow an explicit no-op sync without GitHub credentials?', async () => { + const root = createTempRoot(); + initWorkspace(root); + + const pushBacklog = vi.spyOn(GitHubAdapter.prototype, 'pushBacklog').mockResolvedValue([]); + const pullBacklog = vi.spyOn(GitHubAdapter.prototype, 'pullBacklog').mockResolvedValue([]); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_sync_github', + arguments: { + workspace: root, + push: false, + pull: false, + }, + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.tool).toBe('method_sync_github'); + expect(result.structuredContent.result.pushRequested).toBe(false); + expect(result.structuredContent.result.pullRequested).toBe(false); + expect(pushBacklog).not.toHaveBeenCalled(); + expect(pullBacklog).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('No changes.'); + }); + + it('Does `method_sync_github` reject non-boolean push and pull flags before touching GitHub?', async () => { + const root = createTempRoot(); + initWorkspace(root); + writeFileSync( + join(root, '.method.json'), + JSON.stringify({ github_token: 'test-token', github_repo: 'owner/repo' }), + 'utf8', + ); + + const pushBacklog = vi.spyOn(GitHubAdapter.prototype, 'pushBacklog').mockResolvedValue([]); + const pullBacklog = vi.spyOn(GitHubAdapter.prototype, 'pullBacklog').mockResolvedValue([]); + const callToolHandler = createCallToolHarness(); + + const badPush = await callToolHandler({ + params: { + name: 'method_sync_github', + arguments: { + workspace: root, + push: 'false', + }, + }, + }); + + expect(badPush.isError).toBe(true); + expect(badPush.structuredContent.error.message).toContain('push must be a boolean'); + expect(pushBacklog).not.toHaveBeenCalled(); + expect(pullBacklog).not.toHaveBeenCalled(); + + const badPull = await callToolHandler({ + params: { + name: 'method_sync_github', + arguments: { + workspace: root, + pull: 'false', + }, + }, + }); + + expect(badPull.isError).toBe(true); + expect(badPull.structuredContent.error.message).toContain('pull must be a boolean'); + expect(pushBacklog).not.toHaveBeenCalled(); + expect(pullBacklog).not.toHaveBeenCalled(); + }); }); diff --git a/tests/ship-sync.test.ts b/tests/ship-sync.test.ts index a26d1f0..5115951 100644 --- a/tests/ship-sync.test.ts +++ b/tests/ship-sync.test.ts @@ -1,8 +1,9 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { initWorkspace, Workspace } from '../src/index.js'; +import { renderBearing } from '../src/renderers.js'; const tempRoots: string[] = []; @@ -49,10 +50,7 @@ describe('Ship Sync', () => { // Create a backlog item and move it to up-next workspace.captureIdea('Next priority', 'FEAT', 'Up Next'); - renameSync( - join(root, 'docs/method/backlog/inbox/FEAT_up-next.md'), - join(root, 'docs/method/backlog/up-next/FEAT_up-next.md') - ); + workspace.moveBacklogItem('docs/method/backlog/inbox/FEAT_up-next.md', 'up-next'); const itemPath = workspace.captureIdea('Feature Z', 'FEAT', 'Just Shipped'); const cycle = workspace.pullItem('FEAT_just-shipped'); @@ -108,4 +106,100 @@ describe('Ship Sync', () => { expect(result.updated).toContain('docs/BEARING.md'); expect(result.updated).not.toContain('CHANGELOG.md'); }); + + it('Does generated `BEARING.md` stop claiming witness generation is not automated?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + await workspace.shipSync(); + + const bearing = readFileSync(join(root, 'docs/BEARING.md'), 'utf8'); + expect(bearing).not.toContain('Witness generation is not yet automated.'); + }); + + it('When backlog pressure changes, does `BEARING.md` describe concrete live repo state instead of hardcoded stale complaints?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + workspace.captureIdea('Inbox pressure', 'PROCESS', 'Inbox Pressure'); + writeFileSync( + join(root, 'docs/method/backlog/asap/PROCESS_asap-pressure.md'), + [ + '---', + 'title: "ASAP Pressure"', + 'legend: PROCESS', + 'lane: asap', + '---', + '', + '# ASAP Pressure', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/bad-code/PROCESS_bad-code-pressure.md'), + [ + '---', + 'title: "Bad Code Pressure"', + 'legend: PROCESS', + 'lane: bad-code', + '---', + '', + '# Bad Code Pressure', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + await workspace.shipSync(); + + const bearing = readFileSync(join(root, 'docs/BEARING.md'), 'utf8'); + expect(bearing).toContain('Inbox still holds 1 untriaged item(s).'); + expect(bearing).toContain('1 ASAP backlog item(s) are still unresolved.'); + expect(bearing).toContain('1 bad-code item(s) remain tracked.'); + expect(bearing).not.toContain('Backlog maintenance is still largely manual.'); + }); + + it('Can I point to the exact backlog counts that caused each `What feels wrong?` line?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + workspace.captureIdea('Inbox pressure', 'PROCESS', 'Inbox Pressure'); + writeFileSync( + join(root, 'docs/method/backlog/asap/PROCESS_asap-pressure.md'), + [ + '---', + 'title: "ASAP Pressure"', + 'legend: PROCESS', + 'lane: asap', + '---', + '', + '# ASAP Pressure', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const bearing = renderBearing(workspace.status(), [], 'test-sha'); + expect(bearing).toContain('Inbox still holds 1 untriaged item(s).'); + expect(bearing).toContain('1 ASAP backlog item(s) are still unresolved.'); + expect(bearing).not.toContain('2 ASAP backlog item(s) are still unresolved.'); + }); + + it('Does `renderBearing` only emit derived or tightly bounded statements instead of unaudited repo assertions?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + const bearing = renderBearing(workspace.status(), [], 'test-sha'); + expect(bearing).toContain('No acute coordination pain is currently recorded.'); + expect(bearing).not.toContain('Backlog maintenance is still largely manual.'); + expect(bearing).not.toContain('Witness generation is not yet automated.'); + }); }); diff --git a/tests/witness.test.ts b/tests/witness.test.ts index 87f1b58..f479217 100644 --- a/tests/witness.test.ts +++ b/tests/witness.test.ts @@ -1,8 +1,9 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { initWorkspace, Workspace } from '../src/index.js'; +import { renderDesignDoc, renderRetroDoc, renderWitnessDoc } from '../src/renderers.js'; const tempRoots: string[] = []; @@ -58,4 +59,97 @@ describe('Automated Witness Capture', () => { expect(content).toContain('[MOCK] Output for tsx src/cli.ts drift 0001-witness-test'); expect(content).toContain('- [x] Automated capture completed successfully.'); }); + + it('Does the witness scaffold label fenced output as text and avoid empty test or drift fences?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + workspace.captureIdea('Witness Fence Test', 'PROCESS', 'Witness Fence Test'); + const cycle = workspace.pullItem('PROCESS_witness-fence-test'); + + const content = renderWitnessDoc({ + cycle, + testResult: '', + driftResult: '', + }); + + expect(content).toContain('## Test Results'); + expect(content).toContain('```text\nNo test output captured.\n```'); + expect(content).toContain('## Drift Results'); + expect(content).toContain('```text\nNo drift output captured.\n```'); + }); + + it('Preserves non-empty witness command output verbatim while only trimming for blank detection.', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + workspace.captureIdea('Witness Payload Test', 'PROCESS', 'Witness Payload Test'); + const cycle = workspace.pullItem('PROCESS_witness-payload-test'); + + const content = renderWitnessDoc({ + cycle, + testResult: ' indented output\n', + driftResult: '\t drift output\n', + }); + + expect(content).toMatch(/## Test Results\n\n```text\n indented output\n\n```/u); + expect(content).toMatch(/## Drift Results\n\n```text\n\t drift output\n\n```/u); + }); + + it('Does YAML frontmatter escape embedded line breaks instead of letting YAML fold them away?', () => { + const content = renderDesignDoc({ + cycleName: '0001-line-breaks', + title: 'Line 1\nLine 2', + source: 'docs/method/backlog/asap/PROCESS_line-1\nline-2.md', + backlogBody: 'Body', + }); + + expect(content).toContain('title: "Line 1\\nLine 2"'); + expect(content).toContain('source_backlog: "docs/method/backlog/asap/PROCESS_line-1\\nline-2.md"'); + }); + + it('Does design-doc frontmatter YAML-escape legends instead of writing raw scalar text?', () => { + const content = renderDesignDoc({ + cycleName: '0001-legend-escape', + title: 'Legend Escape', + legend: 'FEAT:alpha #quoted "value"', + source: 'docs/method/backlog/asap/PROCESS_legend-escape.md', + backlogBody: 'Body', + }); + + expect(content).toContain('legend: "FEAT:alpha #quoted \\"value\\""'); + expect(content).toContain('Legend: FEAT:alpha #quoted "value"'); + }); + + it('Normalizes generated frontmatter paths to POSIX separators.', () => { + const root = createTempRoot(); + const designDocPath = join(root, 'docs', 'design', '0001-windows-paths', 'windows-paths.md'); + mkdirSync(dirname(designDocPath), { recursive: true }); + writeFileSync(designDocPath, '# Windows Paths\n', 'utf8'); + + const designContent = renderDesignDoc({ + cycleName: '0001-windows-paths', + title: 'Windows Paths', + source: 'docs\\method\\backlog\\asap\\PROTO_windows-paths.md', + backlogBody: 'Body', + }); + expect(designContent).toContain('source_backlog: "docs/method/backlog/asap/PROTO_windows-paths.md"'); + + const retroContent = renderRetroDoc({ + cycle: { + name: '0001-windows-paths', + number: 1, + slug: 'windows-paths', + designDoc: designDocPath, + retroDoc: join(root, 'unused.md'), + }, + root, + outcome: 'partial', + witnessDir: 'docs\\method\\retro\\0001-windows-paths\\witness', + }); + expect(retroContent).toContain('design_doc: "docs/design/0001-windows-paths/windows-paths.md"'); + expect(retroContent).toContain('Add artifacts under `docs/method/retro/0001-windows-paths/witness`'); + }); });