diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e1770eb..a04579d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ --- title: "Architecture" -generator: "method sync ship" +generator: "method sync refs" provenance_level: artifact_history --- @@ -27,6 +27,7 @@ src/ domain.ts drift.ts errors.ts + feedback-surface.ts frontmatter.ts generate.ts index.ts @@ -45,6 +46,7 @@ The `Workspace` class is the core. It owns: - **Backlog operations**: `captureIdea`, `pullItem`, `moveBacklogItem` - **Cycle lifecycle**: `closeCycle`, `openCycles`, `captureWitness` - **Drift detection**: `detectDrift` (delegates to `drift.ts`) +- **Reference sync**: `syncRefs` (refreshes generated reference docs without touching ship-only artifacts) - **Ship sync**: `shipSync` (updates CHANGELOG and BEARING) - **Status**: `status` (backlog lanes, active cycles, legend health) diff --git a/README.md b/README.md index f751142..1db075d 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,12 @@ A backlog, a loop, and honest bookkeeping. **The agent and the human sit at the same table.** They see different things. Both are named in every design as abstract roles (e.g., -"Repository Operator", "System Architect"). Both must agree before work -ships. +"Repository Operator", "System Architect"), not literal people or tool +instances. Both must agree before work ships. + +When METHOD says `user`, it means the served perspective or beneficiary +of the change, like in a user story. It does not mean a specific named +person who must be present during authorship or playback. **The METHOD repo gets no special pleading.** The repository that defines METHOD should use METHOD on itself. If repo truth conflicts with @@ -74,12 +78,11 @@ docs/ backlog/ inbox/ raw ideas, anyone, anytime asap/ do this now - up-next/ do this soon cool-ideas/ experiments, wild thoughts bad-code/ tech debt + vX.Y.Z/ release-scoped planning lane + / other repo-defined planning lanes *.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 @@ -143,14 +146,18 @@ The inbox is processed during maintenance. |------|---------| | `inbox/` | Unprocessed. | | `asap/` | Pull into a cycle soon. | -| `up-next/` | Next in line. | | `cool-ideas/` | Not commitments. | | `bad-code/` | It works, but it bothers you. | -Anything else sits in the backlog root. +Repos may also create custom first-level backlog lanes when the default +buckets stop being the right planning surface. A lane such as +`v1.1.0/` is valid when the repo wants to group candidate work toward a +named release target or another explicit planning bucket. -`method init` scaffolds all lane directories. Lanes exist from -initialization, not on first use. +`method init` scaffolds the canonical lanes above. Custom lanes are +created intentionally on demand. Backlog root remains a valid escape +hatch, but items there should be short-lived because `method doctor` +warns on unclassified root backlog files. ### Naming @@ -164,10 +171,13 @@ debt-trailer-codec-dts.md ### Promoting -When a backlog item is pulled into a cycle, it becomes a design doc: +When a backlog item is pulled into a cycle, it becomes a design doc. +Unreleased work without explicit release scope goes to `docs/design/`. +Release-tagged work goes to `docs/releases//design/`. ```text backlog/asap/SYNTH_executive-summary-protocol.md -> design//executive-summary-protocol.md +backlog/v2.4.5/PROCESS_release-scope.md -> releases/v2.4.5/design//release-scope.md ``` The backlog file is removed. Work does not live in two places. @@ -296,6 +306,10 @@ in one sentence, the cycle is too big. Split it. 4. **Playback** - produce a witness. The agent answers agent questions. The human answers user questions. Write it down. + Those are perspective labels, not literal identities. `agent` names + the machine-operable seat in the workflow, and `user` names the + served perspective the hill claims to satisfy. + The **witness** is the concrete artifact - test output, transcript, screenshot, recording - that shows both answers. No clear yes means no. If the witness cannot be reproduced from committed commands, @@ -380,7 +394,7 @@ a standup: - Is this workspace structurally healthy? -> `method doctor` - What is under review? -> `method review-state` - What is committed? -> each design doc names its sponsors and hill -- What is next? -> `ls docs/method/backlog/asap/` +- What is next? -> `method status` and the current lane under `docs/method/backlog/` - What failed and why? -> `ls docs/method/retro/` - What did we decide not to do? -> `ls docs/method/graveyard/` @@ -436,37 +450,32 @@ 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 +Use `method retire` when a live backlog item should become a graveyard +tombstone instead of silently disappearing. -Feedback that is not yet shaped into backlog or design work belongs in -`docs/method/feedback/`. +## Intake -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. +Anything not yet shaped into planned work belongs in `inbox/`, +including critique, review notes, and outside-in observations. -The archive preserves provenance, shows what feedback already landed, -and prevents the same point from being rediscovered without context. +Use `method inbox` to capture raw intake without prematurely deciding +whether it belongs in `asap/`, a release lane such as `v2.4.5/`, +`bad-code/`, `cool-ideas/`, or the graveyard. When provenance matters, +record it explicitly with `--source` and `--captured-at`. --- ## Flow ```text -idea -> inbox/ -> cool-ideas/ -> up-next/ -> asap/ - -> design// (committed) +idea -> inbox/ -> chosen lane (cool-ideas/, asap/, v2.4.5/, bad-code/, ...) + -> design// or releases//design// (committed) -> RED -> GREEN -> playback (witness) - -> retro// (cycle packet closed) + -> retro// or releases//retros// (cycle packet closed) -> PR/review -> main -> ship sync (BEARING / CHANGELOG / release when meaningful) - or -> -> graveyard/ - -feedback -> feedback/ -> backlog/design/signpost/release/no-action - -> feedback/archive/ ``` --- @@ -488,15 +497,22 @@ Run during development: npm run method -- status ``` -Core commands: `method init`, `method doctor`, `method inbox`, -`method pull`, `method close`, `method drift`, `method status`, -`method mcp`, `method sync github`, `method sync ship`. +Core commands: `method init`, `method doctor`, `method migrate`, `method inbox`, +`method backlog add`, `method backlog move`, `method backlog edit`, `method backlog list`, `method backlog deps`, `method retire`, +`method signpost status`, `method signpost init`, +`method repair`, `method next`, `method pull`, +`method close`, `method drift`, `method status`, `method mcp`, +`method sync github`, `method sync ship`, `method sync refs`. See `docs/CLI.md` for the full command reference with flags and examples. See `docs/MCP.md` for the MCP tool reference -(`method_doctor`, `method_status`, `method_inbox`, `method_pull`, -`method_drift`, `method_close`, `method_sync_ship`, `method_sync_github`, -`method_capture_witness`). +(`method_doctor`, `method_migrate`, `method_status`, `method_inbox`, `method_backlog_add`, +`method_backlog_move`, `method_backlog_edit`, `method_backlog_query`, `method_backlog_dependencies`, +`method_next_work`, +`method_signpost_status`, `method_signpost_init`, +`method_retire`, `method_pull`, `method_drift`, +`method_repair`, `method_close`, `method_sync_ship`, `method_sync_refs`, +`method_sync_github`, `method_capture_witness`). Repo-local CI currently uses GitHub Actions as a host adapter through `.github/workflows/ci.yml`. The first cut stays narrow and explicit: diff --git a/docs/CLI.md b/docs/CLI.md index d96ebc8..59b147a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,7 +1,7 @@ --- title: CLI Reference generated_at: 2026-04-08T21:17:50.746Z -generator: method sync ship +generator: method sync refs generated_from_commit: ecde6ac00e798d68bf69b2c2bfb9044ad44d47e9 provenance_level: artifact_history --- @@ -23,17 +23,62 @@ Scaffold a METHOD workspace in the given directory. Inspect METHOD workspace health without mutating it. -### `method inbox [--legend CODE] [--title TITLE]` +### `method migrate [--json]` -Capture a raw idea in docs/method/backlog/inbox/. +Run doctor, apply the bounded repair set, then re-check the workspace. + +### `method inbox [--legend CODE] [--title TITLE] [--body-file PATH] [--source TEXT] [--captured-at YYYY-MM-DD] [--json]` + +Capture raw input in docs/method/backlog/inbox/. + +### `method backlog add --lane LANE --title TITLE [--legend CODE] [--body-file PATH] [--json]` + +Create a shaped backlog note directly in the requested backlog lane. + +### `method backlog move --to LANE [--json]` + +Move a live backlog note into another backlog lane. + +### `method backlog edit [--owner TEXT|--clear-owner] [--priority VALUE|--clear-priority] [--keyword VALUE ...|--clear-keywords] [--blocked-by REF ...|--clear-blocked-by] [--blocks REF ...|--clear-blocks] [--json]` + +Update explicit schema-backed backlog metadata on a live backlog note. Repeat `--keyword`, `--blocked-by`, or `--blocks` to replace list fields. + +### `method backlog list [--lane LANE] [--legend CODE] [--priority VALUE] [--keyword VALUE] [--owner VALUE] [--ready|--blocked] [--has-acceptance-criteria|--missing-acceptance-criteria] [--blocked-by REF] [--blocks REF] [--sort lane|priority|path] [--limit N] [--json]` + +Return structured backlog items and explicit frontmatter metadata such as owner, priority, keywords, blocks, blocked_by, readiness, and acceptance criteria presence. + +### `method backlog deps [item] [--ready] [--critical-path] [--json]` + +Inspect live backlog dependencies from `blocked_by` / `blocks` frontmatter. +Use `--ready` to show unblocked work, or pass ` --critical-path` to show the longest blocker chain to one item. + +### `method retire --reason TEXT [--replacement PATH] [--dry-run] [--yes] [--json]` + +Retire a live backlog note into the graveyard with an explicit disposition record. + +### `method signpost status [--json]` + +Report which expected repo signposts exist, which are missing, and which can be initialized by helper commands. + +### `method signpost init [--json]` + +Initialize a narrowly supported missing canonical signpost such as BEARING or MCP. + +### `method repair (--plan | --apply) [--json]` + +Plan or apply bounded doctor-guided repairs for missing directories, scaffold files, and frontmatter stubs. + +### `method next [--lane LANE] [--legend CODE] [--priority VALUE] [--keyword VALUE] [--owner VALUE] [--include-blocked] [--limit N] [--json]` + +Return a bounded advisory menu of sensible next backlog items using lane order, declared frontmatter, dependency readiness, and literal BEARING mentions. ### `method pull ` -Promote a backlog item into the next numbered design cycle. +Promote a backlog item into the next numbered cycle packet. Release-tagged work scaffolds under docs/releases//. ### `method close [cycle] [--drift-check yes|no] --outcome hill-met|partial|not-met` -Close an active cycle into docs/method/retro/. `--outcome` is required. +Close an active cycle into its retro packet. `--outcome` is required. ### `method status` @@ -53,12 +98,12 @@ Defaults to --current-branch when no selector flag is provided. Start an MCP (Model Context Protocol) server on stdio. -### `method sync github|ship [options]` +### `method sync github|ship|refs [options]` GitHub Options: --push Update GitHub issues with local changes (default). --pull Update local backlog with GitHub changes (labels, comments, status). -Perform Ship Sync or synchronize the backlog with GitHub Issues. +Perform Ship Sync, refresh generated reference docs, or synchronize the backlog with GitHub Issues. ### Exit Codes diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 53d46a5..e0044bc 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1,6 +1,6 @@ --- title: "Guide" -generator: "method sync ship" +generator: "method sync refs" provenance_level: artifact_history --- @@ -42,24 +42,25 @@ Why this is good practice: current feature work. - It avoids awkward cleanup and weird Git operations later. -## Process feedback in batches +## Keep one raw-intake path 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. +`docs/method/backlog/inbox/` instead of inventing a second holding area +for critique. -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. +Use `method inbox` for this too. Add `--source` and `--captured-at` +when origin and timing matter. + +Treat the inbox item as raw input, not as already-prioritized work. +During a maintenance pass, split accepted points into the right durable +artifacts and then move or retire the inbox item intentionally. Why this is good practice: -- It keeps the backlog from becoming a dumping ground for half-processed - critique. +- It keeps raw intake legible in one place. - It preserves the original wording and context behind later backlog or docs changes. -- It makes "no action" a visible decision instead of silent deletion. +- It makes triage visible instead of hiding it in parallel inboxes. ## Advice is not doctrine diff --git a/docs/MCP.md b/docs/MCP.md index 0df047e..5938f5d 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -1,7 +1,7 @@ --- title: MCP Reference generated_at: 2026-04-08T21:17:50.754Z -generator: method sync ship +generator: method sync refs generated_from_commit: ecde6ac00e798d68bf69b2c2bfb9044ad44d47e9 provenance_level: artifact_history --- @@ -25,6 +25,18 @@ workspace-agnostic — a single instance can serve multiple projects. Inspect METHOD workspace health and report concrete problems with suggested fixes, even when the workspace is partially broken. +### `method_repair` + +Plan or apply bounded doctor-guided repairs for missing directories, missing scaffold files, and frontmatter stubs. + +**Parameters:** + +- `mode` (required) `string` (plan, apply) — Whether to return a repair plan or apply the same bounded repair set. + +### `method_migrate` + +Run doctor, apply the bounded repair set, then re-check the workspace so callers can normalize a repo in one step. + ### `method_review_state` Get PR review / merge-readiness state for the current branch or an explicit PR. `pr` and `currentBranch` are mutually exclusive; when `pr` is omitted, current-branch resolution is the default behavior. @@ -51,10 +63,116 @@ Capture a new raw idea into the inbox - `idea` (required) `string` - `legend` (optional) `string` - `title` (optional) `string` +- `body` (optional) `string` — Optional markdown body. Defaults to the idea text when omitted. +- `source` (optional) `string` — Optional source such as a reviewer, channel, or system. +- `capturedAt` (optional) `string` — Optional capture date in YYYY-MM-DD format. + +### `method_backlog_add` + +Create a shaped backlog note directly in the requested backlog lane. + +**Parameters:** + +- `lane` (required) `string` — Destination backlog lane such as `bad-code` or `v1.1.0`. +- `title` (required) `string` — Backlog note title used for frontmatter, heading, and slug derivation. +- `legend` (optional) `string` — Optional legend code prefix such as PROCESS. +- `body` (optional) `string` — Optional markdown body to place under the heading. + +### `method_backlog_move` + +Move a live backlog note into another backlog lane. + +**Parameters:** + +- `item` (required) `string` — Backlog path, stem, or slug that resolves to exactly one live backlog note. +- `to` (required) `string` — Destination backlog lane such as `asap`, `bad-code`, or `v1.1.0`. + +### `method_backlog_edit` + +Update explicit schema-backed metadata on a live backlog note without opening arbitrary frontmatter editing. + +**Parameters:** + +- `item` (required) `string` — Backlog path, stem, or slug that resolves to exactly one live backlog note. +- `owner` (optional) `string` — Optional owner role to set. +- `clearOwner` (optional) `boolean` — When true, remove the owner field. +- `priority` (optional) `string` — Optional priority to set, such as `high` or `medium`. +- `clearPriority` (optional) `boolean` — When true, remove the priority field. +- `keywords` (optional) `array` — Optional replacement keyword list. +- `clearKeywords` (optional) `boolean` — When true, remove the keywords field. +- `blockedBy` (optional) `array` — Optional replacement `blocked_by` reference list. +- `clearBlockedBy` (optional) `boolean` — When true, remove the `blocked_by` field. +- `blocks` (optional) `array` — Optional replacement `blocks` reference list. +- `clearBlocks` (optional) `boolean` — When true, remove the `blocks` field. + +### `method_backlog_query` + +Enumerate live backlog items as structured data with explicit frontmatter metadata such as owner, priority, keywords, and declared dependency refs. + +**Parameters:** + +- `lane` (optional) `string` — Optional backlog lane filter such as `bad-code` or `v1.1.0`. +- `legend` (optional) `string` — Optional legend filter such as PROCESS. +- `priority` (optional) `string` — Optional priority filter such as `medium`. +- `keyword` (optional) `string` — Optional explicit frontmatter keyword filter. +- `owner` (optional) `string` — Optional explicit frontmatter owner filter. +- `ready` (optional) `boolean` — Optional readiness filter. `true` returns items without declared `blocked_by` refs; `false` returns blocked items. +- `hasAcceptanceCriteria` (optional) `boolean` — Optional acceptance-criteria presence filter. +- `blockedBy` (optional) `string` — Optional declared `blocked_by` reference filter. +- `blocks` (optional) `string` — Optional declared `blocks` reference filter. +- `sort` (optional) `string` — Optional backlog query sort mode: `lane`, `priority`, or `path`. Defaults to `lane`. +- `limit` (optional) `integer` — Maximum number of returned items. Defaults to 50 and may not exceed 100. + +### `method_backlog_dependencies` + +Return the live backlog dependency graph from `blocked_by` / `blocks` frontmatter, optionally focusing on one item, ready work, or the critical path. + +**Parameters:** + +- `item` (optional) `string` — Optional backlog path, stem, or slug to focus on. +- `readyOnly` (optional) `boolean` — When true, request the unblocked layer-0 backlog items. +- `criticalPath` (optional) `boolean` — When true, include the longest blocker chain to the focused item. Requires `item`. + +### `method_next_work` + +Return a bounded advisory menu of sensible next backlog items using lane order, declared frontmatter, dependency readiness, current status, and literal BEARING mentions. + +**Parameters:** + +- `lane` (optional) `string` — Optional backlog lane filter such as `asap` or `v1.1.0`. +- `legend` (optional) `string` — Optional legend filter such as PROCESS. +- `priority` (optional) `string` — Optional priority filter such as `high`. +- `keyword` (optional) `string` — Optional explicit frontmatter keyword filter. +- `owner` (optional) `string` — Optional explicit frontmatter owner filter. +- `includeBlocked` (optional) `boolean` — When true, keep blocked items in the candidate set even when ready work exists. +- `limit` (optional) `integer` — Maximum number of recommendations. Defaults to 3 and may not exceed 10. + +### `method_signpost_status` + +Report which expected repo signposts exist, which are missing, and which can be initialized by helper commands. + +### `method_signpost_init` + +Initialize a narrowly supported missing canonical signpost such as BEARING, MCP, CLI, GUIDE, or ARCHITECTURE. + +**Parameters:** + +- `name` (required) `string` — Canonical signpost name or path, such as BEARING or docs/MCP.md. + +### `method_retire` + +Retire a live backlog note into the graveyard with an explicit disposition note instead of silently deleting it. + +**Parameters:** + +- `item` (required) `string` — Live backlog path, stem, or slug that resolves to exactly one backlog note. +- `reason` (required) `string` — Required retirement reason recorded under a graveyard Disposition section. +- `replacement` (optional) `string` — Optional replacement path or successor reference to record in the tombstone. +- `dryRun` (optional) `boolean` — When true, return the planned graveyard move without mutating the repo. ### `method_pull` -Promote a backlog item into the next numbered cycle +Promote a backlog item into the next numbered cycle packet, using release-scoped paths when the backlog item carries release metadata. **Parameters:** @@ -70,7 +188,7 @@ Check active cycle playback questions against tests ### `method_close` -Close an active cycle into a retro +Close an active cycle into its retro packet. **Parameters:** @@ -82,6 +200,10 @@ Close an active cycle into a retro Perform the Ship Sync maneuver (update CHANGELOG.md and BEARING.md) +### `method_sync_refs` + +Refresh generated reference docs without mutating ship-only artifacts such as CHANGELOG.md or docs/BEARING.md. + ### `method_sync_github` Synchronize backlog with GitHub Issues diff --git a/docs/design/0010-yaml-frontmatter-schema/yaml-frontmatter-schema.md b/docs/design/0010-yaml-frontmatter-schema/yaml-frontmatter-schema.md index 7ccc6fa..687cd49 100644 --- a/docs/design/0010-yaml-frontmatter-schema/yaml-frontmatter-schema.md +++ b/docs/design/0010-yaml-frontmatter-schema/yaml-frontmatter-schema.md @@ -92,8 +92,11 @@ document-specific, and how strict the validation should be. - `source_files`: array of strings ### Design Docs (Recommended) -- `sponsors`: object (`human`, `agent`) +- `sponsors`: object (`human`, `agent`) with abstract role labels, not + literal identities - `source_backlog`: string (path) +- `release`: string (optional version scope when the cycle belongs to a + release packet) ### Retros (Recommended) - `design_doc`: string (path) @@ -103,3 +106,6 @@ document-specific, and how strict the validation should be. ### Backlog Items (Minimal) - `legend`: string - `lane`: string +- `release`: string (optional durable release association) +- `source`: string (optional capture provenance) +- `captured_at`: string (optional YYYY-MM-DD capture date) diff --git a/docs/design/PROCESS_generated-reference-sync-coupling.md b/docs/design/PROCESS_generated-reference-sync-coupling.md new file mode 100644 index 0000000..8cecd3c --- /dev/null +++ b/docs/design/PROCESS_generated-reference-sync-coupling.md @@ -0,0 +1,94 @@ +--- +title: "Generated Reference Sync Coupling" +legend: "PROCESS" +cycle: "PROCESS_generated-reference-sync-coupling" +source_backlog: "docs/method/backlog/bad-code/PROCESS_generated-reference-sync-coupling.md" +--- + +# Generated Reference Sync Coupling + +Source backlog item: `docs/method/backlog/bad-code/PROCESS_generated-reference-sync-coupling.md` +Legend: PROCESS + +## Sponsors + +- Human: Backlog operator +- Agent: Implementation agent + +These labels are abstract roles. In this design, `user` means the served +perspective, like in a user story, not a literal named person or +specific agent instance. + +## Hill + +Decouple branch-local reference-doc refresh from the full post-merge +ship sync, and flatten the design-doc layout so cycle naming scales for +concurrent users without sequential number collisions. + +## Playback Questions + +### Human + +- [ ] Does `method sync refs` refresh only `ARCHITECTURE.md`, `docs/CLI.md`, `docs/MCP.md`, and `docs/GUIDE.md` without mutating `CHANGELOG.md` or `docs/BEARING.md`? +- [ ] Does `method pull` create a flat design doc at `docs/design/_.md` instead of a nested directory? +- [ ] Does `method doctor` detect legacy nested design doc directories and offer to flatten them? + +### Agent + +- [ ] Does `generateReferenceDocs()` return the same target list that the CLI prints? +- [ ] Does `method_sync_refs` return structured content with the refreshed targets? +- [ ] Does `readCycleFromDoc()` discover both flat and legacy nested design docs? +- [ ] Does `method_doctor` report `legacy-design-layout` warnings with `flatten-design-doc` repair hints? + +## Accessibility and Assistive Reading + +- Linear truth / reduced-complexity posture: `method sync refs` prints + the exact list of refreshed targets so the operator can read scope + top-to-bottom. Doctor issues list legacy dirs with concrete fix + instructions. +- Non-visual or alternate-reading expectations: all outputs are + text-based lists; no color or layout cues are required. + +## Localization and Directionality + +- Locale / wording / formatting assumptions: English-only, ASCII paths. +- Logical direction / layout assumptions: left-to-right, no + bidirectional layout dependencies. + +## Agent Inspectability and Explainability + +- What must be explicit and deterministic for agents: `syncRefs()` + returns `{ targets, updated }` so agents know exactly which files + changed. `readCycleFromDoc()` is deterministic across flat and legacy + layouts. +- What must be attributable, evidenced, or governed: doctor repair hints + carry `kind` and `targetPath` so agents can plan or apply repairs + programmatically. + +## Non-goals + +- [ ] Remove or weaken `method sync ship`. +- [ ] Make `BEARING` or `CHANGELOG` branch-local artifacts again. +- [ ] Migrate existing legacy design doc directories in this PR. +- [ ] Solve every generated-document refresh problem in the same slice. + +## Backlog Context + +Refreshing generated reference docs during PR work currently requires +running `method sync ship`, even when the only intended output is a file +like `docs/MCP.md` or `docs/CLI.md`. That full ship maneuver also +touches repo-level artifacts such as `docs/BEARING.md` and +`CHANGELOG.md`, which are explicitly supposed to reflect merged `main` +state rather than branch-local review work. + +In practice, that means a small documentation regeneration step creates +unrelated branch noise that then has to be manually reverted. This is +the sort of coupling that wastes review time and makes agents less +confident about running the generator they actually need. + +Additionally, the directory-per-cycle design doc layout +(`docs/design/-/.md`) uses sequential numeric +prefixes that collide when multiple operators work concurrently. The +flat layout (`docs/design/_.md`) with legend-prefixed +cycle names eliminates both the unnecessary nesting and the numbering +bottleneck. diff --git a/docs/invariants/sponsor-abstractness.md b/docs/invariants/sponsor-abstractness.md index 61de96f..46a0024 100644 --- a/docs/invariants/sponsor-abstractness.md +++ b/docs/invariants/sponsor-abstractness.md @@ -1,11 +1,12 @@ --- -title: "Invariant: Sponsor Abstractness" +title: "Invariant: Role and Perspective Abstractness" --- ## What must remain true? -Sponsors named in design documents are abstract roles, not specific -individuals or agent instances. +When METHOD says `Human`, `Agent`, or `User`, it means an abstract role +or perspective, not a specific individual, account, model brand, or +running agent instance. ## Why does it matter? @@ -16,11 +17,17 @@ Roles (e.g., "Repository Operator", "Code Hardener", "Protocol Designer") describe *who would care* about the feature and *what perspective* they bring, which remains true regardless of who is currently sitting in the seat. +`User` should be read the same way it is in a user story: the served +perspective or beneficiary of the change, not a named person who must be +present during authorship, review, or playback. + ## How do you check? - Design documents name sponsors as roles (e.g., "Human: System Architect"). +- Doctrine that says `human`, `agent`, or `user` is describing a seat or + perspective in the workflow, not a literal identity. - No literal personal names or specific agent brand names are used in - the `Sponsors` section. + the `Sponsors` section or elsewhere when an abstract seat is intended. - The roles named are descriptive of the interests being represented in the cycle. - This is enforced by the automated docs test (`tests/docs.test.ts`). diff --git a/docs/method/backlog/bad-code/PROCESS_generated-reference-sync-coupling.md b/docs/method/backlog/bad-code/PROCESS_generated-reference-sync-coupling.md deleted file mode 100644 index 8ef1fb3..0000000 --- a/docs/method/backlog/bad-code/PROCESS_generated-reference-sync-coupling.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: "Generated Reference Sync Coupling" -legend: PROCESS -lane: bad-code -owner: "METHOD maintainers" -priority: medium -acceptance_criteria: - - "The note names the current bad behavior: refreshing generated reference docs like docs/MCP.md or docs/CLI.md currently requires `method sync ship`, which also mutates repo-level ship surfaces such as BEARING and CHANGELOG." - - "The proposal defines a bounded way to regenerate reference docs without touching ship-only artifacts that are supposed to move on merged main." - - "The contract keeps full `method sync ship` as the post-merge main maneuver rather than weakening that workflow." - - "The contract specifies regression expectations proving a scoped reference-doc refresh leaves BEARING and CHANGELOG unchanged on branch-local runs." ---- - -# Generated Reference Sync Coupling - -Refreshing generated reference docs during PR work currently requires -running `method sync ship`, even when the only intended output is a file -like `docs/MCP.md` or `docs/CLI.md`. That full ship maneuver also -touches repo-level artifacts such as `docs/BEARING.md` and -`CHANGELOG.md`, which are explicitly supposed to reflect merged `main` -state rather than branch-local review work. - -In practice, that means a small documentation regeneration step creates -unrelated branch noise that then has to be manually reverted. This is -the sort of coupling that wastes review time and makes agents less -confident about running the generator they actually need. - -## Proposed Contract - -- Current bad behavior: - regenerating generated reference docs on a feature branch should not - require mutating ship-only artifacts whose truth is tied to merged - `main`. -- Desired split: - keep `method sync ship` as the full post-merge ship maneuver, and add - a dedicated branch-local reference-doc refresh surface such as - `method sync refs`, backed by a TypeScript helper such as - `generateReferenceDocs()`, for regenerating reference docs without - mutating ship-only artifacts. -- Scoped outputs: - `method sync refs` should refresh the current generated reference - signposts exactly: `ARCHITECTURE.md`, `docs/CLI.md`, `docs/MCP.md`, - and `docs/GUIDE.md`. It must not edit ship-only artifacts such as - `docs/BEARING.md` or `CHANGELOG.md`. -- Safety: - `method sync refs` MUST print the list of refreshed targets, and - `generateReferenceDocs()` MUST return the same target list for MCP or - other callers. That surfaced scope must name the exact refreshed - targets (`ARCHITECTURE.md`, `docs/CLI.md`, `docs/MCP.md`, - `docs/GUIDE.md`) and exclude ship-only files such as - `docs/BEARING.md` and `CHANGELOG.md`. - -## Non-goals - -- Remove or weaken `method sync ship`. -- Make `BEARING` or `CHANGELOG` branch-local artifacts again. -- Solve every generated-document refresh problem in the same slice. diff --git a/docs/method/backlog/inbox/PROCESS_i18n-string-extraction.md b/docs/method/backlog/cool-ideas/PROCESS_i18n-string-extraction.md similarity index 94% rename from docs/method/backlog/inbox/PROCESS_i18n-string-extraction.md rename to docs/method/backlog/cool-ideas/PROCESS_i18n-string-extraction.md index 67a8c75..420ce93 100644 --- a/docs/method/backlog/inbox/PROCESS_i18n-string-extraction.md +++ b/docs/method/backlog/cool-ideas/PROCESS_i18n-string-extraction.md @@ -1,7 +1,7 @@ --- title: "i18n String Extraction" legend: PROCESS -lane: inbox +lane: cool-ideas --- # i18n String Extraction diff --git a/docs/method/backlog/inbox/PROCESS_interactive-scaffolder.md b/docs/method/backlog/cool-ideas/PROCESS_interactive-scaffolder.md similarity index 94% rename from docs/method/backlog/inbox/PROCESS_interactive-scaffolder.md rename to docs/method/backlog/cool-ideas/PROCESS_interactive-scaffolder.md index b3c8428..580a2dd 100644 --- a/docs/method/backlog/inbox/PROCESS_interactive-scaffolder.md +++ b/docs/method/backlog/cool-ideas/PROCESS_interactive-scaffolder.md @@ -1,7 +1,7 @@ --- title: "Interactive Scaffolder" legend: PROCESS -lane: inbox +lane: cool-ideas --- # Interactive Scaffolder diff --git a/docs/method/backlog/inbox/PROCESS_multi-forge-adapter.md b/docs/method/backlog/cool-ideas/PROCESS_multi-forge-adapter.md similarity index 93% rename from docs/method/backlog/inbox/PROCESS_multi-forge-adapter.md rename to docs/method/backlog/cool-ideas/PROCESS_multi-forge-adapter.md index 9ee6daa..af37e3b 100644 --- a/docs/method/backlog/inbox/PROCESS_multi-forge-adapter.md +++ b/docs/method/backlog/cool-ideas/PROCESS_multi-forge-adapter.md @@ -1,7 +1,7 @@ --- title: "Multi-Forge Adapter" legend: PROCESS -lane: inbox +lane: cool-ideas --- # Multi-Forge Adapter diff --git a/docs/method/backlog/inbox/PROCESS_semantic-drift-detector.md b/docs/method/backlog/cool-ideas/PROCESS_semantic-drift-detector.md similarity index 97% rename from docs/method/backlog/inbox/PROCESS_semantic-drift-detector.md rename to docs/method/backlog/cool-ideas/PROCESS_semantic-drift-detector.md index 6719ef3..b1e8f3c 100644 --- a/docs/method/backlog/inbox/PROCESS_semantic-drift-detector.md +++ b/docs/method/backlog/cool-ideas/PROCESS_semantic-drift-detector.md @@ -1,7 +1,7 @@ --- title: "Semantic Drift Detector" legend: PROCESS -lane: inbox +lane: cool-ideas --- # Semantic Drift Detector diff --git a/docs/method/backlog/v1.1.0/PROCESS_backlog-authoring-command.md b/docs/method/backlog/v1.1.0/PROCESS_backlog-authoring-command.md new file mode 100644 index 0000000..b86b1a4 --- /dev/null +++ b/docs/method/backlog/v1.1.0/PROCESS_backlog-authoring-command.md @@ -0,0 +1,55 @@ +--- +title: "Backlog Authoring Command" +legend: PROCESS +lane: v1.1.0 +owner: "METHOD maintainers" +priority: high +acceptance_criteria: + - "The note defines a bounded CLI and MCP surface for creating backlog notes directly in an explicit lane." + - "The contract supports title, body, legend, and lane without requiring manual markdown file creation." + - "Write behavior stays explicit and inspectable, with stable structured results for agents." + - "The first slice names how frontmatter is seeded so repo truth stays aligned after creation." +--- + +# Backlog Authoring Command + +METHOD currently offers `method inbox `, but not a first-class +way to create a shaped backlog note directly in an intentional lane. +That pushes humans and agents back into manual file creation for common +operations such as adding a `bad-code`, `up-next`, or release-target +item. + +## Proposed Contract + +- Surface: + ship a CLI command such as + `method backlog add --lane --title [--legend <code>] [--body-file <path>] [--json]` + plus an MCP tool such as `method_backlog_add`. +- Output: + CLI `--json` and MCP should return the same structured result with at + least `path`, `lane`, `legend`, `title`, `stem`, `slug`, and `ok`. +- Write semantics: + creation must write a real markdown note under the requested backlog + lane, seed frontmatter fields like `title`, `legend`, and `lane`, and + preserve the provided body without extra hidden templating. +- Lane handling: + the command must accept canonical lanes and repo-defined custom lanes + such as `v1.1.0`, but it must reject traversal or malformed lane + names before any write. +- Relationship to inbox: + `method inbox` remains the raw capture path. This command is for + intentional authoring once the shape is already known. + +## Why It Matters + +- The current split is inspect with METHOD, mutate by editing files. +- Arbitrary-lane authoring is one of the most common missing agent + operations during real repo work. +- Repo-defined lanes only stay trustworthy if METHOD can write them on + purpose instead of treating them as shell-only behavior. + +## Non-goals + +- Auto-prioritize or auto-pull a note just because it was created. +- Replace `method inbox` as the zero-ceremony capture path. +- Bundle lane-move or retire behavior into the first authoring slice. diff --git a/docs/method/backlog/v1.1.0/PROCESS_backlog-move-command.md b/docs/method/backlog/v1.1.0/PROCESS_backlog-move-command.md new file mode 100644 index 0000000..5d8671d --- /dev/null +++ b/docs/method/backlog/v1.1.0/PROCESS_backlog-move-command.md @@ -0,0 +1,55 @@ +--- +title: "Backlog Move Command" +legend: PROCESS +lane: v1.1.0 +owner: "METHOD maintainers" +priority: high +acceptance_criteria: + - "The note defines a bounded CLI and MCP surface for moving a live backlog item between live lanes." + - "The contract preserves frontmatter lane truth instead of relying on path inference." + - "The first slice supports canonical lanes and repo-defined lanes such as v1.1.0." + - "The result shape names the source path, destination path, destination lane, and touched files." +--- + +# Backlog Move Command + +METHOD already knows how to read backlog items by path, slug, or stem, +and the internal API can move them, but there is no first-class CLI or +MCP mutation for everyday reprioritization across live lanes. + +That leaves a common maintenance task outside the agent surface even +though the move itself is deterministic and filesystem-shaped. + +## Proposed Contract + +- Surface: + ship a CLI command such as + `method backlog move <item> --to <lane> [--json]` + plus an MCP tool such as `method_backlog_move`. +- Resolution: + `<item>` should resolve the same way `method pull` does: path, stem, + or slug that maps to exactly one live backlog file. +- Write semantics: + move the file to the requested lane directory, update frontmatter + `lane`, preserve legend/title/body, and create the destination lane + directory when needed. +- Safety: + ambiguous matches, malformed lane names, or destination collisions + must fail before any write. +- Scope boundary: + retirement to graveyard remains the job of a separate retire command, + not an overloaded generic move. + +## Why It Matters + +- Repo truth should not depend on humans remembering to rename a file + and patch YAML together. +- Agents need a native move surface for inbox triage, release-target + shaping, and backlog hygiene. +- This is the missing sibling to `method backlog add`. + +## Non-goals + +- Retire or delete backlog items. +- Bulk reorder an entire lane in one command. +- Infer destination lanes from heuristics. diff --git a/docs/method/backlog/v1.1.0/PROCESS_doctor-repair-command.md b/docs/method/backlog/v1.1.0/PROCESS_doctor-repair-command.md new file mode 100644 index 0000000..f079ac6 --- /dev/null +++ b/docs/method/backlog/v1.1.0/PROCESS_doctor-repair-command.md @@ -0,0 +1,59 @@ +--- +title: "Doctor Repair Command" +legend: PROCESS +lane: v1.1.0 +owner: "METHOD maintainers" +priority: high +acceptance_criteria: + - "The note defines a bounded repair surface for safe doctor findings such as missing directories, missing scaffold files, and repairable frontmatter stubs." + - "The contract separates planning (`--plan`) from mutation (`--apply`) so callers can inspect the proposed repairs first." + - "The design names how structured repair hints feed the repair command instead of duplicating diagnosis rules by hand." + - "CLI and MCP return stable structured results describing findings selected, files touched, and anything left unresolved." +--- + +# Doctor Repair Command + +`method doctor` is now good at diagnosing repo-shape problems, but it +still stops at prose. The next manual step is often obvious: +recreate a missing directory, restore a required file scaffold, or add a +minimal frontmatter stub so the packet is at least structurally valid. + +That is exactly the kind of bounded repo repair METHOD should be able to +plan explicitly and, when asked, execute safely. + +## Proposed Contract + +- Surface: + ship a CLI command such as + `method repair --plan` and `method repair --apply` + plus an MCP tool such as `method_repair`. +- Repair classes: + the first slice should handle safe deterministic repairs only: + missing directories, missing scaffold files, and minimal frontmatter + stubs for METHOD packet markdown. +- Planning first: + `--plan` must show the exact repairs that would be made. `--apply` + must reuse that same bounded repair set rather than inventing new + behavior on the fly. +- Structured inputs: + the implementation should build on structured repair hints from doctor + results so agents do not need to scrape prose fix strings. +- Result shape: + return the selected issues, planned or applied repairs, touched paths, + skipped findings, and `ok`. + +## Why It Matters + +- Diagnosis without a first-class repair path leaves METHOD's mutation + surface thin in the exact place agents most often need help. +- A repair plan is safer and more inspectable than pushing every caller + back into ad hoc filesystem edits. +- The command would make dogfooding doctor much tighter inside METHOD on + itself and across other repos. + +## Non-goals + +- Silent auto-repair during `method doctor`. +- Predictive multi-step refactors that go beyond bounded repo-shape + fixes. +- Repairing arbitrary business logic or source-code defects. diff --git a/docs/method/backlog/v1.1.0/PROCESS_feedback-command.md b/docs/method/backlog/v1.1.0/PROCESS_feedback-command.md new file mode 100644 index 0000000..d30109b --- /dev/null +++ b/docs/method/backlog/v1.1.0/PROCESS_feedback-command.md @@ -0,0 +1,55 @@ +--- +title: "Feedback Command" +legend: PROCESS +lane: v1.1.0 +owner: "METHOD maintainers" +priority: medium +acceptance_criteria: + - "The note defines a bounded CLI and MCP surface for capturing raw feedback into docs/method/feedback/." + - "The contract also covers archiving processed feedback with a short disposition note instead of silent deletion." + - "The result shape preserves feedback identity fields such as path, title, source, captured_at, and status." + - "The design keeps feedback distinct from backlog items so critique remains source material until processed." +--- + +# Feedback Command + +METHOD already has a documented `docs/method/feedback/` workflow, but no +native mutation surface for it. That is a repo concept without a +matching agent surface. + +The gap shows up whenever humans or agents want to capture critique, +review notes, or rough observations without prematurely turning them +into backlog work. + +## Proposed Contract + +- Capture surface: + ship a CLI command such as + `method feedback add --title <title> [--source <text>] [--body-file <path>] [--captured-at <date>] [--json]` + plus an MCP tool such as `method_feedback_add`. +- Archive surface: + add a companion command such as + `method feedback archive <item> --disposition-file <path> [--json]` + plus an MCP equivalent, so processed feedback can move into + `docs/method/feedback/archive/` with an explicit disposition note. +- Result shape: + return at least `path`, `title`, `source`, `captured_at`, `status`, + and any `archived_path` when archival happens. +- Boundary: + feedback remains source material. The command should not auto-create + backlog notes or cycles just because feedback was captured. + +## Why It Matters + +- Feedback is currently a documented part of METHOD without first-class + write support. +- A native archive flow would make provenance cheaper to preserve and + reduce pressure to leave processed feedback mixed into the live inbox. +- Agents need a named place to put critique before a human or later pass + decides what should land. + +## Non-goals + +- Auto-triage feedback directly into backlog items. +- Replace the backlog as the home of accepted planned work. +- Build a generalized comments or review system inside METHOD. diff --git a/docs/method/backlog/cool-ideas/PROCESS_retire-command.md b/docs/method/backlog/v1.1.0/PROCESS_retire-command.md similarity index 99% rename from docs/method/backlog/cool-ideas/PROCESS_retire-command.md rename to docs/method/backlog/v1.1.0/PROCESS_retire-command.md index f5d4470..c7866cd 100644 --- a/docs/method/backlog/cool-ideas/PROCESS_retire-command.md +++ b/docs/method/backlog/v1.1.0/PROCESS_retire-command.md @@ -1,7 +1,7 @@ --- title: "Retire Command" legend: PROCESS -lane: cool-ideas +lane: v1.1.0 owner: "METHOD maintainers" priority: medium acceptance_criteria: diff --git a/docs/method/backlog/v1.1.0/PROCESS_signpost-helper-commands.md b/docs/method/backlog/v1.1.0/PROCESS_signpost-helper-commands.md new file mode 100644 index 0000000..0179772 --- /dev/null +++ b/docs/method/backlog/v1.1.0/PROCESS_signpost-helper-commands.md @@ -0,0 +1,51 @@ +--- +title: "Signpost Helper Commands" +legend: PROCESS +lane: v1.1.0 +owner: "METHOD maintainers" +priority: medium +acceptance_criteria: + - "The note defines a bounded status/init helper surface for expected signposts." + - "The first slice names concrete helpers such as signpost status and signpost init for missing canonical signposts." + - "The contract keeps signpost work explicit and narrower than a full ship sync or full doctor run." + - "CLI and MCP results name missing signposts, initialized targets, and any skipped paths." +--- + +# Signpost Helper Commands + +METHOD has strong repo-level signpost doctrine, but the operational +surface is still broad: either run ship sync for generated references or +run doctor for everything. There is little support for bounded signpost +maintenance on purpose. + +## Proposed Contract + +- Surface: + ship helpers such as + `method signpost status` and + `method signpost init <name>` + plus MCP equivalents. +- Status behavior: + report which expected signposts exist, which are missing, and which + are generated versus handwritten. +- Init behavior: + scaffold narrowly targeted missing signposts such as `docs/BEARING.md` + or `docs/MCP.md` without pretending to do full repo repair. +- Relationship to ship sync: + these helpers do not replace `method sync ship` or `method sync refs`; + they provide a narrower repo-maintenance surface when the operator + knows signposts are the problem. + +## Why It Matters + +- Repo signposts are central to METHOD's coordination story. +- Bounded helpers are easier for agents to use safely than a broad + repair or sync command. +- Missing or stale signposts should be easier to inspect intentionally + than by reconstructing the answer from unrelated commands. + +## Non-goals + +- Regenerate every repo-level artifact in one catch-all command. +- Replace the release or ship-sync doctrine. +- Infer repo voice or meaning beyond the existing signpost contracts. diff --git a/docs/method/backlog/cool-ideas/PROCESS_structured-repair-hints.md b/docs/method/backlog/v1.1.0/PROCESS_structured-repair-hints.md similarity index 99% rename from docs/method/backlog/cool-ideas/PROCESS_structured-repair-hints.md rename to docs/method/backlog/v1.1.0/PROCESS_structured-repair-hints.md index 37101b7..574c871 100644 --- a/docs/method/backlog/cool-ideas/PROCESS_structured-repair-hints.md +++ b/docs/method/backlog/v1.1.0/PROCESS_structured-repair-hints.md @@ -1,7 +1,7 @@ --- title: "Structured Repair Hints" legend: PROCESS -lane: cool-ideas +lane: v1.1.0 owner: "METHOD maintainers" priority: medium acceptance_criteria: diff --git a/docs/method/backlog/v1.1.0/PROCESS_task-dependency-dag.md b/docs/method/backlog/v1.1.0/PROCESS_task-dependency-dag.md new file mode 100644 index 0000000..66ea573 --- /dev/null +++ b/docs/method/backlog/v1.1.0/PROCESS_task-dependency-dag.md @@ -0,0 +1,34 @@ +--- +title: "Task Dependency DAG" +legend: PROCESS +lane: v1.1.0 +owner: "METHOD maintainers" +priority: medium +--- + +# Add blocks/blocked_by fields to task YAML frontmatter + +## What + +Extend METHOD task file conventions to support dependency tracking: + +- `blocks:` — list of task IDs this item blocks +- `blocked_by:` — list of task IDs this item is blocked by + +Add MCP/CLI methods to: +- Generate the full dependency DAG from frontmatter +- Show the DAG for a specific task (what blocks it, what it blocks) +- Show unblocked items (layer 0 — ready to start) +- Show the critical path to a given item + +## Why + +Used this pattern in git-warp v17.0.0 backlog planning. The YAML +frontmatter + critical path visualization made the execution order +obvious. Without it, items reference each other in prose and the +dependency order is invisible. + +## Origin + +git-warp cycle 0013. 21 backlog items with dependency DAG in +`docs/method/backlog/v17.0.0/`. diff --git a/docs/method/feedback/README.md b/docs/method/feedback/README.md index c2f94e1..c5dcb7a 100644 --- a/docs/method/feedback/README.md +++ b/docs/method/feedback/README.md @@ -1,29 +1,22 @@ --- -title: "Feedback Inbox" +title: "Legacy Feedback Archive" --- -# Feedback Inbox +# Legacy Feedback Archive -Store raw feedback documents here until they are processed. +New raw intake belongs in `docs/method/backlog/inbox/`, not here. -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. +This directory remains only to preserve older feedback artifacts that +predate the inbox-only intake model. -Recommended contents: +Current repo contract: -- what the feedback is -- where it came from -- when it was captured -- the concrete observations or suggestions +- capture critique, review notes, and outside-in observations in + `docs/method/backlog/inbox/` +- use explicit `source` and `captured_at` metadata when provenance + matters +- triage inbox items into `asap/`, a release lane, `bad-code/`, + `cool-ideas/`, a direct docs repair, or the graveyard -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. +Historical files under `archive/` are kept for provenance only. Do not +add new raw intake documents here. diff --git a/docs/method/feedback/archive/2026-04-11-missing-api-surfaces.md b/docs/method/feedback/archive/2026-04-11-missing-api-surfaces.md new file mode 100644 index 0000000..cc07cd0 --- /dev/null +++ b/docs/method/feedback/archive/2026-04-11-missing-api-surfaces.md @@ -0,0 +1,191 @@ +--- +title: "Missing METHOD API surfaces found during repo work" +captured_at: "2026-04-11" +source: "Cross-repo usage while working in warp-ttd, Echo, Wesley, Continuum, and git-warp" +status: "archived" +--- + +# Missing METHOD API surfaces found during repo work + +## What this feedback is + +Concrete feedback from actually trying to use METHOD as the process +surface while doing real cross-repo work. + +The current CLI/MCP surface is useful for inspection and a few +workflow-native operations, but there are several recurring places +where the absence of an API pushes work back into direct filesystem +edits. + +That drift matters because METHOD's own doctrine says the agent surface +should be explicit and inspectable. + +## Where it came from + +Captured while: + +- seeding backlog items across multiple repos +- writing feedback and process notes +- running `method_doctor` and interpreting its output +- trying to decide when direct file edits were acceptable fallback + versus when a METHOD-native operation should exist + +## Concrete observations + +### 1. No general backlog-authoring API + +Current surface: + +- `method inbox <idea>` exists +- `method pull <item>` exists + +Missing: + +- create a structured backlog note directly in a chosen lane +- supply title/body/frontmatter/legend/lane intentionally +- create a backlog note from an agent/tool without dropping to manual + file creation + +This gap shows up constantly for: + +- `up-next/` follow-on notes +- `bad-code/` smells found during implementation +- `cool-ideas/` notes worth preserving without making them backlog root + +Desired surface: + +- `method backlog add --lane up-next --title ... --body-file ...` +- MCP equivalent for structured backlog note creation + +### 2. No repair/fix API for doctor findings + +Current surface: + +- `method doctor` diagnoses +- `method init` scaffolds a fresh workspace + +Missing: + +- safe repair of missing directories/files in an existing workspace +- frontmatter bootstrapping/stubbing +- repair planning with dry-run output + +This is the most obvious gap when `doctor` reports dozens of +structure/frontmatter issues but the tool provides no first-class +repair path. + +Desired surface: + +- `method fix --safe` +- or `method repair --plan` / `method repair --apply` + +Safe fix classes should probably include: + +- create missing required directories +- create missing required scaffold files +- add minimal frontmatter stubs to METHOD packet markdown +- optionally move orphaned backlog docs into a nominated lane + +### 3. No feedback capture API + +There is a `docs/method/feedback/` workflow, but no obvious CLI/MCP +command for capturing feedback into that inbox. + +That means the repo has a named concept with no first-class agent +surface. + +Desired surface: + +- `method feedback add --title ... --body-file ...` +- optional metadata flags like `--source`, `--captured-at`, `--from` + +### 4. No structured backlog triage/move API + +There is `pull`, but not much for everyday backlog hygiene such as: + +- move a note from `inbox/` to `up-next/` +- move a note from backlog root into a lane +- retire a note to `graveyard/` +- archive processed feedback with a disposition note + +This leaves common METHOD maintenance operations outside the tool +surface even though they are very filesystem-shaped and deterministic. + +Desired surface: + +- `method move <path> --to up-next` +- `method retire <path> --reason ...` +- `method feedback archive <path> --disposition-file ...` + +### 5. No explicit signpost maintenance helpers + +There is `sync ship`, which is broad, but not much for bounded, +explicit signpost work while staying inside METHOD language. + +Examples: + +- add or refresh `docs/BEARING.md` +- scaffold missing signposts such as `docs/MCP.md` +- report which expected signposts are missing in a more targeted way + than the full doctor run + +Desired surface: + +- `method signpost status` +- `method signpost init BEARING` +- `method signpost init MCP` + +### 6. MCP parity is incomplete for METHOD-native mutations + +The MCP surface is strong for inspection, but the mutation side is +still thin in the places where agents most often need to stay inside +METHOD instead of dropping to manual edits. + +The missing operations above should be available through MCP as well, +not only the CLI. + +## Suggestions + +Priority order I would want: + +1. backlog note creation in arbitrary lanes +2. doctor repair/fix plan + apply +3. feedback capture +4. backlog move/retire/archive operations +5. signpost helpers + +## Why this matters + +Right now the tool encourages a split brain: + +- inspect with METHOD +- mutate by editing files directly + +That is workable, but it weakens METHOD's claim to be the explicit +agent surface for process. + +The more deterministic repo-shape operations METHOD can own directly, +the less often agents and humans need to improvise around the method +instead of through it. + +## Disposition + +Accepted as `v1.1.0` planning input on 2026-04-11. + +Created backlog notes: + +- `PROCESS_backlog-authoring-command` +- `PROCESS_backlog-move-command` +- `PROCESS_doctor-repair-command` +- `PROCESS_feedback-command` +- `PROCESS_signpost-helper-commands` + +Moved related existing backlog notes into `docs/method/backlog/v1.1.0/`: + +- `PROCESS_task-dependency-dag` +- `PROCESS_retire-command` +- `PROCESS_structured-repair-hints` + +The feedback document now belongs in the archive because its accepted +points have been converted into explicit backlog work rather than left +as a live unprocessed note. diff --git a/docs/method/process.md b/docs/method/process.md index 9e88a8b..4c19fd3 100644 --- a/docs/method/process.md +++ b/docs/method/process.md @@ -12,10 +12,12 @@ METHOD cycles run as a calm pull-design-test-playback-close-review-ship-sync loo - If a claimed result cannot be reproduced, it is not done. - Drift is checked explicitly at close, not hand-waved after the fact. Invariant preservation is part of the drift check. +- `human`, `agent`, and `user` are abstract seats or perspectives in + METHOD doctrine, not literal people, accounts, or model brands. - 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. +- All raw intake, including feedback or review notes, lands in + `docs/method/backlog/inbox/`. Use explicit metadata such as + `source` and `captured_at` when provenance matters. - Repo-level ship surfaces such as `BEARING.md` and `CHANGELOG.md` reflect merged `main` state, not branch-local closeout state. - If merged `main` is ever found carrying an open cycle packet, treat @@ -24,7 +26,7 @@ METHOD cycles run as a calm pull-design-test-playback-close-review-ship-sync loo - Review visibility is available through `method review-state`, which summarizes branch and PR context without turning METHOD into a forge cockpit. -- All cycle work must be done on a branch named `cycles/####-slug`. +- All cycle work must be done on a branch named `cycles/<LEGEND>_<slug>`. - Once a full cycle is complete (after the retro), the operator must push the branch and open a PR to the target branch (usually `main`). - Agents must stage and commit all modified files at the end of each turn @@ -32,18 +34,24 @@ METHOD cycles run as a calm pull-design-test-playback-close-review-ship-sync loo ## Default Loop -1. Pull an item from the backlog into `docs/design/<cycle>/`. +1. Pull an item from the backlog into `docs/design/<cycle>.md` or, for + release-tagged work, into `docs/releases/<version>/design/<cycle>.md`. 2. Write the design with both human and agent sponsors named as abstract roles (e.g., "System Architect", "Workflow Automator"), plus the accessibility, localization, and agent-inspectability - contract. + contract. Treat `user` the same way: it names the served perspective + in the hill or witness, like a user story, not a literal named + person. 3. Write failing tests from the playback questions. 4. Make the tests pass. 5. Produce a reproducible playback witness, including reduced/ linearized, localized, or agent-facing paths when the hill claims them. A purely observational artifact may support the witness, but it does not satisfy done on its own. -6. Close the cycle packet with a retro in `docs/method/retro/<cycle>/`. +6. Close the cycle packet with a retro in either + `docs/method/retro/<cycle>/<cycle>.md` or + `docs/releases/<version>/retros/<cycle>/<cycle>.md`, depending on + whether the cycle carries release scope. 7. Review the complete cycle packet on a branch or PR. 8. After merge, update repo-level ship surfaces on `main` such as `BEARING.md`, `CHANGELOG.md`, and release notes when relevant. @@ -58,8 +66,8 @@ METHOD uses Git for distributed coordination but remains forge-agnostic. ### Branch Naming -- **Cycle Branches:** Use `cycles/####-slug` (e.g., - `cycles/0015-git-branch-workflow-policy`). +- **Cycle Branches:** Use `cycles/<LEGEND>_<slug>` (e.g., + `cycles/PROCESS_git-branch-workflow-policy`). - **Maintenance Branches:** Use `maint-slug` for low-risk changes that require review (e.g., `maint-fix-typos`). - **Triage/Backlog:** Small backlog captures or moves can happen @@ -73,20 +81,17 @@ 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 +### Intake and Triage 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. + observations as backlog notes in `docs/method/backlog/inbox/`. +2. **Annotate when useful:** Record explicit frontmatter metadata such + as `source` and `captured_at` when the origin or timing matters. +3. **Process:** During a maintenance pass, convert the inbox item into + a durable choice: `asap`, a release lane such as `v2.4.5`, + `bad-code`, `cool-ideas`, a direct docs repair, or the graveyard. +4. **Do not split intake systems:** The inbox is the raw intake path. + Avoid parallel holding areas for critique or review notes. ### The Ship Sync Maneuver @@ -138,9 +143,9 @@ A specialized cycle for "reading the repo and summarizing what it is." This prot - `README.md` - repo instructions and `docs/method/process.md` - `docs/method/legends/*.md` - - `docs/design/*/` + - `docs/design/*.md` - `docs/method/retro/*/` - - backlog lanes in priority order + - backlog lanes in repo-defined order - `docs/method/graveyard/` 2. Record the exact source list that will ground the synthesis. diff --git a/docs/method/release.md b/docs/method/release.md index 6344721..262dc84 100644 --- a/docs/method/release.md +++ b/docs/method/release.md @@ -30,10 +30,25 @@ surface lives in `docs/releases/`. ## Scope -Releases aggregate shipped work. They do not create -`docs/method/backlog/<version>/` directories, and they do not move -backlog items by version. Backlog lanes stay about priority and scope, -not release membership. +Releases aggregate shipped work. Release truth still lives in +`docs/method/releases/vX.Y.Z/`, `docs/releases/vX.Y.Z.md`, and +`CHANGELOG.md`. + +A repo may use a backlog lane such as `docs/method/backlog/v1.1.0/` as +the primary planning bucket for a release. That is a good way to keep +release scope legible in the backlog. + +Release lanes are still planning input, not release truth. Creating or +filling that lane does not declare the release accepted, does not +guarantee that every item in the lane ships together, and does not +replace the release packet or user-facing release notes. + +When work is pulled from a release lane, the cycle packet may live under +`docs/releases/vX.Y.Z/design/<cycle>/` and +`docs/releases/vX.Y.Z/retros/<cycle>/` so the filesystem keeps release +scope and cycle evidence adjacent. If an item is temporarily promoted to +`asap/`, durable `release:` metadata should preserve that release +association. The release design names and justifies the intended version before tagging. Commit history, diff inspection, and validation can support or @@ -58,6 +73,10 @@ the decision by themselves. 5. Tag, publish, and verify delivery directly. 6. Ship sync repo-level surfaces that the release changed. +Future automation may add a release-readiness witness or rollup, but it +should summarize the release-scoped cycle evidence above rather than +replace it. + ## User-Facing Release Notes `docs/releases/vX.Y.Z.md` should be documentation, not ledger sludge. diff --git a/docs/method/retro/PROCESS_generated-reference-sync-coupling/PROCESS_generated-reference-sync-coupling.md b/docs/method/retro/PROCESS_generated-reference-sync-coupling/PROCESS_generated-reference-sync-coupling.md new file mode 100644 index 0000000..240ff01 --- /dev/null +++ b/docs/method/retro/PROCESS_generated-reference-sync-coupling/PROCESS_generated-reference-sync-coupling.md @@ -0,0 +1,51 @@ +--- +title: "Generated Reference Sync Coupling" +cycle: "PROCESS_generated-reference-sync-coupling" +design_doc: "docs/design/PROCESS_generated-reference-sync-coupling.md" +outcome: hill-met +drift_check: yes +--- + +# Generated Reference Sync Coupling Retro + +## Summary + +Two coupled problems solved in one cycle: + +1. **Reference doc decoupling**: `method sync refs` and `Workspace.syncRefs()` + now refresh `ARCHITECTURE.md`, `docs/CLI.md`, `docs/MCP.md`, and + `docs/GUIDE.md` without touching ship-only artifacts (`CHANGELOG.md`, + `docs/BEARING.md`). Exposed via CLI, MCP (`method_sync_refs`), and + integrated into `shipSync()`. + +2. **Flat design docs with legend-prefixed cycle names**: Design docs are + now flat files (`docs/design/<LEGEND>_<slug>.md`) instead of + directory-per-cycle (`docs/design/<NNNN>-<slug>/<slug>.md`). Sequential + numeric prefixes replaced with legend-prefixed slugs. Doctor detects + legacy nested dirs and offers `flatten-design-doc` repair. Retros keep + directory layout for witness subdirs. Legacy layout remains + discoverable for backward compat. + +## Playback Witness + +- [Automated verification witness](witness/verification.md) + +## Drift + +- None recorded. All 7 playback questions matched. + +## New Debt + +- 39 existing design doc directories in this repo are still in legacy + nested format. `method doctor` detects them; `method doctor --repair` + can flatten them. + +## Cool Ideas + +- None recorded. + +## Backlog Maintenance + +- [x] Inbox processed +- [x] Priorities reviewed +- [ ] Dead work buried or merged diff --git a/docs/method/retro/PROCESS_generated-reference-sync-coupling/witness/verification.md b/docs/method/retro/PROCESS_generated-reference-sync-coupling/witness/verification.md new file mode 100644 index 0000000..c4e92a8 --- /dev/null +++ b/docs/method/retro/PROCESS_generated-reference-sync-coupling/witness/verification.md @@ -0,0 +1,40 @@ +--- +title: "Verification Witness for Cycle PROCESS_generated-reference-sync-coupling" +--- + +# Verification Witness for Cycle PROCESS_generated-reference-sync-coupling + +This witness proves that `Generated Reference Sync Coupling` now carries the required +behavior and adheres to the repo invariants. + +## Test Results + +```text + +> @flyingrobots/method@1.0.0 test +> vitest run --config vitest.config.ts + + + RUN v4.1.2 . + + + Test Files 17 passed (17) + Tests 256 passed (256) + Start at 18:12:33 + Duration 1.78s (transform 1.19s, setup 0ms, import 4.04s, tests 4.37s, environment 1ms) + + +``` + +## Drift Results + +```text +No playback-question drift found. +Scanned 1 active cycle, 7 playback questions, 269 test descriptions. +Search basis: exact normalized match in tests/**/*.test.* and tests/**/*.spec.* descriptions. + +``` + +## Manual Verification + +- [x] Automated capture completed successfully. diff --git a/flyingrobots-method-1.0.0.tgz b/flyingrobots-method-1.0.0.tgz new file mode 100644 index 0000000..ab1b574 Binary files /dev/null and b/flyingrobots-method-1.0.0.tgz differ diff --git a/src/adapters/github.ts b/src/adapters/github.ts index fc06250..f818870 100644 --- a/src/adapters/github.ts +++ b/src/adapters/github.ts @@ -77,14 +77,7 @@ export class GitHubAdapter { } private getAllBacklogItems(status: WorkspaceStatus) { - return [ - ...status.backlog.inbox, - ...status.backlog.asap, - ...status.backlog['up-next'], - ...status.backlog['cool-ideas'], - ...status.backlog['bad-code'], - ...status.backlog.root, - ]; + return Object.values(status.backlog).flat(); } async pushItem(relativePath: string): Promise<GitHubSyncResult> { diff --git a/src/cli-args.ts b/src/cli-args.ts index 7a2e6ec..dd46ef4 100644 --- a/src/cli-args.ts +++ b/src/cli-args.ts @@ -5,7 +5,18 @@ export type ParsedCommand = | { command: 'help'; topic?: string } | { command: 'init'; path: string } | { command: 'doctor'; json?: boolean } - | { command: 'inbox'; idea: string; legend?: string; title?: string } + | { command: 'migrate'; json?: boolean } + | { command: 'inbox'; idea: string; legend?: string; title?: string; bodyFile?: string; source?: string; capturedAt?: string; json?: boolean } + | { command: 'backlog-add'; lane: string; title: string; legend?: string; bodyFile?: string; json?: boolean } + | { command: 'backlog-move'; item: string; to: string; json?: boolean } + | { command: 'backlog-edit'; item: string; owner?: string; clearOwner?: boolean; priority?: string; clearPriority?: boolean; keywords?: string[]; clearKeywords?: boolean; blockedBy?: string[]; clearBlockedBy?: boolean; blocks?: string[]; clearBlocks?: boolean; json?: boolean } + | { command: 'backlog-list'; lane?: string; legend?: string; priority?: string; keyword?: string; owner?: string; ready?: boolean; hasAcceptanceCriteria?: boolean; blockedBy?: string; blocks?: string; sort?: string; limit: number; json?: boolean } + | { command: 'backlog-deps'; item?: string; readyOnly?: boolean; criticalPath?: boolean; json?: boolean } + | { command: 'retire'; item: string; reason: string; replacement?: string; dryRun?: boolean; yes?: boolean; json?: boolean } + | { command: 'signpost-status'; json?: boolean } + | { command: 'signpost-init'; name: string; json?: boolean } + | { command: 'repair'; mode: 'plan' | 'apply'; json?: boolean } + | { command: 'next'; lane?: string; legend?: string; priority?: string; keyword?: string; owner?: string; includeBlocked?: boolean; limit: number; json?: boolean } | { command: 'pull'; item: string } | { command: 'close'; cycle?: string; driftCheck?: 'yes' | 'no'; outcome: Outcome } | { command: 'drift'; cycle?: string } @@ -13,7 +24,8 @@ export type ParsedCommand = | { command: 'status' } | { command: 'mcp' } | { command: 'sync'; adapter: 'github'; push?: boolean; pull?: boolean } - | { command: 'sync'; adapter: 'ship' }; + | { command: 'sync'; adapter: 'ship' } + | { command: 'sync'; adapter: 'refs' }; export function parseCliArgs(argv: readonly string[]): ParsedCommand { const [command, ...rest] = argv; @@ -37,8 +49,22 @@ export function parseCliArgs(argv: readonly string[]): ParsedCommand { return parseInitArgs(rest); case 'doctor': return parseDoctorArgs(rest); + case 'migrate': + return parseMigrateArgs(rest); case 'inbox': return parseInboxArgs(rest); + case 'backlog': + return parseBacklogArgs(rest); + case 'retire': + return parseRetireArgs(rest); + case 'signpost': + return parseSignpostArgs(rest); + case 'feedback': + throw new MethodError('`feedback` was removed. Capture outside-in critique in `method inbox` with `--source`, `--captured-at`, and optional `--body-file`.'); + case 'repair': + return parseRepairArgs(rest); + case 'next': + return parseNextArgs(rest); case 'pull': return parsePullArgs(rest); case 'close': @@ -77,13 +103,19 @@ export function parseCliArgs(argv: readonly string[]): ParsedCommand { } return { command: 'sync', adapter: 'ship' }; } - throw new MethodError(`Usage: method sync github|ship\n\n${usage('sync')}`); + if (rest[0] === 'refs') { + if (rest.length > 1) { + throw new MethodError(`\`sync refs\` does not take any arguments.\n\n${usage('sync')}`); + } + return { command: 'sync', adapter: 'refs' }; + } + throw new MethodError(`Usage: method sync github|ship|refs\n\n${usage('sync')}`); default: throw new MethodError(`Unknown command: ${command}`); } } -export const CLI_TOPICS = ['init', 'doctor', 'inbox', 'pull', 'close', 'status', 'drift', 'review-state', 'mcp', 'sync'] as const; +export const CLI_TOPICS = ['init', 'doctor', 'migrate', 'inbox', 'backlog-add', 'backlog-move', 'backlog-edit', 'backlog-list', 'backlog-deps', 'retire', 'signpost-status', 'signpost-init', 'repair', 'next', 'pull', 'close', 'status', 'drift', 'review-state', 'mcp', 'sync'] as const; export function usage(topic?: string): string { if (topic === 'init') { @@ -96,14 +128,113 @@ export function usage(topic?: string): string { 'Inspect METHOD workspace health without mutating it.', ].join('\n'); } + if (topic === 'migrate') { + return [ + 'Usage: method migrate [--json]', + '', + 'Run doctor, apply the bounded repair set, then re-check the workspace.', + ].join('\n'); + } if (topic === 'inbox') { - return 'Usage: method inbox <idea> [--legend CODE] [--title TITLE]\n\nCapture a raw idea in docs/method/backlog/inbox/.'; + return 'Usage: method inbox <idea> [--legend CODE] [--title TITLE] [--body-file PATH] [--source TEXT] [--captured-at YYYY-MM-DD] [--json]\n\nCapture raw input in docs/method/backlog/inbox/.'; + } + if (topic === 'backlog') { + return [ + 'Usage:', + ' method backlog add --lane LANE --title TITLE [--legend CODE] [--body-file PATH] [--json]', + ' method backlog move <item> --to LANE [--json]', + ' method backlog edit <item> [--owner TEXT|--clear-owner] [--priority VALUE|--clear-priority] [--keyword VALUE ...|--clear-keywords] [--blocked-by REF ...|--clear-blocked-by] [--blocks REF ...|--clear-blocks] [--json]', + ' method backlog list [--lane LANE] [--legend CODE] [--priority VALUE] [--keyword VALUE] [--owner VALUE] [--ready|--blocked] [--has-acceptance-criteria|--missing-acceptance-criteria] [--blocked-by REF] [--blocks REF] [--sort lane|priority|path] [--limit N] [--json]', + ' method backlog deps [item] [--ready] [--critical-path] [--json]', + '', + 'Create, move, or inspect backlog notes without manual filesystem edits.', + ].join('\n'); + } + if (topic === 'backlog-add') { + return [ + 'Usage: method backlog add --lane LANE --title TITLE [--legend CODE] [--body-file PATH] [--json]', + '', + 'Create a shaped backlog note directly in the requested backlog lane.', + ].join('\n'); + } + if (topic === 'backlog-move') { + return [ + 'Usage: method backlog move <item> --to LANE [--json]', + '', + 'Move a live backlog note into another backlog lane.', + ].join('\n'); + } + if (topic === 'backlog-edit') { + return [ + 'Usage: method backlog edit <item> [--owner TEXT|--clear-owner] [--priority VALUE|--clear-priority] [--keyword VALUE ...|--clear-keywords] [--blocked-by REF ...|--clear-blocked-by] [--blocks REF ...|--clear-blocks] [--json]', + '', + 'Update explicit schema-backed backlog metadata on a live backlog note. Repeat `--keyword`, `--blocked-by`, or `--blocks` to replace list fields.', + ].join('\n'); + } + if (topic === 'backlog-list') { + return [ + 'Usage: method backlog list [--lane LANE] [--legend CODE] [--priority VALUE] [--keyword VALUE] [--owner VALUE] [--ready|--blocked] [--has-acceptance-criteria|--missing-acceptance-criteria] [--blocked-by REF] [--blocks REF] [--sort lane|priority|path] [--limit N] [--json]', + '', + 'Return structured backlog items and explicit frontmatter metadata such as owner, priority, keywords, blocks, blocked_by, readiness, and acceptance criteria presence.', + ].join('\n'); + } + if (topic === 'backlog-deps') { + return [ + 'Usage: method backlog deps [item] [--ready] [--critical-path] [--json]', + '', + 'Inspect live backlog dependencies from `blocked_by` / `blocks` frontmatter.', + 'Use `--ready` to show unblocked work, or pass `<item> --critical-path` to show the longest blocker chain to one item.', + ].join('\n'); + } + if (topic === 'retire') { + return [ + 'Usage: method retire <item> --reason TEXT [--replacement PATH] [--dry-run] [--yes] [--json]', + '', + 'Retire a live backlog note into the graveyard with an explicit disposition record.', + ].join('\n'); + } + if (topic === 'signpost') { + return [ + 'Usage:', + ' method signpost status [--json]', + ' method signpost init <name> [--json]', + '', + 'Inspect expected repo signposts or initialize a narrowly supported missing signpost.', + ].join('\n'); + } + if (topic === 'signpost-status') { + return [ + 'Usage: method signpost status [--json]', + '', + 'Report which expected repo signposts exist, which are missing, and which can be initialized by helper commands.', + ].join('\n'); + } + if (topic === 'signpost-init') { + return [ + 'Usage: method signpost init <name> [--json]', + '', + 'Initialize a narrowly supported missing canonical signpost such as BEARING or MCP.', + ].join('\n'); + } + if (topic === 'repair') { + return [ + 'Usage: method repair (--plan | --apply) [--json]', + '', + 'Plan or apply bounded doctor-guided repairs for missing directories, scaffold files, and frontmatter stubs.', + ].join('\n'); + } + if (topic === 'next') { + return [ + 'Usage: method next [--lane LANE] [--legend CODE] [--priority VALUE] [--keyword VALUE] [--owner VALUE] [--include-blocked] [--limit N] [--json]', + '', + 'Return a bounded advisory menu of sensible next backlog items using lane order, declared frontmatter, dependency readiness, and literal BEARING mentions.', + ].join('\n'); } if (topic === 'pull') { - return 'Usage: method pull <item>\n\nPromote a backlog item into the next numbered design cycle.'; + return 'Usage: method pull <item>\n\nPromote a backlog item into a new cycle packet. Release-tagged work scaffolds under docs/releases/<version>/.'; } 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/. `--outcome` is required.'; + return 'Usage: method close [cycle] [--drift-check yes|no] --outcome hill-met|partial|not-met\n\nClose an active cycle into its retro packet. `--outcome` is required.'; } if (topic === 'status') { return 'Usage: method status\n\nShow backlog lanes, active cycles, and legend health.'; @@ -132,13 +263,13 @@ export function usage(topic?: string): string { if (topic === 'sync') { return [ - 'Usage: method sync github|ship [options]', + 'Usage: method sync github|ship|refs [options]', '', 'GitHub Options:', ' --push Update GitHub issues with local changes (default).', ' --pull Update local backlog with GitHub changes (labels, comments, status).', '', - 'Perform Ship Sync or synchronize the backlog with GitHub Issues.', + 'Perform Ship Sync, refresh generated reference docs, or synchronize the backlog with GitHub Issues.', ].join('\n'); } @@ -148,7 +279,18 @@ export function usage(topic?: string): string { 'Commands:', ' init [path] Scaffold a METHOD workspace.', ' doctor [--json] Inspect workspace health and suggest fixes.', - ' inbox <idea> Capture a raw idea in inbox/.', + ' migrate [--json] Doctor, apply bounded repairs, then re-check.', + ' inbox <idea> Capture raw input in inbox/.', + ' backlog add Create a shaped backlog note in a chosen lane.', + ' backlog move Move a backlog note into another lane.', + ' backlog edit Update schema-backed backlog metadata.', + ' backlog list Query backlog items by lane or frontmatter metadata.', + ' backlog deps Inspect backlog dependency edges and ready work.', + ' retire <item> Retire a live backlog note into graveyard/.', + ' signpost status Report expected signposts and gaps.', + ' signpost init <name> Initialize a supported missing signpost.', + ' repair (--plan|--apply) Plan or apply safe doctor-guided repairs.', + ' next [--limit N] Recommend a bounded next-work menu.', ' pull <item> Promote a backlog item into a cycle.', ' close [cycle] Write a retro for an active cycle.', ' drift [cycle] Check active cycle playback questions against tests.', @@ -157,6 +299,7 @@ export function usage(topic?: string): string { ' mcp Start the MCP server over stdio.', ' sync github [--push|--pull] Sync backlog with GitHub Issues.', ' sync ship Perform Ship Sync.', + ' sync refs Refresh generated reference docs.', '', 'Run `method help <command>` for command-specific usage.', ].join('\n'); @@ -183,9 +326,27 @@ function parseDoctorArgs(args: readonly string[]): ParsedCommand { return { command: 'doctor', json }; } +function parseMigrateArgs(args: readonly string[]): ParsedCommand { + let json = false; + + for (const value of args) { + if (value === '--json') { + json = true; + continue; + } + throw new MethodError(`Unknown option: ${value}\n\n${usage('migrate')}`); + } + + return { command: 'migrate', json }; +} + function parseInboxArgs(args: readonly string[]): ParsedCommand { let legend: string | undefined; let title: string | undefined; + let bodyFile: string | undefined; + let source: string | undefined; + let capturedAt: string | undefined; + let json = false; const positionals: string[] = []; for (let index = 0; index < args.length; index += 1) { @@ -208,6 +369,37 @@ function parseInboxArgs(args: readonly string[]): ParsedCommand { title = value.slice('--title='.length); continue; } + if (value === '--body-file') { + bodyFile = requireOptionValue(args, index, '--body-file'); + index += 1; + continue; + } + if (value.startsWith('--body-file=')) { + bodyFile = value.slice('--body-file='.length); + continue; + } + if (value === '--source') { + source = requireOptionValue(args, index, '--source'); + index += 1; + continue; + } + if (value.startsWith('--source=')) { + source = value.slice('--source='.length); + continue; + } + if (value === '--captured-at') { + capturedAt = requireOptionValue(args, index, '--captured-at'); + index += 1; + continue; + } + if (value.startsWith('--captured-at=')) { + capturedAt = value.slice('--captured-at='.length); + continue; + } + if (value === '--json') { + json = true; + continue; + } if (value.startsWith('-')) { throw new MethodError(`Unknown option: ${value}`); } @@ -215,10 +407,610 @@ function parseInboxArgs(args: readonly string[]): ParsedCommand { } if (positionals.length !== 1) { - throw new MethodError('Usage: method inbox <idea> [--legend CODE] [--title TITLE]'); + throw new MethodError(usage('inbox')); } - return { command: 'inbox', idea: positionals[0], legend, title }; + return { command: 'inbox', idea: positionals[0], legend, title, bodyFile, source, capturedAt, json }; +} + +function parseBacklogArgs(args: readonly string[]): ParsedCommand { + if (args[0] === 'move') { + return parseBacklogMoveArgs(args.slice(1)); + } + if (args[0] === 'edit') { + return parseBacklogEditArgs(args.slice(1)); + } + if (args[0] === 'list') { + return parseBacklogListArgs(args.slice(1)); + } + if (args[0] === 'deps') { + return parseBacklogDepsArgs(args.slice(1)); + } + if (args[0] !== 'add') { + throw new MethodError(usage('backlog')); + } + + let lane: string | undefined; + let title: string | undefined; + let legend: string | undefined; + let bodyFile: string | undefined; + let json = false; + + for (let index = 1; index < args.length; index += 1) { + const value = args[index]; + if (value === '--lane') { + lane = requireOptionValue(args, index, '--lane'); + index += 1; + continue; + } + if (value.startsWith('--lane=')) { + lane = value.slice('--lane='.length); + continue; + } + if (value === '--title') { + title = requireOptionValue(args, index, '--title'); + index += 1; + continue; + } + if (value.startsWith('--title=')) { + title = value.slice('--title='.length); + continue; + } + if (value === '--legend') { + legend = requireOptionValue(args, index, '--legend'); + index += 1; + continue; + } + if (value.startsWith('--legend=')) { + legend = value.slice('--legend='.length); + continue; + } + if (value === '--body-file') { + bodyFile = requireOptionValue(args, index, '--body-file'); + index += 1; + continue; + } + if (value.startsWith('--body-file=')) { + bodyFile = value.slice('--body-file='.length); + continue; + } + if (value === '--json') { + json = true; + continue; + } + throw new MethodError(`Unknown option: ${value}\n\n${usage('backlog')}`); + } + + if (lane === undefined || title === undefined) { + throw new MethodError(usage('backlog')); + } + + return { command: 'backlog-add', lane, title, legend, bodyFile, json }; +} + +function parseBacklogMoveArgs(args: readonly string[]): ParsedCommand { + let to: string | undefined; + let json = false; + const positionals: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === '--to') { + to = requireOptionValue(args, index, '--to'); + index += 1; + continue; + } + if (value.startsWith('--to=')) { + to = value.slice('--to='.length); + continue; + } + if (value === '--json') { + json = true; + continue; + } + if (value.startsWith('-')) { + throw new MethodError(`Unknown option: ${value}\n\n${usage('backlog-move')}`); + } + positionals.push(value); + } + + if (positionals.length !== 1 || to === undefined) { + throw new MethodError(usage('backlog-move')); + } + + return { command: 'backlog-move', item: positionals[0], to, json }; +} + +function parseBacklogEditArgs(args: readonly string[]): ParsedCommand { + let owner: string | undefined; + let clearOwner = false; + let priority: string | undefined; + let clearPriority = false; + const keywords: string[] = []; + let clearKeywords = false; + const blockedBy: string[] = []; + let clearBlockedBy = false; + const blocks: string[] = []; + let clearBlocks = false; + let json = false; + const positionals: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === '--owner') { + owner = requireOptionValue(args, index, '--owner'); + index += 1; + continue; + } + if (value.startsWith('--owner=')) { + owner = value.slice('--owner='.length); + continue; + } + if (value === '--clear-owner') { + clearOwner = true; + continue; + } + if (value === '--priority') { + priority = requireOptionValue(args, index, '--priority'); + index += 1; + continue; + } + if (value.startsWith('--priority=')) { + priority = value.slice('--priority='.length); + continue; + } + if (value === '--clear-priority') { + clearPriority = true; + continue; + } + if (value === '--keyword') { + keywords.push(requireOptionValue(args, index, '--keyword')); + index += 1; + continue; + } + if (value.startsWith('--keyword=')) { + keywords.push(value.slice('--keyword='.length)); + continue; + } + if (value === '--clear-keywords') { + clearKeywords = true; + continue; + } + if (value === '--blocked-by') { + blockedBy.push(requireOptionValue(args, index, '--blocked-by')); + index += 1; + continue; + } + if (value.startsWith('--blocked-by=')) { + blockedBy.push(value.slice('--blocked-by='.length)); + continue; + } + if (value === '--clear-blocked-by') { + clearBlockedBy = true; + continue; + } + if (value === '--blocks') { + blocks.push(requireOptionValue(args, index, '--blocks')); + index += 1; + continue; + } + if (value.startsWith('--blocks=')) { + blocks.push(value.slice('--blocks='.length)); + continue; + } + if (value === '--clear-blocks') { + clearBlocks = true; + continue; + } + if (value === '--json') { + json = true; + continue; + } + if (value.startsWith('-')) { + throw new MethodError(`Unknown option: ${value}\n\n${usage('backlog-edit')}`); + } + positionals.push(value); + } + + if (positionals.length !== 1) { + throw new MethodError(usage('backlog-edit')); + } + + return { + command: 'backlog-edit', + item: positionals[0], + owner, + clearOwner, + priority, + clearPriority, + keywords: keywords.length === 0 ? undefined : keywords, + clearKeywords, + blockedBy: blockedBy.length === 0 ? undefined : blockedBy, + clearBlockedBy, + blocks: blocks.length === 0 ? undefined : blocks, + clearBlocks, + json, + }; +} + +function parseBacklogListArgs(args: readonly string[]): ParsedCommand { + let lane: string | undefined; + let legend: string | undefined; + let priority: string | undefined; + let keyword: string | undefined; + let owner: string | undefined; + let ready: boolean | undefined; + let hasAcceptanceCriteria: boolean | undefined; + let blockedBy: string | undefined; + let blocks: string | undefined; + let sort: string | undefined; + let limit = 50; + let json = false; + + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === '--lane') { + lane = requireOptionValue(args, index, '--lane'); + index += 1; + continue; + } + if (value.startsWith('--lane=')) { + lane = value.slice('--lane='.length); + continue; + } + if (value === '--legend') { + legend = requireOptionValue(args, index, '--legend'); + index += 1; + continue; + } + if (value.startsWith('--legend=')) { + legend = value.slice('--legend='.length); + continue; + } + if (value === '--priority') { + priority = requireOptionValue(args, index, '--priority'); + index += 1; + continue; + } + if (value.startsWith('--priority=')) { + priority = value.slice('--priority='.length); + continue; + } + if (value === '--keyword') { + keyword = requireOptionValue(args, index, '--keyword'); + index += 1; + continue; + } + if (value.startsWith('--keyword=')) { + keyword = value.slice('--keyword='.length); + continue; + } + if (value === '--owner') { + owner = requireOptionValue(args, index, '--owner'); + index += 1; + continue; + } + if (value.startsWith('--owner=')) { + owner = value.slice('--owner='.length); + continue; + } + if (value === '--ready') { + if (ready === false) { + throw new MethodError('Cannot combine `--ready` with `--blocked`.\n\n' + usage('backlog-list')); + } + ready = true; + continue; + } + if (value === '--blocked') { + if (ready === true) { + throw new MethodError('Cannot combine `--ready` with `--blocked`.\n\n' + usage('backlog-list')); + } + ready = false; + continue; + } + if (value === '--has-acceptance-criteria') { + if (hasAcceptanceCriteria === false) { + throw new MethodError('Cannot combine `--has-acceptance-criteria` with `--missing-acceptance-criteria`.\n\n' + usage('backlog-list')); + } + hasAcceptanceCriteria = true; + continue; + } + if (value === '--missing-acceptance-criteria') { + if (hasAcceptanceCriteria === true) { + throw new MethodError('Cannot combine `--has-acceptance-criteria` with `--missing-acceptance-criteria`.\n\n' + usage('backlog-list')); + } + hasAcceptanceCriteria = false; + continue; + } + if (value === '--blocked-by') { + blockedBy = requireOptionValue(args, index, '--blocked-by'); + index += 1; + continue; + } + if (value.startsWith('--blocked-by=')) { + blockedBy = value.slice('--blocked-by='.length); + continue; + } + if (value === '--blocks') { + blocks = requireOptionValue(args, index, '--blocks'); + index += 1; + continue; + } + if (value.startsWith('--blocks=')) { + blocks = value.slice('--blocks='.length); + continue; + } + if (value === '--sort') { + sort = requireOptionValue(args, index, '--sort'); + index += 1; + continue; + } + if (value.startsWith('--sort=')) { + sort = value.slice('--sort='.length); + continue; + } + if (value === '--limit') { + limit = parseBacklogQueryLimit(requireOptionValue(args, index, '--limit')); + index += 1; + continue; + } + if (value.startsWith('--limit=')) { + limit = parseBacklogQueryLimit(value.slice('--limit='.length)); + continue; + } + if (value === '--json') { + json = true; + continue; + } + throw new MethodError(`Unknown option: ${value}\n\n${usage('backlog-list')}`); + } + + return { command: 'backlog-list', lane, legend, priority, keyword, owner, ready, hasAcceptanceCriteria, blockedBy, blocks, sort, limit, json }; +} + +function parseBacklogDepsArgs(args: readonly string[]): ParsedCommand { + let readyOnly = false; + let criticalPath = false; + let json = false; + const positionals: string[] = []; + + for (const value of args) { + if (value === '--ready') { + readyOnly = true; + continue; + } + if (value === '--critical-path') { + criticalPath = true; + continue; + } + if (value === '--json') { + json = true; + continue; + } + if (value.startsWith('-')) { + throw new MethodError(`Unknown option: ${value}\n\n${usage('backlog-deps')}`); + } + positionals.push(value); + } + + if (positionals.length > 1) { + throw new MethodError(usage('backlog-deps')); + } + if (readyOnly && (criticalPath || positionals.length > 0)) { + throw new MethodError('`method backlog deps --ready` does not accept an item or `--critical-path`.\n\n' + usage('backlog-deps')); + } + if (criticalPath && positionals.length !== 1) { + throw new MethodError('`--critical-path` requires exactly one backlog item.\n\n' + usage('backlog-deps')); + } + + return { + command: 'backlog-deps', + item: positionals[0], + readyOnly, + criticalPath, + json, + }; +} + +function parseRetireArgs(args: readonly string[]): ParsedCommand { + let reason: string | undefined; + let replacement: string | undefined; + let dryRun = false; + let yes = false; + let json = false; + const positionals: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === '--reason') { + reason = requireOptionValue(args, index, '--reason'); + index += 1; + continue; + } + if (value.startsWith('--reason=')) { + reason = value.slice('--reason='.length); + continue; + } + if (value === '--replacement') { + replacement = requireOptionValue(args, index, '--replacement'); + index += 1; + continue; + } + if (value.startsWith('--replacement=')) { + replacement = value.slice('--replacement='.length); + continue; + } + if (value === '--dry-run') { + dryRun = true; + continue; + } + if (value === '--yes') { + yes = true; + continue; + } + if (value === '--json') { + json = true; + continue; + } + if (value.startsWith('-')) { + throw new MethodError(`Unknown option: ${value}\n\n${usage('retire')}`); + } + positionals.push(value); + } + + if (positionals.length !== 1 || reason === undefined) { + throw new MethodError(usage('retire')); + } + + return { command: 'retire', item: positionals[0], reason, replacement, dryRun, yes, json }; +} + +function parseSignpostArgs(args: readonly string[]): ParsedCommand { + if (args[0] === 'status') { + let json = false; + for (const value of args.slice(1)) { + if (value === '--json') { + json = true; + continue; + } + throw new MethodError(`Unknown option: ${value}\n\n${usage('signpost-status')}`); + } + return { command: 'signpost-status', json }; + } + + if (args[0] === 'init') { + let json = false; + const positionals: string[] = []; + for (const value of args.slice(1)) { + if (value === '--json') { + json = true; + continue; + } + if (value.startsWith('-')) { + throw new MethodError(`Unknown option: ${value}\n\n${usage('signpost-init')}`); + } + positionals.push(value); + } + if (positionals.length !== 1) { + throw new MethodError(usage('signpost-init')); + } + return { command: 'signpost-init', name: positionals[0], json }; + } + + throw new MethodError(usage('signpost')); +} + +function parseRepairArgs(args: readonly string[]): ParsedCommand { + let mode: 'plan' | 'apply' | undefined; + let json = false; + + for (const value of args) { + if (value === '--plan') { + if (mode !== undefined) { + throw new MethodError(usage('repair')); + } + mode = 'plan'; + continue; + } + if (value === '--apply') { + if (mode !== undefined) { + throw new MethodError(usage('repair')); + } + mode = 'apply'; + continue; + } + if (value === '--json') { + json = true; + continue; + } + throw new MethodError(`Unknown option: ${value}\n\n${usage('repair')}`); + } + + if (mode === undefined) { + throw new MethodError(usage('repair')); + } + + return { command: 'repair', mode, json }; +} + +function parseNextArgs(args: readonly string[]): ParsedCommand { + let lane: string | undefined; + let legend: string | undefined; + let priority: string | undefined; + let keyword: string | undefined; + let owner: string | undefined; + let includeBlocked = false; + let limit = 3; + let json = false; + + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === '--lane') { + lane = requireOptionValue(args, index, '--lane'); + index += 1; + continue; + } + if (value.startsWith('--lane=')) { + lane = value.slice('--lane='.length); + continue; + } + if (value === '--legend') { + legend = requireOptionValue(args, index, '--legend'); + index += 1; + continue; + } + if (value.startsWith('--legend=')) { + legend = value.slice('--legend='.length); + continue; + } + if (value === '--priority') { + priority = requireOptionValue(args, index, '--priority'); + index += 1; + continue; + } + if (value.startsWith('--priority=')) { + priority = value.slice('--priority='.length); + continue; + } + if (value === '--keyword') { + keyword = requireOptionValue(args, index, '--keyword'); + index += 1; + continue; + } + if (value.startsWith('--keyword=')) { + keyword = value.slice('--keyword='.length); + continue; + } + if (value === '--owner') { + owner = requireOptionValue(args, index, '--owner'); + index += 1; + continue; + } + if (value.startsWith('--owner=')) { + owner = value.slice('--owner='.length); + continue; + } + if (value === '--include-blocked') { + includeBlocked = true; + continue; + } + if (value === '--limit') { + limit = parseNextLimit(requireOptionValue(args, index, '--limit')); + index += 1; + continue; + } + if (value.startsWith('--limit=')) { + limit = parseNextLimit(value.slice('--limit='.length)); + continue; + } + if (value === '--json') { + json = true; + continue; + } + throw new MethodError(`Unknown option: ${value}\n\n${usage('next')}`); + } + + return { command: 'next', lane, legend, priority, keyword, owner, includeBlocked, limit, json }; } function parsePullArgs(args: readonly string[]): ParsedCommand { @@ -340,3 +1132,19 @@ function parsePositiveInteger(value: string, optionName: string): number { } return parsed; } + +function parseBacklogQueryLimit(value: string): number { + const parsed = parsePositiveInteger(value, '--limit'); + if (parsed > 100) { + throw new MethodError('`--limit` must be between 1 and 100.'); + } + return parsed; +} + +function parseNextLimit(value: string): number { + const parsed = parsePositiveInteger(value, '--limit'); + if (parsed > 10) { + throw new MethodError('`--limit` must be between 1 and 10.'); + } + return parsed; +} diff --git a/src/cli-renderer.ts b/src/cli-renderer.ts index b5b9aed..838c91a 100644 --- a/src/cli-renderer.ts +++ b/src/cli-renderer.ts @@ -1,12 +1,15 @@ import { headerBox, separator } from '@flyingrobots/bijou'; import { createNodeContext } from '@flyingrobots/bijou-node'; -import type { WorkspaceStatus } from './domain.js'; +import type { NextWorkResult, WorkspaceStatus } from './domain.js'; +import type { BacklogDependencyReport } from './index.js'; +import type { BacklogQueryResult } from './domain.js'; export function renderStatus(status: WorkspaceStatus): string { const ctx = createNodeContext(); + const laneWidth = Math.max(10, ...Object.keys(status.backlog).map((lane) => lane.length)); const backlogLines = [ ...Object.entries(status.backlog).map(([lane, items]) => { - return `${lane.padEnd(10, ' ')} ${String(items.length).padStart(2, ' ')} ${items.map((item) => item.stem).join(', ') || '-'}`; + return `${lane.padEnd(laneWidth, ' ')} ${String(items.length).padStart(2, ' ')} ${items.map((item) => item.stem).join(', ') || '-'}`; }), ]; @@ -32,3 +35,188 @@ export function renderStatus(status: WorkspaceStatus): string { '', ].join('\n'); } + +export function renderSignpostStatus(result: { + root: string; + signposts: Array<{ name: string; path: string; kind: string; exists: boolean; initable: boolean }>; + missing: string[]; +}): string { + const ctx = createNodeContext(); + const lines = result.signposts.map((signpost) => + `${signpost.name.padEnd(12, ' ')} ${signpost.exists ? 'present' : 'missing'} ${signpost.kind.padEnd(13, ' ')} ${signpost.initable ? 'initable' : 'manual '} ${signpost.path}`, + ); + + return [ + `${headerBox('METHOD Signposts', { detail: result.root, ctx })}`, + '', + `${separator({ label: 'Inventory', ctx })}`, + ...lines, + '', + `${separator({ label: 'Missing', ctx })}`, + ...(result.missing.length === 0 ? ['-'] : result.missing.map((path) => `- ${path}`)), + '', + ].join('\n'); +} + +export function renderSignpostInit(result: { + requested: string; + initializedTargets: string[]; + skippedPaths: string[]; +}): string { + const ctx = createNodeContext(); + return [ + `${headerBox('METHOD Signpost Init', { detail: result.requested, ctx })}`, + '', + `${separator({ label: 'Initialized', ctx })}`, + ...(result.initializedTargets.length === 0 ? ['-'] : result.initializedTargets.map((path) => `- ${path}`)), + '', + `${separator({ label: 'Skipped', ctx })}`, + ...(result.skippedPaths.length === 0 ? ['-'] : result.skippedPaths.map((path) => `- ${path}`)), + '', + ].join('\n'); +} + +export function renderBacklogQuery(result: BacklogQueryResult): string { + const ctx = createNodeContext(); + const filterParts = [ + result.filters.lane === undefined ? undefined : `lane=${result.filters.lane}`, + result.filters.legend === undefined ? undefined : `legend=${result.filters.legend}`, + result.filters.priority === undefined ? undefined : `priority=${result.filters.priority}`, + result.filters.keyword === undefined ? undefined : `keyword=${result.filters.keyword}`, + result.filters.owner === undefined ? undefined : `owner=${result.filters.owner}`, + result.filters.ready === undefined ? undefined : `ready=${result.filters.ready}`, + result.filters.hasAcceptanceCriteria === undefined ? undefined : `has_acceptance_criteria=${result.filters.hasAcceptanceCriteria}`, + result.filters.blockedBy === undefined ? undefined : `blocked_by=${result.filters.blockedBy}`, + result.filters.blocks === undefined ? undefined : `blocks=${result.filters.blocks}`, + `sort=${result.filters.sort}`, + `limit=${result.filters.limit}`, + ].filter((value): value is string => value !== undefined); + const lines = result.items.length === 0 + ? ['-'] + : result.items.map((item) => { + const details = [ + item.lane, + item.legend === undefined ? 'untagged' : item.legend, + item.priority === undefined ? undefined : `priority=${item.priority}`, + item.owner === undefined ? undefined : `owner=${item.owner}`, + item.keywords.length === 0 ? undefined : `keywords=${item.keywords.join(', ')}`, + item.blockedBy.length === 0 ? 'ready=true' : `blocked_by=${item.blockedBy.join(', ')}`, + item.blocks.length === 0 ? undefined : `blocks=${item.blocks.join(', ')}`, + item.hasAcceptanceCriteria ? 'has_acceptance_criteria=true' : undefined, + ].filter((value): value is string => value !== undefined); + return `${item.stem} ${details.join(' ')}`; + }); + + return [ + `${headerBox('METHOD Backlog Query', { detail: result.root, ctx })}`, + '', + `${separator({ label: 'Filters', ctx })}`, + filterParts.join(', '), + '', + `${separator({ label: 'Summary', ctx })}`, + `returned=${result.returnedCount} total=${result.totalCount} truncated=${result.truncated ? 'yes' : 'no'}`, + '', + `${separator({ label: 'Items', ctx })}`, + ...lines, + '', + ].join('\n'); +} + +export function renderNextWork(result: NextWorkResult): string { + const ctx = createNodeContext(); + const laneCounts = Object.entries(result.summary.lane_counts) + .map(([lane, count]) => `${lane}=${count}`) + .join(', '); + const recommendationLines = result.recommendations.length === 0 + ? ['-'] + : result.recommendations.flatMap((item, index) => [ + `${index + 1}. ${item.title} [${item.scoreBand}]`, + ` ${item.path} lane=${item.lane}${item.priority === undefined ? '' : ` priority=${item.priority}`}`, + ...item.whyNow.map((reason) => ` - ${reason}`), + ]); + + return [ + `${headerBox('METHOD Next Work', { detail: result.generated_at, ctx })}`, + '', + `${separator({ label: 'Summary', ctx })}`, + `active_cycles=${result.summary.active_cycle_count}`, + laneCounts, + `bearing_priority=${result.summary.bearing_priority ?? '-'}`, + `bearing_concerns=${result.summary.bearing_concerns.length === 0 ? '-' : result.summary.bearing_concerns.join(' | ')}`, + '', + `${separator({ label: 'Recommendations', ctx })}`, + ...recommendationLines, + '', + `${separator({ label: 'Notes', ctx })}`, + ...(result.selection_notes.length === 0 ? ['-'] : result.selection_notes.map((note) => `- ${note}`)), + '', + ].join('\n'); +} + +export function renderBacklogDependencies(result: BacklogDependencyReport): string { + const ctx = createNodeContext(); + + if (result.query.readyOnly) { + return [ + `${headerBox('METHOD Backlog Dependencies', { detail: result.root, ctx })}`, + '', + `${separator({ label: 'Ready Items', ctx })}`, + ...(result.ready.length === 0 ? ['-'] : result.ready.map((item) => `- ${item.stem} (${item.lane})`)), + '', + `${separator({ label: 'Summary', ctx })}`, + `items=${result.items.length} edges=${result.edges.length} cycles=${result.cycles.length}`, + '', + ].join('\n'); + } + + if (result.focus !== undefined) { + return [ + `${headerBox('METHOD Backlog Dependencies', { detail: result.focus.item.path, ctx })}`, + '', + `${separator({ label: 'Item', ctx })}`, + `${result.focus.item.stem} lane=${result.focus.item.lane} ready=${result.focus.item.ready ? 'yes' : 'no'}`, + ...(result.focus.item.unresolvedBlockedBy.length === 0 ? [] : [`unresolved blocked_by: ${result.focus.item.unresolvedBlockedBy.join(', ')}`]), + ...(result.focus.item.unresolvedBlocks.length === 0 ? [] : [`unresolved blocks: ${result.focus.item.unresolvedBlocks.join(', ')}`]), + '', + `${separator({ label: 'Blocked By', ctx })}`, + ...(result.focus.blockers.length === 0 ? ['-'] : result.focus.blockers.map((item) => `- ${item.stem}`)), + '', + `${separator({ label: 'Blocks', ctx })}`, + ...(result.focus.blocked.length === 0 ? ['-'] : result.focus.blocked.map((item) => `- ${item.stem}`)), + '', + ...(result.query.criticalPath + ? [ + `${separator({ label: 'Critical Path', ctx })}`, + ...(result.focus.criticalPathReason === undefined + ? (result.focus.criticalPath.length === 0 + ? ['-'] + : result.focus.criticalPath.map((item) => `- ${item.stem}`)) + : [result.focus.criticalPathReason]), + '', + ] + : []), + `${separator({ label: 'Cycles', ctx })}`, + ...(result.cycles.length === 0 ? ['-'] : result.cycles.map((cycle) => `- ${cycle.join(' -> ')}`)), + '', + ].join('\n'); + } + + return [ + `${headerBox('METHOD Backlog Dependencies', { detail: result.root, ctx })}`, + '', + `${separator({ label: 'Summary', ctx })}`, + `items=${result.items.length} edges=${result.edges.length} ready=${result.ready.length} cycles=${result.cycles.length}`, + '', + `${separator({ label: 'Ready Items', ctx })}`, + ...(result.ready.length === 0 ? ['-'] : result.ready.map((item) => `- ${item.stem}`)), + '', + `${separator({ label: 'Edges', ctx })}`, + ...(result.edges.length === 0 ? ['-'] : result.edges.map((edge) => `- ${pathToStem(edge.blocker)} -> ${pathToStem(edge.blocked)}`)), + '', + ].join('\n'); +} + +function pathToStem(path: string): string { + const stem = path.split('/').at(-1) ?? path; + return stem.endsWith('.md') ? stem.slice(0, -3) : stem; +} diff --git a/src/cli.ts b/src/cli.ts index 5680759..7b39093 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,15 +6,15 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { alert, confirm } from '@flyingrobots/bijou'; import { createNodeContext } from '@flyingrobots/bijou-node'; import { parseCliArgs, usage } from './cli-args.js'; -import { renderStatus } from './cli-renderer.js'; +import { renderBacklogDependencies, renderBacklogQuery, renderNextWork, renderSignpostInit, renderSignpostStatus, renderStatus } from './cli-renderer.js'; import { initWorkspace, Workspace } from './index.js'; import { loadConfig } from './config.js'; import { createMcpServer } from './mcp.js'; import { GitHubAdapter } from './adapters/github.js'; -import { renderDoctorText, runDoctor } from './doctor.js'; +import { renderDoctorMigrateText, renderDoctorRepairText, renderDoctorText, runDoctor, runDoctorMigrate, runDoctorRepair } from './doctor.js'; +import { createBacklogFromCli, createInboxFromCli, describeBacklogDependenciesFromCli, editBacklogMetadataFromCli, moveBacklogFromCli, queryBacklogFromCli, retireBacklogFromCli } from './feedback-surface.js'; import { queryReviewState, renderReviewStateText, type ReviewStateQueryOptions, type ReviewStateResult } from './review-state.js'; -type Writer = Pick<NodeJS.WritableStream, 'write'>; -type ConfirmPrompt = (options: { title: string; defaultValue: boolean }) => Promise<boolean>; +type Writer = Pick<NodeJS.WritableStream, 'write'>; type ConfirmPrompt = (options: { title: string; defaultValue: boolean }) => Promise<boolean>; export interface RunCliOptions { cwd?: string; stdout?: Writer; @@ -34,10 +34,7 @@ export async function runCli( ?? ((promptOptions) => confirm({ title: promptOptions.title, defaultValue: promptOptions.defaultValue, ctx })); try { const parsed = parseCliArgs(argv); - if (parsed.command === 'help') { - stdout.write(`${usage(parsed.topic)}\n`); - return 0; - } + if (parsed.command === 'help') { stdout.write(`${usage(parsed.topic)}\n`); return 0; } if (parsed.command === 'init') { const target = resolve(root, parsed.path); const config = loadConfig(target); @@ -50,20 +47,42 @@ export async function runCli( } if (parsed.command === 'doctor') { const report = runDoctor(root); - if (parsed.json) { - stdout.write(`${JSON.stringify(report, null, 2)}\n`); - } else { - stdout.write(renderDoctorText(report)); - } + stdout.write(parsed.json ? `${JSON.stringify(report, null, 2)}\n` : renderDoctorText(report)); return report.status === 'error' ? 1 : 0; } + if (parsed.command === 'migrate') { + const result = runDoctorMigrate(root); + stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : renderDoctorMigrateText(result)); + return result.ok ? 0 : 1; + } + if (parsed.command === 'repair') { + const result = runDoctorRepair(root, parsed.mode); + stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : renderDoctorRepairText(result)); + return result.ok ? 0 : 1; + } + if (parsed.command === 'mcp') { + const server = createMcpServer({ reviewStateQuery: options.reviewStateQuery ?? queryReviewState }); + const transport = new StdioServerTransport(); + await server.connect(transport); + // Let it run indefinitely + return new Promise<number>(() => {}); + } const workspace = new Workspace(root); workspace.ensureInitialized(); if (parsed.command === 'inbox') { - const created = workspace.captureIdea(parsed.idea, parsed.legend, parsed.title); - stdout.write(`${alert(`Captured ${relative(root, created)}`, { variant: 'success', ctx })}\n`); + const created = createInboxFromCli(root, workspace, parsed); + stdout.write(parsed.json ? `${JSON.stringify(created, null, 2)}\n` : `${alert(`Captured ${created.path}`, { variant: 'success', ctx })}\n`); return 0; } + if (parsed.command === 'backlog-add') { const result = createBacklogFromCli(root, workspace, parsed); stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : `${alert(`Created ${result.path}`, { variant: 'success', ctx })}\n`); return 0; } + if (parsed.command === 'backlog-move') { const result = moveBacklogFromCli(workspace, parsed); stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : `${alert(`Moved ${result.sourcePath} to ${result.path}`, { variant: 'success', ctx })}\n`); return 0; } + if (parsed.command === 'backlog-edit') { const result = editBacklogMetadataFromCli(workspace, parsed); stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : `${alert(`Updated metadata on ${result.path} (${result.updatedFields.join(', ')})`, { variant: 'success', ctx })}\n`); return 0; } + if (parsed.command === 'backlog-list') { const result = queryBacklogFromCli(workspace, parsed); stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : renderBacklogQuery(result)); return 0; } + if (parsed.command === 'backlog-deps') { const result = describeBacklogDependenciesFromCli(workspace, parsed); stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : renderBacklogDependencies(result)); return 0; } + if (parsed.command === 'retire') { const result = retireBacklogFromCli(workspace, parsed); stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : `${alert(result.dryRun ? `Planned retirement of ${result.sourcePath} to ${result.graveyardPath}` : `Retired ${result.sourcePath} to ${result.graveyardPath}`, { variant: 'success', ctx })}\n`); return 0; } + if (parsed.command === 'signpost-status') { const result = workspace.signpostStatus(); stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : renderSignpostStatus(result)); return 0; } + if (parsed.command === 'signpost-init') { const result = await workspace.initSignpost(parsed.name); stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : renderSignpostInit(result)); return 0; } + if (parsed.command === 'next') { const result = workspace.nextWork({ lane: parsed.lane, legend: parsed.legend, priority: parsed.priority, keyword: parsed.keyword, owner: parsed.owner, includeBlocked: parsed.includeBlocked, limit: parsed.limit }); stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : renderNextWork(result)); return 0; } if (parsed.command === 'pull') { const cycle = workspace.pullItem(parsed.item); stdout.write(`${alert(`Pulled into ${cycle.name}`, { variant: 'success', ctx })}\n`); @@ -89,25 +108,10 @@ export async function runCli( } if (parsed.command === 'review-state') { const reviewStateQuery = options.reviewStateQuery ?? queryReviewState; - const result = await reviewStateQuery({ - cwd: root, - pr: parsed.pr, - currentBranch: parsed.currentBranch, - }); - if (parsed.json) { - stdout.write(`${JSON.stringify(result, null, 2)}\n`); - } else { - stdout.write(`${renderReviewStateText(result)}\n`); - } + const result = await reviewStateQuery({ cwd: root, pr: parsed.pr, currentBranch: parsed.currentBranch }); + stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : `${renderReviewStateText(result)}\n`); return 0; } - if (parsed.command === 'mcp') { - const server = createMcpServer({ reviewStateQuery: options.reviewStateQuery ?? queryReviewState }); - const transport = new StdioServerTransport(); - await server.connect(transport); - // Let it run indefinitely - return new Promise<number>(() => {}); - } if (parsed.command === 'sync') { if (parsed.adapter === 'github') { const token = workspace.config.github_token; @@ -170,6 +174,16 @@ export async function runCli( } return 0; } + if (parsed.adapter === 'refs') { + const result = workspace.syncRefs(); + for (const path of result.targets) { + stdout.write(`${alert(`Refreshed ${path}`, { variant: 'success', ctx })}\n`); + } + if (result.targets.length === 0) { + stdout.write('No generated reference targets found.\n'); + } + return 0; + } } const status = workspace.status(); stdout.write(renderStatus(status)); @@ -179,6 +193,5 @@ export async function runCli( stderr.write(`${alert(message, { variant: 'error', ctx })}\n`); return 1; } -} -export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> { return runCli(argv); } +} export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> { return runCli(argv); } if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === realpathSync(resolve(process.argv[1]))) { main().then((code) => { process.exitCode = code; }); } diff --git a/src/doctor.ts b/src/doctor.ts index a100456..84115ce 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -1,11 +1,12 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { basename, relative, resolve } from 'node:path'; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { basename, dirname, relative, resolve } from 'node:path'; import { headerBox, separator } from '@flyingrobots/bijou'; import { createNodeContext } from '@flyingrobots/bijou-node'; import { ConfigSchema, DEFAULT_PATHS, PathsSchema, type PathsConfig } from './config.js'; -import type { DoctorCheck, DoctorCheckId, DoctorIssue, DoctorReport, DoctorSeverity, DoctorStatus } from './domain.js'; -import { LANES } from './domain.js'; +import type { DoctorCheck, DoctorCheckId, DoctorIssue, DoctorRepairKind, DoctorReport, DoctorSeverity, DoctorStatus } from './domain.js'; +import { isLaneName } from './domain.js'; +import { workspaceScaffold } from './index.js'; import { parse as parseYaml } from 'yaml'; interface ConfigInspection { @@ -21,6 +22,35 @@ const DOCTOR_CHECKS: readonly DoctorCheckId[] = [ 'backlog', ] as const; +export type DoctorRepairMode = 'plan' | 'apply'; + +export interface DoctorRepairAction { + issueCode: string; + kind: DoctorRepairKind; + targetPath: string; + status: 'planned' | 'applied' | 'skipped'; + reason?: string; +} + +export interface DoctorRepairResult { + root: string; + mode: DoctorRepairMode; + ok: boolean; + selectedIssues: DoctorIssue[]; + repairs: DoctorRepairAction[]; + touchedPaths: string[]; + unresolvedIssues: DoctorIssue[]; +} + +export interface DoctorMigrateResult { + root: string; + ok: boolean; + changed: boolean; + initialReport: DoctorReport; + repair: DoctorRepairResult; + finalReport: DoctorReport; +} + export function runDoctor(root: string): DoctorReport { const configInspection = inspectConfig(root); const structureIssues = configInspection.paths === null ? [] : inspectStructure(root, configInspection.paths); @@ -79,6 +109,148 @@ export function renderDoctorText(report: DoctorReport): string { ].join('\n'); } +export function runDoctorRepair(root: string, mode: DoctorRepairMode): DoctorRepairResult { + const initialReport = runDoctor(root); + const selectedIssues = initialReport.issues.filter((issue) => issue.repair !== undefined); + + if (mode === 'plan') { + return { + root, + mode, + ok: true, + selectedIssues, + repairs: selectedIssues.map((issue) => ({ + issueCode: issue.code, + kind: issue.repair!.kind, + targetPath: issue.repair!.targetPath, + status: 'planned', + })), + touchedPaths: [], + unresolvedIssues: initialReport.issues, + }; + } + + const touchedPaths = new Set<string>(); + const repairs = selectedIssues.map((issue) => { + const repair = issue.repair!; + const outcome = applyRepair(root, issue); + if (outcome.touchedPath !== undefined) { + touchedPaths.add(outcome.touchedPath); + } + return { + issueCode: issue.code, + kind: repair.kind, + targetPath: repair.targetPath, + status: outcome.status, + reason: outcome.reason, + } satisfies DoctorRepairAction; + }); + const unresolvedIssues = runDoctor(root).issues; + + return { + root, + mode, + ok: repairs.every((repair) => repair.status !== 'skipped'), + selectedIssues, + repairs, + touchedPaths: [...touchedPaths].sort((left, right) => left.localeCompare(right)), + unresolvedIssues, + }; +} + +export function runDoctorMigrate(root: string): DoctorMigrateResult { + const initialReport = runDoctor(root); + const hasRepairableIssues = initialReport.issues.some((issue) => issue.repair !== undefined); + const repair = hasRepairableIssues + ? runDoctorRepair(root, 'apply') + : { + root, + mode: 'apply' as const, + ok: true, + selectedIssues: [], + repairs: [], + touchedPaths: [], + unresolvedIssues: initialReport.issues, + }; + const finalReport = runDoctor(root); + + return { + root, + ok: repair.ok && finalReport.status !== 'error', + changed: repair.touchedPaths.length > 0, + initialReport, + repair, + finalReport, + }; +} + +export function renderDoctorRepairText(result: DoctorRepairResult): string { + const title = result.mode === 'plan' ? 'METHOD Repair Plan' : 'METHOD Repair Apply'; + const ctx = createNodeContext(); + const repairLines = result.repairs.length === 0 + ? ['No repairable issues selected.'] + : result.repairs.map((repair) => { + const suffix = repair.reason === undefined ? '' : ` (${repair.reason})`; + return `- [${repair.status}] ${repair.kind}: ${repair.targetPath}${suffix}`; + }); + const unresolvedLines = result.unresolvedIssues.length === 0 + ? ['No unresolved issues remain.'] + : result.unresolvedIssues.map((issue) => { + const target = issue.path === undefined ? '' : ` (${issue.path})`; + return `- [${issue.severity}] ${issue.code}${target}`; + }); + const touched = result.touchedPaths.length === 0 ? '-' : result.touchedPaths.join(', '); + + return [ + `${headerBox(title, { detail: result.root, ctx })}`, + '', + `Selected issues: ${result.selectedIssues.length}`, + `Touched paths: ${touched}`, + `Command ok: ${result.ok ? 'yes' : 'no'}`, + '', + `${separator({ label: 'Repairs', ctx })}`, + ...repairLines, + '', + `${separator({ label: 'Unresolved', ctx })}`, + ...unresolvedLines, + '', + ].join('\n'); +} + +export function renderDoctorMigrateText(result: DoctorMigrateResult): string { + const ctx = createNodeContext(); + const touched = result.repair.touchedPaths.length === 0 ? '-' : result.repair.touchedPaths.join(', '); + const repairLines = result.repair.repairs.length === 0 + ? ['No bounded repairs were applied.'] + : result.repair.repairs.map((repair) => { + const suffix = repair.reason === undefined ? '' : ` (${repair.reason})`; + return `- [${repair.status}] ${repair.kind}: ${repair.targetPath}${suffix}`; + }); + const finalIssueLines = result.finalReport.issues.length === 0 + ? ['No issues remain.'] + : result.finalReport.issues.map((issue) => { + const target = issue.path === undefined ? '' : ` (${issue.path})`; + return `- [${issue.severity}] ${issue.code}${target}`; + }); + + return [ + `${headerBox('METHOD Migrate', { detail: result.root, ctx })}`, + '', + `Before: ${result.initialReport.status} (${result.initialReport.counts.errors} errors, ${result.initialReport.counts.warnings} warnings)`, + `After: ${result.finalReport.status} (${result.finalReport.counts.errors} errors, ${result.finalReport.counts.warnings} warnings)`, + `Changed: ${result.changed ? 'yes' : 'no'}`, + `Command ok: ${result.ok ? 'yes' : 'no'}`, + `Touched paths: ${touched}`, + '', + `${separator({ label: 'Repairs', ctx })}`, + ...repairLines, + '', + `${separator({ label: 'Remaining Issues', ctx })}`, + ...finalIssueLines, + '', + ].join('\n'); +} + function inspectConfig(root: string): ConfigInspection { const configPath = resolve(root, '.method.json'); if (!existsSync(configPath)) { @@ -174,6 +346,7 @@ function inspectStructure(root: string, paths: PathsConfig): DoctorIssue[] { 'A required METHOD directory is missing.', relative(root, directory), `Run \`method init\` in \`${root}\` or recreate \`${relative(root, directory)}\`.`, + { kind: 'create-directory', targetPath: relative(root, directory) }, )); } @@ -199,9 +372,34 @@ function inspectStructure(root: string, paths: PathsConfig): DoctorIssue[] { 'A required METHOD file is missing.', relative(root, file), `Run \`method init\` in \`${root}\` or restore \`${relative(root, file)}\`.`, + { kind: 'restore-file', targetPath: relative(root, file) }, )); } + // Detect legacy nested design doc directories + const designDir = resolve(root, paths.design); + if (isDirectoryPath(designDir)) { + for (const entry of readdirSync(designDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue; + } + const subdir = resolve(designDir, entry.name); + const mdFiles = readdirSync(subdir).filter((name) => name.endsWith('.md')); + if (mdFiles.length > 0) { + const relDir = relative(root, subdir); + issues.push(createIssue( + 'legacy-design-layout', + 'structure', + 'warning', + 'Design doc lives in a subdirectory instead of as a flat file under the design root.', + relDir, + `Run \`method doctor --repair\` to flatten \`${relDir}/\` into \`${paths.design}/<name>.md\`.`, + { kind: 'flatten-design-doc', targetPath: relDir }, + )); + } + } + } + return issues; } @@ -231,6 +429,7 @@ function inspectFrontmatter(root: string, paths: PathsConfig): DoctorIssue[] { 'METHOD packet markdown is missing a YAML frontmatter block.', relativePath, `Add a top-of-file \`---\` frontmatter block to \`${relativePath}\`.`, + { kind: 'frontmatter-stub', targetPath: relativePath }, )); continue; } @@ -379,8 +578,9 @@ function inspectBacklog(root: string, paths: PathsConfig): DoctorIssue[] { const issues: DoctorIssue[] = []; for (const file of collectMarkdownFiles(backlogRoot)) { const relativeToBacklog = relative(backlogRoot, file); - const [firstSegment] = relativeToBacklog.split(/[\\/]/u); - if (firstSegment !== undefined && (LANES as readonly string[]).includes(firstSegment)) { + const segments = relativeToBacklog.split(/[\\/]/u).filter((segment) => segment.length > 0); + const [firstSegment] = segments; + if (segments.length > 1 && firstSegment !== undefined && isLaneName(firstSegment)) { continue; } @@ -443,6 +643,7 @@ function createIssue( message: string, path: string | undefined, fix: string, + repair?: DoctorIssue['repair'], ): DoctorIssue { return { code, @@ -451,9 +652,149 @@ function createIssue( message, path, fix, + repair, }; } +function applyRepair( + root: string, + issue: DoctorIssue, +): { status: 'applied' | 'skipped'; touchedPath?: string; reason?: string } { + const repair = issue.repair; + if (repair === undefined) { + return { status: 'skipped', reason: 'no-repair-hint' }; + } + + const target = resolve(root, repair.targetPath); + if (repair.kind === 'create-directory') { + if (isDirectoryPath(target)) { + return { status: 'skipped', reason: 'already-exists' }; + } + if (existsSync(target)) { + return { status: 'skipped', reason: 'path-exists-as-file' }; + } + mkdirSync(target, { recursive: true }); + return { status: 'applied', touchedPath: repair.targetPath }; + } + + if (repair.kind === 'restore-file') { + if (isFilePath(target)) { + return { status: 'skipped', reason: 'already-exists' }; + } + if (existsSync(target)) { + return { status: 'skipped', reason: 'path-exists-as-directory' }; + } + const content = scaffoldContentForPath(root, repair.targetPath); + if (content === undefined) { + return { status: 'skipped', reason: 'no-scaffold-template' }; + } + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content, 'utf8'); + return { status: 'applied', touchedPath: repair.targetPath }; + } + + if (repair.kind === 'frontmatter-stub') { + if (!isFilePath(target)) { + return { status: 'skipped', reason: 'file-missing' }; + } + const raw = readFileSync(target, 'utf8'); + if (/^\uFEFF?---\r?\n/u.test(raw)) { + return { status: 'skipped', reason: 'frontmatter-present' }; + } + const normalized = raw.replace(/^\uFEFF/u, ''); + const title = inferFrontmatterTitle(repair.targetPath, normalized); + const frontmatter = ['---', `title: "${title.replace(/"/gu, '\\"')}"`, '---'].join('\n'); + writeFileSync(target, normalized.length === 0 ? `${frontmatter}\n` : `${frontmatter}\n\n${normalized}`, 'utf8'); + return { status: 'applied', touchedPath: repair.targetPath }; + } + + if (repair.kind === 'flatten-design-doc') { + if (!isDirectoryPath(target)) { + return { status: 'skipped', reason: 'directory-missing' }; + } + const mdFiles = readdirSync(target).filter((name) => name.endsWith('.md')); + if (mdFiles.length === 0) { + return { status: 'skipped', reason: 'no-md-files' }; + } + const sourceFile = resolve(target, mdFiles[0]); + const raw = readFileSync(sourceFile, 'utf8'); + + // Derive the new cycle name from frontmatter legend + slug, or fall back to dir name + const dirName = basename(target); + const legacyMatch = /^(\d{4})-(.+)$/u.exec(dirName); + const slug = legacyMatch !== null ? legacyMatch[2] : dirName; + const legendMatch = /^legend:\s*"?([A-Z][A-Z0-9]*)"?\s*$/mu.exec(raw); + const legend = legendMatch !== null ? legendMatch[1] : undefined; + const cycleName = legend !== undefined ? `${legend}_${slug}` : slug; + + const designRoot = dirname(target); + const flatPath = resolve(designRoot, `${cycleName}.md`); + if (existsSync(flatPath)) { + return { status: 'skipped', reason: 'flat-target-exists' }; + } + + // Update the cycle frontmatter field if present + const updated = raw.replace( + /^(cycle:\s*)"[^"]*"\s*$/mu, + `$1"${cycleName}"`, + ); + writeFileSync(flatPath, updated, 'utf8'); + + // Remove the old directory (all files should be the single md) + rmSync(target, { recursive: true }); + + // Also rename the corresponding retro directory if it exists + const configInspection = inspectConfig(root); + const paths = configInspection.paths ?? DEFAULT_PATHS; + const retroDir = resolve(root, paths.retro, dirName); + if (isDirectoryPath(retroDir) && dirName !== cycleName) { + const newRetroDir = resolve(root, paths.retro, cycleName); + if (!existsSync(newRetroDir)) { + renameSync(retroDir, newRetroDir); + // Rename the retro doc inside to match the new cycle name + const oldRetroDoc = resolve(newRetroDir, `${slug}.md`); + const newRetroDoc = resolve(newRetroDir, `${cycleName}.md`); + if (isFilePath(oldRetroDoc) && !existsSync(newRetroDoc)) { + const retroRaw = readFileSync(oldRetroDoc, 'utf8'); + const retroUpdated = retroRaw + .replace(/^(cycle:\s*)"[^"]*"\s*$/mu, `$1"${cycleName}"`) + .replace(/^(design_doc:\s*)"[^"]*"\s*$/mu, `$1"${paths.design}/${cycleName}.md"`); + writeFileSync(newRetroDoc, retroUpdated, 'utf8'); + if (oldRetroDoc !== newRetroDoc) { + rmSync(oldRetroDoc); + } + } + } + } + + return { status: 'applied', touchedPath: relative(root, flatPath) }; + } + + return { status: 'skipped', reason: 'unsupported-repair-kind' }; +} + +function scaffoldContentForPath(root: string, targetPath: string): string | undefined { + const configInspection = inspectConfig(root); + const scaffold = workspaceScaffold(root, configInspection.paths ?? DEFAULT_PATHS); + return scaffold.files.get(resolve(root, targetPath)); +} + +function inferFrontmatterTitle(targetPath: string, raw: string): string { + const heading = /^\s*#\s+(.+)$/mu.exec(raw)?.[1]?.trim(); + if (heading !== undefined && heading.length > 0) { + return heading; + } + + const stem = basename(targetPath, '.md') + .replace(/^[A-Z][A-Z0-9]*_/u, '') + .replace(/^\d{4}-\d{2}-\d{2}-/u, ''); + return stem + .split(/[-_]+/u) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + function collectMarkdownFiles(root: string, maxDepth = 10): string[] { if (maxDepth <= 0 || !isDirectoryPath(root)) { return []; diff --git a/src/domain.ts b/src/domain.ts index 781fe3b..0b69c78 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -1,8 +1,68 @@ import { z } from 'zod'; -export const LANES = ['inbox', 'asap', 'up-next', 'cool-ideas', 'bad-code'] as const; -export const LaneSchema = z.enum([...LANES, 'root']); +export const LANES = ['inbox', 'asap', 'bad-code', 'cool-ideas'] as const; +export type CanonicalLane = typeof LANES[number]; +const CANONICAL_LANE_SET = new Set<string>(LANES); +const LANE_PATTERN = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; +const RELEASE_LANE_PATTERN = /^v(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$/u; + +export const LaneSchema = z.string().regex(LANE_PATTERN); +export const BacklogLaneSchema = z.union([LaneSchema, z.literal('root')]); export type Lane = z.infer<typeof LaneSchema>; +export type BacklogLane = z.infer<typeof BacklogLaneSchema>; + +export function isCanonicalLane(value: string): value is CanonicalLane { + return CANONICAL_LANE_SET.has(value); +} + +export function isLaneName(value: string): value is Lane { + return LANE_PATTERN.test(value); +} + +export function isReleaseLane(value: string): boolean { + return RELEASE_LANE_PATTERN.test(value); +} + +export function orderedBacklogLaneNames(lanes: Iterable<string>): string[] { + const unique = new Set(lanes); + const leadingCanonical = ['inbox', 'asap'].filter((lane): lane is CanonicalLane => unique.has(lane)); + const legacyUpNext = unique.has('up-next') ? ['up-next'] : []; + const releaseLanes = [...unique] + .filter((lane) => isReleaseLane(lane)) + .sort(compareReleaseLaneNames); + const trailingCanonical = ['bad-code', 'cool-ideas'].filter((lane): lane is CanonicalLane => unique.has(lane)); + const custom = [...unique] + .filter((lane) => lane !== 'root' && !isCanonicalLane(lane) && lane !== 'up-next' && !isReleaseLane(lane)) + .sort((left, right) => left.localeCompare(right)); + + return [...leadingCanonical, ...legacyUpNext, ...releaseLanes, ...trailingCanonical, ...custom, ...(unique.has('root') ? ['root'] : [])]; +} + +function compareReleaseLaneNames(left: string, right: string): number { + const leftMatch = RELEASE_LANE_PATTERN.exec(left); + const rightMatch = RELEASE_LANE_PATTERN.exec(right); + if (leftMatch?.groups === undefined || rightMatch?.groups === undefined) { + return left.localeCompare(right); + } + + const leftParts = [ + Number.parseInt(leftMatch.groups.major, 10), + Number.parseInt(leftMatch.groups.minor, 10), + Number.parseInt(leftMatch.groups.patch, 10), + ]; + const rightParts = [ + Number.parseInt(rightMatch.groups.major, 10), + Number.parseInt(rightMatch.groups.minor, 10), + Number.parseInt(rightMatch.groups.patch, 10), + ]; + + for (let index = 0; index < leftParts.length; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index]! - rightParts[index]!; + } + } + return left.localeCompare(right); +} export const OutcomeSchema = z.enum(['hill-met', 'partial', 'not-met']); @@ -10,7 +70,6 @@ export type Outcome = z.infer<typeof OutcomeSchema>; export const CycleSchema = z.object({ name: z.string(), - number: z.number().int().min(1), slug: z.string(), designDoc: z.string(), retroDoc: z.string(), @@ -19,13 +78,52 @@ export type Cycle = z.infer<typeof CycleSchema>; export const BacklogItemSchema = z.object({ stem: z.string(), - lane: LaneSchema, + lane: BacklogLaneSchema, path: z.string(), legend: z.string().optional(), slug: z.string(), }); export type BacklogItem = z.infer<typeof BacklogItemSchema>; +export const BacklogQueryItemSchema = BacklogItemSchema.extend({ + title: z.string().optional(), + owner: z.string().optional(), + priority: z.string().optional(), + keywords: z.array(z.string()), + blockedBy: z.array(z.string()), + blocks: z.array(z.string()), + hasAcceptanceCriteria: z.boolean(), +}); +export type BacklogQueryItem = z.infer<typeof BacklogQueryItemSchema>; + +export const BacklogQuerySortSchema = z.enum(['lane', 'priority', 'path']); +export type BacklogQuerySort = z.infer<typeof BacklogQuerySortSchema>; + +export const BacklogQueryFiltersSchema = z.object({ + lane: BacklogLaneSchema.optional(), + legend: z.string().optional(), + priority: z.string().optional(), + keyword: z.string().optional(), + owner: z.string().optional(), + ready: z.boolean().optional(), + hasAcceptanceCriteria: z.boolean().optional(), + blockedBy: z.string().optional(), + blocks: z.string().optional(), + sort: BacklogQuerySortSchema, + limit: z.number().int().min(1).max(100), +}); +export type BacklogQueryFilters = z.infer<typeof BacklogQueryFiltersSchema>; + +export const BacklogQueryResultSchema = z.object({ + root: z.string(), + filters: BacklogQueryFiltersSchema, + items: z.array(BacklogQueryItemSchema), + totalCount: z.number().int().nonnegative(), + returnedCount: z.number().int().nonnegative(), + truncated: z.boolean(), +}); +export type BacklogQueryResult = z.infer<typeof BacklogQueryResultSchema>; + export const LegendHealthSchema = z.object({ legend: z.string(), backlog: z.number().int().nonnegative(), @@ -35,12 +133,49 @@ export type LegendHealth = z.infer<typeof LegendHealthSchema>; export const WorkspaceStatusSchema = z.object({ root: z.string(), - backlog: z.record(LaneSchema, z.array(BacklogItemSchema)), + backlog: z.record(z.string(), z.array(BacklogItemSchema)), activeCycles: z.array(CycleSchema), legendHealth: z.array(LegendHealthSchema), }); export type WorkspaceStatus = z.infer<typeof WorkspaceStatusSchema>; +export const NextWorkSignalSchema = z.object({ + type: z.string(), + value: z.union([z.string(), z.number(), z.boolean()]), + source: z.string(), +}); +export type NextWorkSignal = z.infer<typeof NextWorkSignalSchema>; + +export const NextWorkScoreBandSchema = z.enum(['highest', 'strong', 'worth-considering']); +export type NextWorkScoreBand = z.infer<typeof NextWorkScoreBandSchema>; + +export const NextWorkRecommendationSchema = z.object({ + path: z.string(), + title: z.string(), + lane: BacklogLaneSchema, + priority: z.string().optional(), + scoreBand: NextWorkScoreBandSchema, + whyNow: z.array(z.string()), + signals: z.array(NextWorkSignalSchema), +}); +export type NextWorkRecommendation = z.infer<typeof NextWorkRecommendationSchema>; + +export const NextWorkSummarySchema = z.object({ + active_cycle_count: z.number().int().nonnegative(), + lane_counts: z.record(z.string(), z.number().int().nonnegative()), + bearing_priority: z.string().optional(), + bearing_concerns: z.array(z.string()), +}); +export type NextWorkSummary = z.infer<typeof NextWorkSummarySchema>; + +export const NextWorkResultSchema = z.object({ + generated_at: z.string(), + summary: NextWorkSummarySchema, + recommendations: z.array(NextWorkRecommendationSchema), + selection_notes: z.array(z.string()), +}); +export type NextWorkResult = z.infer<typeof NextWorkResultSchema>; + export const DoctorStatusSchema = z.enum(['ok', 'warn', 'error']); export type DoctorStatus = z.infer<typeof DoctorStatusSchema>; @@ -63,6 +198,20 @@ export const DoctorCheckSchema = z.object({ }); export type DoctorCheck = z.infer<typeof DoctorCheckSchema>; +export const DoctorRepairKindSchema = z.enum([ + 'create-directory', + 'restore-file', + 'frontmatter-stub', + 'flatten-design-doc', +]); +export type DoctorRepairKind = z.infer<typeof DoctorRepairKindSchema>; + +export const DoctorRepairHintSchema = z.object({ + kind: DoctorRepairKindSchema, + targetPath: z.string(), +}); +export type DoctorRepairHint = z.infer<typeof DoctorRepairHintSchema>; + export const DoctorIssueSchema = z.object({ code: z.string(), check: DoctorCheckIdSchema, @@ -70,6 +219,7 @@ export const DoctorIssueSchema = z.object({ message: z.string(), path: z.string().optional(), fix: z.string(), + repair: DoctorRepairHintSchema.optional(), }); export type DoctorIssue = z.infer<typeof DoctorIssueSchema>; diff --git a/src/feedback-surface.ts b/src/feedback-surface.ts new file mode 100644 index 0000000..d6ffdb4 --- /dev/null +++ b/src/feedback-surface.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { ParsedCommand } from './cli-args.js'; +import type { Workspace } from './index.js'; + +type BacklogAddCommand = Extract<ParsedCommand, { command: 'backlog-add' }>; +type BacklogMoveCommand = Extract<ParsedCommand, { command: 'backlog-move' }>; +type BacklogEditCommand = Extract<ParsedCommand, { command: 'backlog-edit' }>; +type BacklogListCommand = Extract<ParsedCommand, { command: 'backlog-list' }>; +type BacklogDepsCommand = Extract<ParsedCommand, { command: 'backlog-deps' }>; +type RetireCommand = Extract<ParsedCommand, { command: 'retire' }>; +type InboxCommand = Extract<ParsedCommand, { command: 'inbox' }>; + +export function createInboxFromCli(root: string, workspace: Workspace, parsed: InboxCommand) { + const body = parsed.bodyFile === undefined ? parsed.idea : readFileSync(resolve(root, parsed.bodyFile), 'utf8'); + const created = workspace.captureIdeaWithMetadata(parsed.idea, parsed.legend, parsed.title, { + body, + source: parsed.source, + capturedAt: parsed.capturedAt, + }); + return workspace.describeBacklogPath(workspace.resolveRepoPath(created)); +} + +export function createBacklogFromCli(root: string, workspace: Workspace, parsed: BacklogAddCommand) { + const body = parsed.bodyFile === undefined ? undefined : readFileSync(resolve(root, parsed.bodyFile), 'utf8'); + const created = workspace.createBacklogItem({ + lane: parsed.lane, + title: parsed.title, + legend: parsed.legend, + body, + }); + return workspace.describeBacklogPath(workspace.resolveRepoPath(created)); +} + +export function moveBacklogFromCli(workspace: Workspace, parsed: BacklogMoveCommand) { + const sourcePath = workspace.resolveBacklogPath(parsed.item); + const path = workspace.moveBacklogItem(sourcePath, parsed.to); + return { sourcePath, ...workspace.describeBacklogPath(path) }; +} + +export function editBacklogMetadataFromCli(workspace: Workspace, parsed: BacklogEditCommand) { + return workspace.editBacklogMetadata(parsed.item, { + owner: parsed.owner, + clearOwner: parsed.clearOwner, + priority: parsed.priority, + clearPriority: parsed.clearPriority, + keywords: parsed.keywords, + clearKeywords: parsed.clearKeywords, + blockedBy: parsed.blockedBy, + clearBlockedBy: parsed.clearBlockedBy, + blocks: parsed.blocks, + clearBlocks: parsed.clearBlocks, + }); +} + +export function queryBacklogFromCli(workspace: Workspace, parsed: BacklogListCommand) { + return workspace.backlogQuery({ + lane: parsed.lane, + legend: parsed.legend, + priority: parsed.priority, + keyword: parsed.keyword, + owner: parsed.owner, + ready: parsed.ready, + hasAcceptanceCriteria: parsed.hasAcceptanceCriteria, + blockedBy: parsed.blockedBy, + blocks: parsed.blocks, + sort: parsed.sort, + limit: parsed.limit, + }); +} + +export function describeBacklogDependenciesFromCli(workspace: Workspace, parsed: BacklogDepsCommand) { + return workspace.backlogDependencies({ + item: parsed.item, + readyOnly: parsed.readyOnly, + criticalPath: parsed.criticalPath, + }); +} + +export function retireBacklogFromCli(workspace: Workspace, parsed: RetireCommand) { + if (!parsed.dryRun && !parsed.yes) { + throw new Error('`method retire` requires `--yes` unless `--dry-run` is set.'); + } + return workspace.retireBacklogItem(parsed.item, parsed.reason, parsed.replacement, { dryRun: parsed.dryRun }); +} diff --git a/src/frontmatter.ts b/src/frontmatter.ts index 55e75d6..43ea2f8 100644 --- a/src/frontmatter.ts +++ b/src/frontmatter.ts @@ -3,9 +3,10 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; const FM_DELIMITER = '---\n'; const FM_END = '\n---\n'; +export type TypedFrontmatter = Record<string, unknown>; interface ParsedDoc { - frontmatter: Record<string, string>; + frontmatter: TypedFrontmatter; body: string; raw: string; } @@ -29,20 +30,19 @@ function parseDoc(path: string): ParsedDoc { const yamlBlock = raw.slice(FM_DELIMITER.length, end); const body = raw.slice(end + FM_END.length); - let frontmatter: Record<string, string> = {}; + let frontmatter: TypedFrontmatter = {}; try { const parsed = parseYaml(yamlBlock); - if (parsed !== null && typeof parsed === 'object') { - for (const [key, value] of Object.entries(parsed)) { - frontmatter[key] = String(value); - } + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + frontmatter = { ...(parsed as TypedFrontmatter) }; } } catch { // Malformed YAML — return empty frontmatter, preserve raw frontmatter = {}; } - if ((frontmatter.title ?? '').trim().length === 0) { + const currentTitle = typeof frontmatter.title === 'string' ? frontmatter.title.trim() : ''; + if (currentTitle.length === 0) { const title = deriveLegacyTitle(raw); if (title !== undefined) { frontmatter.title = title; @@ -52,18 +52,32 @@ function parseDoc(path: string): ParsedDoc { return { frontmatter, body, raw }; } -function serializeDoc(frontmatter: Record<string, string>, body: string): string { +function serializeDoc(frontmatter: TypedFrontmatter, body: string): string { const yamlBlock = stringifyYaml(frontmatter, { lineWidth: 0 }).trim(); return `---\n${yamlBlock}\n---\n${body}`; } +export function readTypedFrontmatter(path: string): TypedFrontmatter { + return { ...parseDoc(path).frontmatter }; +} + export function readFrontmatter(path: string): Record<string, string> { - return parseDoc(path).frontmatter; + const frontmatter = parseDoc(path).frontmatter; + return Object.fromEntries( + Object.entries(frontmatter).map(([key, value]) => [key, stringifyFrontmatterValue(value)]), + ); } -export function updateFrontmatter(path: string, updates: Record<string, string>): void { +export function updateTypedFrontmatter(path: string, updates: TypedFrontmatter): void { const doc = parseDoc(path); - const merged = { ...doc.frontmatter, ...updates }; + const merged = { ...doc.frontmatter }; + for (const [key, value] of Object.entries(updates)) { + if (value === undefined) { + delete merged[key]; + continue; + } + merged[key] = value; + } if (doc.raw.startsWith(FM_DELIMITER)) { const end = doc.raw.indexOf(FM_END, FM_DELIMITER.length); @@ -78,6 +92,10 @@ export function updateFrontmatter(path: string, updates: Record<string, string>) writeFileSync(path, serializeDoc(merged, `\n${doc.raw}`), 'utf8'); } +export function updateFrontmatter(path: string, updates: Record<string, string>): void { + updateTypedFrontmatter(path, updates); +} + export function readBody(path: string): string { const doc = parseDoc(path); const lines = doc.body.trim().split(/\r?\n/u); @@ -125,3 +143,10 @@ function deriveLegacyTitle(raw: string): string | undefined { return undefined; } + +function stringifyFrontmatterValue(value: unknown): string { + if (Array.isArray(value)) { + return value.map((entry) => String(entry)).join(','); + } + return String(value); +} diff --git a/src/generate.ts b/src/generate.ts index 81c9108..2e4b332 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -1,10 +1,144 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; import { CLI_TOPICS, usage } from './cli-args.js'; import { MCP_TOOLS } from './mcp.js'; export type GeneratorFn = () => string; export type GeneratorRegistry = Record<string, GeneratorFn>; +export type SignpostKind = 'Hand-authored' | 'Hybrid' | 'Generated'; +export type SignpostInitStrategy = 'reference-doc' | 'bearing'; +export interface SignpostSpec { + name: string; + path: string; + kind: SignpostKind; + description: string; + initStrategy?: SignpostInitStrategy; +} + +export const SIGNPOST_SPECS: readonly SignpostSpec[] = [ + { name: 'README', path: 'README.md', kind: 'Hand-authored', description: 'Core doctrine and filesystem shape.' }, + { name: 'ARCHITECTURE', path: 'ARCHITECTURE.md', kind: 'Hybrid', description: 'How the source code is organized.', initStrategy: 'reference-doc' }, + { name: 'BEARING', path: 'docs/BEARING.md', kind: 'Generated', description: 'Current direction and recent ships.', initStrategy: 'bearing' }, + { name: 'VISION', path: 'docs/VISION.md', kind: 'Generated', description: 'Bounded executive synthesis.' }, + { name: 'CLI', path: 'docs/CLI.md', kind: 'Hybrid', description: 'CLI command reference.', initStrategy: 'reference-doc' }, + { name: 'MCP', path: 'docs/MCP.md', kind: 'Hybrid', description: 'MCP tool reference.', initStrategy: 'reference-doc' }, + { name: 'GUIDE', path: 'docs/GUIDE.md', kind: 'Hybrid', description: 'Operator advice with generated sections.', initStrategy: 'reference-doc' }, +] as const; + +export const REFERENCE_DOC_TARGETS = [ + 'ARCHITECTURE.md', + 'docs/CLI.md', + 'docs/MCP.md', + 'docs/GUIDE.md', +] as const; + +const REFERENCE_DOC_TEMPLATES: Record<(typeof REFERENCE_DOC_TARGETS)[number], string> = { + 'ARCHITECTURE.md': [ + '---', + 'title: "Architecture"', + 'generator: "method sync refs"', + 'provenance_level: artifact_history', + '---', + '', + '# Architecture', + '', + 'METHOD is a TypeScript CLI and library that implements the METHOD', + 'development workflow.', + '', + '## Source Layout', + '', + '<!-- generate:source-layout -->', + '<!-- /generate -->', + '', + '## Key Modules', + '', + '### `index.ts` — Workspace', + '', + 'The `Workspace` class owns backlog operations, cycle lifecycle,', + 'reference sync, ship sync, and status.', + '', + '### `cli.ts` — CLI Entry Point', + '', + 'Dispatches commands parsed by `cli-args.ts`.', + '', + '### `mcp.ts` — MCP Server', + '', + 'Exposes the same workspace operations through MCP tools.', + '', + ].join('\n'), + 'docs/CLI.md': [ + '---', + 'title: CLI Reference', + 'generator: method sync refs', + 'provenance_level: artifact_history', + '---', + '', + '# CLI Reference', + '', + 'The `method` command is the primary interface for METHOD workspace', + 'operations. Run `method help` for a quick summary or', + '`method help <command>` for command-specific usage.', + '', + '## Commands', + '', + '<!-- generate:cli-commands -->', + '<!-- /generate -->', + '', + '## Configuration', + '', + 'The CLI reads `.method.json` from the workspace root for:', + '', + '- `paths` — custom directory layout (see `ARCHITECTURE.md`)', + '- `github_token` / `github_repo` — GitHub adapter credentials', + '- Environment overrides: `GITHUB_TOKEN`, `GITHUB_REPO`', + '', + ].join('\n'), + 'docs/MCP.md': [ + '---', + 'title: MCP Reference', + 'generator: method sync refs', + 'provenance_level: artifact_history', + '---', + '', + '# MCP Reference', + '', + 'METHOD exposes its workspace operations through a Model Context', + 'Protocol (MCP) server. Start it with `method mcp`.', + '', + '## Workspace Parameter', + '', + 'Every tool requires a `workspace` parameter: the absolute path to the', + 'METHOD workspace root directory.', + '', + '## Tools', + '', + '<!-- generate:mcp-tools -->', + '<!-- /generate -->', + '', + '## Error Handling', + '', + 'All tools return a human-readable text message in `content`.', + 'Machine-readable callers should consume `structuredContent`.', + '', + ].join('\n'), + 'docs/GUIDE.md': [ + '---', + 'title: "Guide"', + 'generator: "method sync refs"', + 'provenance_level: artifact_history', + '---', + '', + '# Guide', + '', + 'This document holds practical advice for working in a METHOD repo.', + '', + '## Signposts', + '', + '<!-- generate:signpost-inventory -->', + '<!-- /generate -->', + '', + ].join('\n'), +}; const MARKER_PATTERN = /<!-- generate:(\S+) -->\n[\s\S]*?<!-- \/generate -->/gu; @@ -33,6 +167,57 @@ export function createGenerators(root: string): GeneratorRegistry { }; } +export function generateReferenceDocs(root: string): { targets: string[]; updated: string[] } { + const generators = createGenerators(root); + const targets: string[] = []; + const updated: string[] = []; + + for (const signpost of REFERENCE_DOC_TARGETS) { + const signpostPath = resolve(root, signpost); + if (!existsSync(signpostPath)) { + mkdirSync(dirname(signpostPath), { recursive: true }); + writeFileSync(signpostPath, REFERENCE_DOC_TEMPLATES[signpost], 'utf8'); + } + + targets.push(signpost); + const before = readFileSync(signpostPath, 'utf8'); + const after = replaceGeneratedSections(before, generators); + if (after !== before) { + writeFileSync(signpostPath, after, 'utf8'); + updated.push(signpost); + } + } + + return { targets, updated }; +} + +export function initializeReferenceDoc(root: string, target: (typeof REFERENCE_DOC_TARGETS)[number]): { path: string; initialized: boolean } { + const signpostPath = resolve(root, target); + if (existsSync(signpostPath)) { + return { path: target, initialized: false }; + } + + mkdirSync(dirname(signpostPath), { recursive: true }); + const content = replaceGeneratedSections(REFERENCE_DOC_TEMPLATES[target], createGenerators(root)); + writeFileSync(signpostPath, content, 'utf8'); + return { path: target, initialized: true }; +} + +export function resolveSignpostSpec(query: string): SignpostSpec | undefined { + const normalized = query.trim(); + if (normalized.length === 0) { + return undefined; + } + + const upper = normalized.toUpperCase(); + return SIGNPOST_SPECS.find((spec) => + spec.name === upper + || spec.path === normalized + || spec.path.toUpperCase() === upper + || spec.path.split('/').at(-1)?.toUpperCase() === upper + ); +} + export function cliCommandsGenerator(): string { const lines: string[] = []; for (const topic of CLI_TOPICS) { @@ -90,13 +275,7 @@ export function signpostInventoryGenerator(): string { const lines: string[] = [ '| Signpost | Type | Description |', '|----------|------|-------------|', - '| `README.md` | Hand-authored | Core doctrine and filesystem shape. |', - '| `ARCHITECTURE.md` | Hybrid | How the source code is organized. |', - '| `docs/BEARING.md` | Generated | Current direction and recent ships. |', - '| `docs/VISION.md` | Generated | Bounded executive synthesis. |', - '| `docs/CLI.md` | Hybrid | CLI command reference. |', - '| `docs/MCP.md` | Hybrid | MCP tool reference. |', - '| `docs/GUIDE.md` | Hybrid | Operator advice with generated sections. |', + ...SIGNPOST_SPECS.map((spec) => `| \`${spec.path}\` | ${spec.kind} | ${spec.description} |`), '', ]; return lines.join('\n'); diff --git a/src/index.ts b/src/index.ts index 3261331..f6cbebb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,10 +12,21 @@ import { promisify } from 'node:util'; import GitPlumbing from '@git-stunts/plumbing'; import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'; import { + type BacklogLane, type BacklogItem, + type BacklogQueryItem, + type BacklogQueryResult, + type BacklogQuerySort, type Cycle, type Lane, LANES, + type NextWorkRecommendation, + type NextWorkResult, + type NextWorkScoreBand, + type NextWorkSignal, + orderedBacklogLaneNames, + isReleaseLane, + isLaneName, type LegendHealth, type Outcome, type WorkspaceStatus, @@ -27,10 +38,12 @@ import { readBody as fmReadBody, readFrontmatter as fmReadFrontmatter, readHeading as fmReadHeading, + readTypedFrontmatter as fmReadTypedFrontmatter, updateBody as fmUpdateBody, updateFrontmatter as fmUpdateFrontmatter, + updateTypedFrontmatter as fmUpdateTypedFrontmatter, } from './frontmatter.js'; -import { createGenerators, replaceGeneratedSections } from './generate.js'; +import { generateReferenceDocs, initializeReferenceDoc, resolveSignpostSpec, SIGNPOST_SPECS } from './generate.js'; import { renderBearing, renderDesignDoc, renderRetroDoc, renderWitnessDoc, titleCase } from './renderers.js'; export interface ResolvedPaths { @@ -42,6 +55,71 @@ export interface ResolvedPaths { methodDir: string; } +interface SignpostStatusEntry { + name: string; + path: string; + kind: string; + description: string; + exists: boolean; + initable: boolean; +} + +export interface BacklogDependencyEdge { + blocker: string; + blocked: string; +} + +export interface BacklogDependencyItem extends BacklogItem { + title?: string; + blockedBy: string[]; + blocks: string[]; + unresolvedBlockedBy: string[]; + unresolvedBlocks: string[]; + ready: boolean; +} + +export interface BacklogDependencyFocus { + item: BacklogDependencyItem; + blockers: BacklogDependencyItem[]; + blocked: BacklogDependencyItem[]; + criticalPath: BacklogDependencyItem[]; + criticalPathReason?: string; +} + +export interface BacklogDependencyReport { + root: string; + query: { + item?: string; + readyOnly: boolean; + criticalPath: boolean; + }; + items: BacklogDependencyItem[]; + edges: BacklogDependencyEdge[]; + ready: BacklogDependencyItem[]; + cycles: string[][]; + focus?: BacklogDependencyFocus; +} + +export interface BacklogMetadataEditResult extends BacklogQueryItem { + updatedFields: string[]; +} + +interface BearingSignals { + exists: boolean; + priority?: string; + concerns: string[]; + sections: Array<{ name: string; text: string; lines: string[] }>; +} + +interface RankedNextWorkItem { + item: BacklogQueryItem; + score: number; + laneRank: number; + signals: NextWorkSignal[]; + whyNow: string[]; + bearingOverride: boolean; +} + function resolvePaths(root: string, paths: PathsConfig): ResolvedPaths { return { backlog: resolve(root, paths.backlog), @@ -55,31 +133,38 @@ function resolvePaths(root: string, paths: PathsConfig): ResolvedPaths { const LEGEND_CODE_PATTERN = /^[A-Z][A-Z0-9]*$/u; const LEGEND_PATTERN = /^(?<legend>[A-Z][A-Z0-9]*)_(?<slug>.+)$/; -const CYCLE_PATTERN = /^(?<number>\d{4})-(?<slug>[a-z0-9][a-z0-9-]*)$/; +const CYCLE_NAME_PATTERN = /^(?:(?<legend>[A-Z][A-Z0-9]*)_)?(?<slug>[a-z0-9][a-z0-9-]*)$/; +const LEGACY_CYCLE_PATTERN = /^(?<number>\d{4})-(?<slug>[a-z0-9][a-z0-9-]*)$/; +const FEEDBACK_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; + +export function workspaceScaffold(root: string, pathsConfig: PathsConfig = DEFAULT_PATHS): { directories: string[]; files: Map<string, string> } { + const p = resolvePaths(root, pathsConfig); + return { + directories: [ + resolve(p.backlog, 'inbox'), + resolve(p.backlog, 'asap'), + resolve(p.backlog, 'cool-ideas'), + resolve(p.backlog, 'bad-code'), + resolve(p.methodDir, 'legends'), + p.graveyard, + resolve(p.methodDir, 'releases'), + p.retro, + resolve(root, 'docs/releases'), + p.design, + ], + files: new Map<string, string>([ + [resolve(root, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n- No externally meaningful changes recorded yet.\n'], + [resolve(p.methodDir, 'process.md'), '# Process\n\nDescribe how cycles run in this repository.\n'], + [resolve(p.methodDir, 'release.md'), '# Release\n\nDescribe when and how externally meaningful releases ship.\n'], + [resolve(p.methodDir, 'release-runbook.md'), '# Release Runbook\n\nDescribe the sequential release pre-flight for this repository.\n'], + [resolve(p.methodDir, 'releases/README.md'), '# Release Packets\n\nStore internal release design and verification artifacts here.\n'], + [resolve(root, 'docs/releases/README.md'), '# Releases\n\nStore user-facing release notes and migration guides here.\n'], + ]), + }; +} export function initWorkspace(root: string, pathsConfig?: PathsConfig): { created: string[] } { - const p = resolvePaths(root, pathsConfig ?? DEFAULT_PATHS); - const directories = [ - resolve(p.backlog, 'inbox'), - resolve(p.backlog, 'asap'), - resolve(p.backlog, 'up-next'), - resolve(p.backlog, 'cool-ideas'), - resolve(p.backlog, 'bad-code'), - resolve(p.methodDir, 'legends'), - p.graveyard, - resolve(p.methodDir, 'releases'), - p.retro, - resolve(root, 'docs/releases'), - p.design, - ]; - const files = new Map<string, string>([ - [resolve(root, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n- No externally meaningful changes recorded yet.\n'], - [resolve(p.methodDir, 'process.md'), '# Process\n\nDescribe how cycles run in this repository.\n'], - [resolve(p.methodDir, 'release.md'), '# Release\n\nDescribe when and how externally meaningful releases ship.\n'], - [resolve(p.methodDir, 'release-runbook.md'), '# Release Runbook\n\nDescribe the sequential release pre-flight for this repository.\n'], - [resolve(p.methodDir, 'releases/README.md'), '# Release Packets\n\nStore internal release design and verification artifacts here.\n'], - [resolve(root, 'docs/releases/README.md'), '# Releases\n\nStore user-facing release notes and migration guides here.\n'], - ]); + const { directories, files } = workspaceScaffold(root, pathsConfig ?? DEFAULT_PATHS); const created: string[] = []; for (const directory of directories) { @@ -127,42 +212,87 @@ export class Workspace { } } - captureIdea(idea: string, legend?: string, title?: string): string { - const cleanIdea = idea.trim(); - if (cleanIdea.length === 0) { - throw new MethodError('Idea cannot be empty.'); + createBacklogItem(options: { + lane: string; + title: string; + body?: string; + legend?: string; + source?: string; + capturedAt?: string; + }): string { + const lane = normalizeLiveBacklogLane(options.lane); + if (lane === undefined) { + throw new MethodError('Backlog lane must be a live lane such as `inbox`, `bad-code`, or `v1.1.0`.'); } - const slug = slugify(title ?? cleanIdea); - if (slug.length === 0) { - throw new MethodError('Could not derive a filename from the provided idea.'); + const heading = options.title.trim(); + if (heading.length === 0) { + throw new MethodError('Title cannot be empty.'); } - let prefix = ''; - if (legend !== undefined) { - const normalized = legend.trim().toUpperCase(); - if (!LEGEND_CODE_PATTERN.test(normalized)) { - throw new MethodError('Legend codes must be uppercase letters and numbers.'); - } - prefix = `${normalized}_`; + const slug = slugify(heading); + if (slug.length === 0) { + throw new MethodError('Could not derive a filename from the provided title.'); } - const path = resolve(this.paths.backlog, 'inbox', `${prefix}${slug}.md`); + const normalizedLegend = normalizeRequestedLegend(options.legend); + const prefix = normalizedLegend === undefined ? '' : `${normalizedLegend}_`; + const path = resolve(this.paths.backlog, lane, `${prefix}${slug}.md`); if (existsSync(path)) { - throw new MethodError(`${relative(this.root, path)} already exists. Use --title to disambiguate.`); + throw new MethodError(`${relative(this.root, path)} already exists. Change the title or legend to disambiguate.`); } mkdirSync(dirname(path), { recursive: true }); - const heading = (title ?? cleanIdea).trim(); const fmLines = ['---', `title: "${heading.replace(/"/gu, '\\"')}"`]; - if (legend !== undefined) { - fmLines.push(`legend: ${legend.trim().toUpperCase()}`); + if (normalizedLegend !== undefined) { + fmLines.push(`legend: ${normalizedLegend}`); + } + const release = inferBacklogRelease(lane, undefined); + if (release !== undefined) { + fmLines.push(`release: "${release}"`); + } + if (options.source?.trim().length) { + fmLines.push(`source: "${options.source.trim().replace(/"/gu, '\\"')}"`); + } + const capturedAt = normalizeCapturedAt(options.capturedAt); + if (options.capturedAt !== undefined || options.source !== undefined) { + fmLines.push(`captured_at: "${capturedAt}"`); } - fmLines.push('lane: inbox', '---'); - writeFileSync(path, `${fmLines.join('\n')}\n\n# ${heading}\n\n${cleanIdea}\n`, 'utf8'); + fmLines.push(`lane: ${lane}`, '---'); + + const body = options.body?.trimEnd() ?? ''; + const content = body.length === 0 + ? `${fmLines.join('\n')}\n\n# ${heading}\n` + : `${fmLines.join('\n')}\n\n# ${heading}\n\n${body}\n`; + + writeFileSync(path, content, 'utf8'); return path; } + captureIdea(idea: string, legend?: string, title?: string): string { + return this.captureIdeaWithMetadata(idea, legend, title); + } + + captureIdeaWithMetadata(idea: string, legend?: string, title?: string, metadata?: { + source?: string; + capturedAt?: string; + body?: string; + }): string { + const cleanIdea = idea.trim(); + if (cleanIdea.length === 0) { + throw new MethodError('Idea cannot be empty.'); + } + + return this.createBacklogItem({ + lane: 'inbox', + title: (title ?? cleanIdea).trim(), + body: metadata?.body ?? cleanIdea, + legend, + source: metadata?.source, + capturedAt: metadata?.capturedAt, + }); + } + pullItem(item: string): Cycle { const backlogItem = this.resolveBacklogItem(item); const title = readHeading(backlogItem); @@ -170,15 +300,14 @@ export class Workspace { throw new MethodError(`${relative(this.root, backlogItem)} is missing a heading.`); } - 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`); + const { legend, slug, release } = readBacklogPullContext(backlogItem, inferBacklogLane(this.paths.backlog, backlogItem)); + const cycleName = legend !== undefined ? `${legend}_${slug}` : slug; + const { designDoc, retroDoc } = resolveCyclePacketPaths(this.root, this.paths, cycleName, release); if (existsSync(designDoc)) { throw new MethodError(`${relative(this.root, designDoc)} already exists.`); } - mkdirSync(designDir, { recursive: false }); + mkdirSync(dirname(designDoc), { recursive: true }); writeFileSync( designDoc, renderDesignDoc({ @@ -187,6 +316,7 @@ export class Workspace { legend, source: relative(this.root, backlogItem), backlogBody: readBody(backlogItem), + release, }), 'utf8', ); @@ -194,10 +324,9 @@ export class Workspace { return { name: cycleName, - number: Number.parseInt(cycleName.slice(0, 4), 10), slug, designDoc, - retroDoc: resolve(this.paths.retro, cycleName, `${slug}.md`), + retroDoc, }; } @@ -207,7 +336,7 @@ export class Workspace { } const cycle = this.resolveCycle(cycleName); - const retroDir = resolve(this.paths.retro, cycle.name); + const retroDir = dirname(cycle.retroDoc); const witnessDir = resolve(retroDir, 'witness'); mkdirSync(witnessDir, { recursive: true }); @@ -225,6 +354,7 @@ export class Workspace { root: this.root, outcome, witnessDir: relative(this.root, witnessDir), + release: readCycleRelease(cycle), }), 'utf8', ); @@ -237,6 +367,69 @@ export class Workspace { return detectWorkspaceDrift(this.root, cycles, this.paths.tests); } + syncRefs(): { targets: string[]; updated: string[] } { + return generateReferenceDocs(this.root); + } + + signpostStatus() { + const signposts = SIGNPOST_SPECS.map((spec): SignpostStatusEntry => ({ + name: spec.name, + path: spec.path, + kind: spec.kind, + description: spec.description, + exists: existsSync(resolve(this.root, spec.path)), + initable: spec.initStrategy !== undefined, + })); + + return { + root: this.root, + signposts, + missing: signposts.filter((signpost) => !signpost.exists).map((signpost) => signpost.path), + present: signposts.filter((signpost) => signpost.exists).map((signpost) => signpost.path), + }; + } + + async initSignpost(name: string) { + const spec = resolveSignpostSpec(name); + if (spec === undefined) { + throw new MethodError(`Unknown signpost ${JSON.stringify(name)}. Use one of: ${SIGNPOST_SPECS.map((entry) => entry.name).join(', ')}`); + } + if (spec.initStrategy === undefined) { + throw new MethodError(`Signpost ${spec.name} is not initable. Current helpers support: ${SIGNPOST_SPECS.filter((entry) => entry.initStrategy !== undefined).map((entry) => entry.name).join(', ')}`); + } + if (existsSync(resolve(this.root, spec.path))) { + return { + ok: true, + requested: spec.name, + initializedTargets: [], + skippedPaths: [spec.path], + signpost: this.signpostStatus().signposts.find((entry) => entry.path === spec.path)!, + }; + } + + if (spec.initStrategy === 'reference-doc') { + const result = initializeReferenceDoc(this.root, spec.path as 'ARCHITECTURE.md' | 'docs/CLI.md' | 'docs/MCP.md' | 'docs/GUIDE.md'); + return { + ok: true, + requested: spec.name, + initializedTargets: result.initialized ? [result.path] : [], + skippedPaths: result.initialized ? [] : [result.path], + signpost: this.signpostStatus().signposts.find((entry) => entry.path === spec.path)!, + }; + } + + const signpostPath = resolve(this.root, spec.path); + mkdirSync(dirname(signpostPath), { recursive: true }); + writeFileSync(signpostPath, renderBearing(this.status(), this.closedCycles(), await this.currentCommitSha()), 'utf8'); + return { + ok: true, + requested: spec.name, + initializedTargets: [spec.path], + skippedPaths: [], + signpost: this.signpostStatus().signposts.find((entry) => entry.path === spec.path)!, + }; + } + async shipSync(): Promise<{ updated: string[]; newShips: Cycle[] }> { const changelogPath = resolve(this.root, 'CHANGELOG.md'); const bearingPath = resolve(this.root, 'docs/BEARING.md'); @@ -255,18 +448,9 @@ export class Workspace { writeFileSync(bearingPath, renderBearing(status, closedCycles, commitSha), 'utf8'); updated.push('docs/BEARING.md'); - // Hybrid generation: replace <!-- generate:NAME --> sections in signpost files - const generators = createGenerators(this.root); - - for (const signpost of ['ARCHITECTURE.md', 'docs/CLI.md', 'docs/MCP.md', 'docs/GUIDE.md']) { - const signpostPath = resolve(this.root, signpost); - if (!existsSync(signpostPath)) { - continue; - } - const before = readFileSync(signpostPath, 'utf8'); - const after = replaceGeneratedSections(before, generators); - if (after !== before) { - writeFileSync(signpostPath, after, 'utf8'); + const refs = this.syncRefs(); + for (const signpost of refs.updated) { + if (!updated.includes(signpost)) { updated.push(signpost); } } @@ -285,7 +469,7 @@ export class Workspace { async captureWitness(cycleName?: string): Promise<string> { const cycle = this.resolveCycle(cycleName); - const retroDir = resolve(this.paths.retro, cycle.name); + const retroDir = dirname(cycle.retroDoc); const witnessPath = resolve(retroDir, 'witness', 'verification.md'); mkdirSync(dirname(witnessPath), { recursive: true }); @@ -304,17 +488,13 @@ export class Workspace { } status(): WorkspaceStatus { - const backlog: WorkspaceStatus['backlog'] = { - inbox: [], - asap: [], - 'up-next': [], - 'cool-ideas': [], - 'bad-code': [], - root: [], - }; + const backlog = createBacklogBuckets(); const backlogItems = this.collectBacklogItems(); for (const item of backlogItems) { + if (backlog[item.lane] === undefined) { + backlog[item.lane] = []; + } backlog[item.lane].push(item); } @@ -323,12 +503,272 @@ export class Workspace { return { root: this.root, - backlog, + backlog: orderBacklogBuckets(backlog), activeCycles, legendHealth, }; } + backlogQuery(options: { + lane?: string; + legend?: string; + priority?: string; + keyword?: string; + owner?: string; + ready?: boolean; + hasAcceptanceCriteria?: boolean; + blockedBy?: string; + blocks?: string; + sort?: string; + limit?: number; + } = {}): BacklogQueryResult { + const laneFilter = normalizeBacklogQueryLane(options.lane); + const legendFilter = normalizeBacklogQueryLegend(options.legend); + const priorityFilter = normalizeBacklogPriority(options.priority); + const keywordFilter = normalizeBacklogKeywordFilter(options.keyword); + const ownerFilter = normalizeBacklogOwnerFilter(options.owner); + const readyFilter = options.ready; + const hasAcceptanceCriteriaFilter = options.hasAcceptanceCriteria; + const blockedByFilter = normalizeBacklogDependencyFilter(options.blockedBy); + const blocksFilter = normalizeBacklogDependencyFilter(options.blocks); + const sortMode = normalizeBacklogQuerySort(options.sort); + const limit = normalizeBacklogQueryLimit(options.limit); + + const filtered = filterBacklogQueryItems(this.collectBacklogQueryItems(), { + lane: laneFilter, + legend: legendFilter, + priority: priorityFilter, + keyword: keywordFilter, + owner: ownerFilter, + ready: readyFilter, + hasAcceptanceCriteria: hasAcceptanceCriteriaFilter, + blockedBy: blockedByFilter, + blocks: blocksFilter, + }) + .sort((left, right) => compareBacklogQueryItems(left, right, sortMode)); + + const items = filtered.slice(0, limit); + return { + root: this.root, + filters: { + lane: laneFilter, + legend: legendFilter, + priority: priorityFilter, + keyword: keywordFilter, + owner: ownerFilter, + ready: readyFilter, + hasAcceptanceCriteria: hasAcceptanceCriteriaFilter, + blockedBy: blockedByFilter, + blocks: blocksFilter, + sort: sortMode, + limit, + }, + items, + totalCount: filtered.length, + returnedCount: items.length, + truncated: filtered.length > items.length, + }; + } + + nextWork(options: { + lane?: string; + legend?: string; + priority?: string; + keyword?: string; + owner?: string; + includeBlocked?: boolean; + limit?: number; + } = {}): NextWorkResult { + const limit = normalizeNextWorkLimit(options.limit); + const status = this.status(); + const laneFilter = normalizeBacklogQueryLane(options.lane); + const legendFilter = normalizeBacklogQueryLegend(options.legend); + const priorityFilter = normalizeBacklogPriority(options.priority); + const keywordFilter = normalizeBacklogKeywordFilter(options.keyword); + const ownerFilter = normalizeBacklogOwnerFilter(options.owner); + const includeBlocked = options.includeBlocked ?? false; + const allItems = filterBacklogQueryItems(this.collectBacklogQueryItems(), { + lane: laneFilter, + legend: legendFilter, + priority: priorityFilter, + keyword: keywordFilter, + owner: ownerFilter, + }); + const readyItems = allItems.filter((item) => item.blockedBy.length === 0); + const candidateItems = includeBlocked || readyItems.length === 0 ? allItems : readyItems; + const skippedBlockedCount = includeBlocked ? 0 : allItems.length - readyItems.length; + const bearing = readBearingSignals(this.root); + const baselineRanked = candidateItems + .map((item) => rankNextWorkItem(item, status, { exists: false, priority: undefined, concerns: [], sections: [] })) + .sort(compareRankedNextWorkItems); + const ranked = candidateItems + .map((item) => rankNextWorkItem(item, status, bearing)) + .sort(compareRankedNextWorkItems); + const top = ranked.slice(0, limit); + const recommendations = top.map((entry, index, array) => ({ + path: entry.item.path, + title: entry.item.title ?? titleCase(entry.item.slug), + lane: entry.item.lane, + priority: entry.item.priority, + scoreBand: nextWorkScoreBand(index, array.length), + whyNow: buildNextWorkWhyNow(entry), + signals: entry.signals, + })); + const selectionNotes: string[] = []; + if (status.activeCycles.length > 0) { + selectionNotes.push(`An active cycle is already open (${status.activeCycles.map((cycle) => cycle.name).join(', ')}), so this menu is advisory rather than a pull directive.`); + } + if (skippedBlockedCount > 0 && readyItems.length > 0) { + selectionNotes.push(`Skipped ${skippedBlockedCount} blocked backlog item(s) because unblocked work is available.`); + } + if (includeBlocked) { + selectionNotes.push('Blocked items were included because `include-blocked` was requested.'); + } + const filterSummary = summarizeNextWorkFilters({ + lane: laneFilter, + legend: legendFilter, + priority: priorityFilter, + keyword: keywordFilter, + owner: ownerFilter, + }); + if (filterSummary !== undefined) { + selectionNotes.push(`Applied filters: ${filterSummary}.`); + } + if (!bearing.exists) { + selectionNotes.push('BEARING was not present, so no directional mention signals were considered.'); + } + selectionNotes.push(...deriveNextWorkOverrideNotes(baselineRanked, ranked, limit)); + if (recommendations.length === 0) { + selectionNotes.push('No candidate backlog items were found.'); + } + + return { + generated_at: new Date().toISOString(), + summary: { + active_cycle_count: status.activeCycles.length, + lane_counts: Object.fromEntries(Object.entries(status.backlog).map(([lane, items]) => [lane, items.length])), + bearing_priority: bearing.priority, + bearing_concerns: bearing.concerns, + }, + recommendations, + selection_notes: selectionNotes, + }; + } + + backlogDependencies(options: { + item?: string; + readyOnly?: boolean; + criticalPath?: boolean; + } = {}): BacklogDependencyReport { + const allBacklogItems = this.collectBacklogItems(); + const dependencyItems = allBacklogItems.map((item) => { + const frontmatter = fmReadTypedFrontmatter(resolve(this.root, item.path)); + return { + ...item, + title: normalizeOptionalString(frontmatter.title), + blockedByRefs: normalizeDependencyRefs(frontmatter.blocked_by), + blocksRefs: normalizeDependencyRefs(frontmatter.blocks), + blockedBySet: new Set<string>(), + blocksSet: new Set<string>(), + unresolvedBlockedBySet: new Set<string>(), + unresolvedBlocksSet: new Set<string>(), + }; + }); + + const edgeMap = new Map<string, BacklogDependencyEdge>(); + for (const item of dependencyItems) { + for (const ref of item.blockedByRefs) { + const blockerPath = resolveDependencyItemRef(ref, dependencyItems); + if (blockerPath === undefined) { + item.unresolvedBlockedBySet.add(ref); + continue; + } + const edgeKey = `${blockerPath}=>${item.path}`; + edgeMap.set(edgeKey, { blocker: blockerPath, blocked: item.path }); + } + + for (const ref of item.blocksRefs) { + const blockedPath = resolveDependencyItemRef(ref, dependencyItems); + if (blockedPath === undefined) { + item.unresolvedBlocksSet.add(ref); + continue; + } + const edgeKey = `${item.path}=>${blockedPath}`; + edgeMap.set(edgeKey, { blocker: item.path, blocked: blockedPath }); + } + } + + const itemsByPath = new Map(dependencyItems.map((item) => [item.path, item])); + for (const edge of edgeMap.values()) { + itemsByPath.get(edge.blocker)?.blocksSet.add(edge.blocked); + itemsByPath.get(edge.blocked)?.blockedBySet.add(edge.blocker); + } + + const finalizedItems = dependencyItems + .map((item) => finalizeDependencyItem(item)) + .sort(compareBacklogPaths); + const finalizedByPath = new Map(finalizedItems.map((item) => [item.path, item])); + const finalizedEdges = [...edgeMap.values()].sort((left, right) => + left.blocker.localeCompare(right.blocker) || left.blocked.localeCompare(right.blocked)); + const adjacency = new Map<string, Set<string>>(); + for (const item of finalizedItems) { + adjacency.set(item.path, new Set(item.blocks)); + } + const cycles = findDependencyCycles(finalizedItems.map((item) => item.path), adjacency); + const ready = finalizedItems.filter((item) => item.ready); + const report: BacklogDependencyReport = { + root: this.root, + query: { + item: options.item, + readyOnly: options.readyOnly ?? false, + criticalPath: options.criticalPath ?? false, + }, + items: finalizedItems, + edges: finalizedEdges, + ready, + cycles, + }; + + if (options.item !== undefined) { + const focusPath = this.resolveBacklogPath(options.item); + const focusItem = finalizedByPath.get(focusPath); + if (focusItem === undefined) { + throw new MethodError(`Could not find backlog item ${JSON.stringify(options.item)}.`); + } + + const blockers = focusItem.blockedBy + .map((path) => finalizedByPath.get(path)) + .filter((item): item is BacklogDependencyItem => item !== undefined) + .sort(compareBacklogPaths); + const blocked = focusItem.blocks + .map((path) => finalizedByPath.get(path)) + .filter((item): item is BacklogDependencyItem => item !== undefined) + .sort(compareBacklogPaths); + + let criticalPath: BacklogDependencyItem[] = []; + let criticalPathReason: string | undefined; + if (options.criticalPath) { + if (cycles.length > 0) { + criticalPathReason = 'Critical path is unavailable while dependency cycles exist.'; + } else { + criticalPath = longestDependencyPathTo(focusItem.path, finalizedByPath) + .map((path) => finalizedByPath.get(path)) + .filter((item): item is BacklogDependencyItem => item !== undefined); + } + } + + report.focus = { + item: focusItem, + blockers, + blocked, + criticalPath, + criticalPathReason, + }; + } + + return report; + } + updateFrontmatter(path: string, updates: Record<string, string>): void { fmUpdateFrontmatter(resolve(this.root, path), updates); } @@ -337,11 +777,46 @@ export class Workspace { return fmReadFrontmatter(resolve(this.root, path)); } + resolveRepoPath(path: string): string { + return relative(this.root, path); + } + + resolveBacklogPath(item: string): string { + return relative(this.root, this.resolveBacklogItem(item)); + } + + describeBacklogPath(path: 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 frontmatter = fmReadFrontmatter(fullPath); + const stem = fileStem(fullPath); + const filenameMetadata = splitLegend(stem); + const hasExplicitLegend = Object.prototype.hasOwnProperty.call(frontmatter, 'legend'); + + return { + stem, + lane: normalizeBacklogLane(frontmatter.lane) ?? inferBacklogLane(this.paths.backlog, fullPath), + path: relative(this.root, fullPath), + legend: hasExplicitLegend + ? normalizeLegend(frontmatter.legend) ?? filenameMetadata.legend + : filenameMetadata.legend, + slug: filenameMetadata.slug, + title: frontmatter.title, + release: normalizeOptionalString(frontmatter.release), + source: frontmatter.source, + captured_at: frontmatter.captured_at, + }; + } + updateBody(path: string, newBody: string): void { fmUpdateBody(resolve(this.root, path), newBody); } - moveBacklogItem(path: string, targetLane: Lane | 'graveyard'): string { + moveBacklogItem(path: string, targetLane: BacklogLane | 'graveyard'): string { const fullPath = resolve(this.root, path); assertWorkspacePath(this.root, fullPath, 'Backlog path'); if (!existsSync(fullPath)) { @@ -350,13 +825,19 @@ export class Workspace { const metadata = this.readBacklogItem(fullPath, inferBacklogLane(this.paths.backlog, fullPath)); const fileName = basename(fullPath); + const normalizedTargetLane = targetLane === 'graveyard' + ? targetLane + : normalizeBacklogLane(targetLane); + if (normalizedTargetLane === undefined) { + throw new MethodError(`Invalid backlog lane: ${targetLane}`); + } let targetDir: string; - if (targetLane === 'graveyard') { + if (normalizedTargetLane === 'graveyard') { targetDir = this.paths.graveyard; - } else if (targetLane === 'root') { + } else if (normalizedTargetLane === 'root') { targetDir = this.paths.backlog; } else { - targetDir = resolve(this.paths.backlog, targetLane); + targetDir = resolve(this.paths.backlog, normalizedTargetLane); } mkdirSync(targetDir, { recursive: true }); @@ -369,14 +850,121 @@ export class Workspace { if (fullPath !== targetPath) { renameSync(fullPath, targetPath); } - const frontmatterUpdates: Record<string, string> = { lane: targetLane }; + const frontmatterUpdates: Record<string, string> = { lane: normalizedTargetLane }; if (metadata.legend !== undefined) { frontmatterUpdates.legend = metadata.legend; } + if (normalizedTargetLane !== 'root' && normalizedTargetLane !== 'graveyard' && isReleaseLane(normalizedTargetLane)) { + frontmatterUpdates.release = normalizedTargetLane; + } fmUpdateFrontmatter(targetPath, frontmatterUpdates); return relative(this.root, targetPath); } + retireBacklogItem(item: string, reason: string, replacement?: string, options?: { dryRun?: boolean }) { + const cleanReason = reason.trim(); + if (cleanReason.length === 0) { + throw new MethodError('Reason cannot be empty.'); + } + + const cleanReplacement = replacement?.trim().length ? replacement.trim() : undefined; + const fullPath = this.resolveBacklogItem(item); + const sourcePath = relative(this.root, fullPath); + const graveyardPath = relative(this.root, resolve(this.paths.graveyard, basename(fullPath))); + if (sourcePath === graveyardPath) { + throw new MethodError(`Backlog item is already retired: ${graveyardPath}`); + } + if (existsSync(resolve(this.root, graveyardPath))) { + throw new MethodError(`Destination already exists: ${graveyardPath}`); + } + + const result = { + ok: true, + dryRun: options?.dryRun ?? false, + sourcePath, + graveyardPath, + reason: cleanReason, + replacement: cleanReplacement, + updatedFiles: [sourcePath, graveyardPath], + }; + if (options?.dryRun) { + return result; + } + + const originalProposal = readBody(fullPath); + const movedPath = this.moveBacklogItem(sourcePath, 'graveyard'); + this.updateBody(movedPath, renderRetiredBody(cleanReason, originalProposal, cleanReplacement)); + return { ...result, graveyardPath: movedPath, updatedFiles: [sourcePath, movedPath] }; + } + + editBacklogMetadata(item: string, updates: { + owner?: string; + clearOwner?: boolean; + priority?: string; + clearPriority?: boolean; + keywords?: readonly string[]; + clearKeywords?: boolean; + blockedBy?: readonly string[]; + clearBlockedBy?: boolean; + blocks?: readonly string[]; + clearBlocks?: boolean; + }): BacklogMetadataEditResult { + const fullPath = this.resolveBacklogItem(item); + const typedUpdates: Record<string, unknown> = {}; + const updatedFields: string[] = []; + + applyMetadataEditField({ + setValue: updates.owner, + clearValue: updates.clearOwner ?? false, + field: 'owner', + normalize: (value) => normalizeRequiredMetadataString(value, 'owner'), + target: typedUpdates, + updatedFields, + }); + applyMetadataEditField({ + setValue: updates.priority, + clearValue: updates.clearPriority ?? false, + field: 'priority', + normalize: (value) => normalizeRequiredBacklogPriority(value), + target: typedUpdates, + updatedFields, + }); + applyMetadataEditField({ + setValue: updates.keywords, + clearValue: updates.clearKeywords ?? false, + field: 'keywords', + normalize: (value) => normalizeRequiredMetadataList(value, 'keywords'), + target: typedUpdates, + updatedFields, + }); + applyMetadataEditField({ + setValue: updates.blockedBy, + clearValue: updates.clearBlockedBy ?? false, + field: 'blocked_by', + normalize: (value) => normalizeRequiredMetadataList(value, 'blocked_by'), + target: typedUpdates, + updatedFields, + }); + applyMetadataEditField({ + setValue: updates.blocks, + clearValue: updates.clearBlocks ?? false, + field: 'blocks', + normalize: (value) => normalizeRequiredMetadataList(value, 'blocks'), + target: typedUpdates, + updatedFields, + }); + + if (updatedFields.length === 0) { + throw new MethodError('No backlog metadata updates were requested.'); + } + + fmUpdateTypedFrontmatter(fullPath, typedUpdates); + return { + ...this.readBacklogQueryItem(fullPath, inferBacklogLane(this.paths.backlog, fullPath)), + updatedFields, + }; + } + openCycles(): Cycle[] { return this.allCycles().filter((cycle) => existsSync(cycle.designDoc) && !existsSync(cycle.retroDoc)); } @@ -391,11 +979,9 @@ export class Workspace { return closedCycles; } const content = readFileSync(changelogPath, 'utf8'); - const releasedCycleNumbers = extractReleasedCycleNumbers(content); return closedCycles.filter((cycle) => ( !content.includes(`- ${cycle.name}:`) && !content.includes(`(${cycle.name})`) - && !releasedCycleNumbers.has(cycle.number) )); } @@ -420,11 +1006,18 @@ export class Workspace { const direct = resolve(this.root, item); if (existsSync(direct)) { assertWorkspacePath(this.root, direct, 'Backlog path'); - return direct; + if (isLiveBacklogFile(this.paths.backlog, direct)) { + return direct; + } + throw new MethodError(`Backlog item must be a live backlog doc under ${relative(this.root, this.paths.backlog)}.`); } const backlogFiles = collectMarkdownFiles(this.paths.backlog); - const exactMatches = backlogFiles.filter((file) => file === direct || basename(file) === item || fileStem(file) === item); + const exactMatches = backlogFiles.filter((file) => { + const stem = fileStem(file); + const { slug } = splitLegend(stem); + return file === direct || basename(file) === item || stem === item || slug === item; + }); if (exactMatches.length === 1) { return exactMatches[0]; } @@ -433,44 +1026,41 @@ export class Workspace { } const lowerItem = item.toLowerCase(); - const fuzzyMatches = backlogFiles.filter((file) => fileStem(file).toLowerCase() === lowerItem); + const fuzzyMatches = backlogFiles.filter((file) => { + const stem = fileStem(file).toLowerCase(); + const slug = splitLegend(fileStem(file)).slug.toLowerCase(); + return stem === lowerItem || slug === lowerItem; + }); if (fuzzyMatches.length === 1) { return fuzzyMatches[0]; } throw new MethodError(`Could not find backlog item ${JSON.stringify(item)}.`); } - private nextCycleNumber(): number { - const numbers = this.allCycles().map((cycle) => cycle.number); - return numbers.length === 0 ? 1 : Math.max(...numbers) + 1; - } - private allCycles(): Cycle[] { const cycles = new Map<string, Cycle>(); - for (const base of [this.paths.design, this.paths.retro]) { + for (const base of [this.paths.design, this.paths.retro, resolve(this.root, 'docs/releases')]) { if (!existsSync(base)) { continue; } - for (const entry of readdirSync(base, { withFileTypes: true })) { - if (!entry.isDirectory()) { + for (const file of collectMarkdownFiles(base)) { + const cycle = readCycleFromDoc(this.root, this.paths, file); + if (cycle === undefined) { continue; } - const match = CYCLE_PATTERN.exec(entry.name); - if (match?.groups === undefined) { + const existing = cycles.get(cycle.name); + if (existing === undefined) { + cycles.set(cycle.name, cycle); continue; } - const slug = match.groups.slug; - const number = Number.parseInt(match.groups.number, 10); - cycles.set(entry.name, { - name: entry.name, - number, - slug, - designDoc: resolve(this.paths.design, entry.name, `${slug}.md`), - retroDoc: resolve(this.paths.retro, entry.name, `${slug}.md`), + cycles.set(cycle.name, { + ...existing, + designDoc: existsSync(cycle.designDoc) ? cycle.designDoc : existing.designDoc, + retroDoc: existsSync(cycle.retroDoc) ? cycle.retroDoc : existing.retroDoc, }); } } - return [...cycles.values()].sort((left, right) => left.number - right.number); + return [...cycles.values()].sort((left, right) => left.name.localeCompare(right.name)); } private resolveCycle(cycleName?: string): Cycle { @@ -506,7 +1096,27 @@ export class Workspace { this.readBacklogItem(file, inferBacklogLane(this.paths.backlog, file))); } - private readBacklogItem(path: string, fallbackLane: Lane | 'root'): BacklogItem { + private collectBacklogQueryItems(): BacklogQueryItem[] { + return this.collectBacklogItems().map((item) => + this.readBacklogQueryItem(resolve(this.root, item.path), item.lane)); + } + + private readBacklogQueryItem(path: string, fallbackLane: BacklogLane): BacklogQueryItem { + const base = this.readBacklogItem(path, fallbackLane); + const frontmatter = fmReadTypedFrontmatter(path); + return { + ...base, + title: normalizeOptionalString(frontmatter.title), + owner: normalizeOptionalString(frontmatter.owner), + priority: normalizeBacklogPriority(frontmatter.priority), + keywords: normalizeBacklogKeywords(frontmatter.keywords), + blockedBy: normalizeDependencyRefs(frontmatter.blocked_by), + blocks: normalizeDependencyRefs(frontmatter.blocks), + hasAcceptanceCriteria: Object.prototype.hasOwnProperty.call(frontmatter, 'acceptance_criteria'), + }; + } + + private readBacklogItem(path: string, fallbackLane: BacklogLane): BacklogItem { const stem = fileStem(path); const filenameMetadata = splitLegend(stem); const frontmatter = fmReadFrontmatter(path); @@ -624,29 +1234,857 @@ function readDesignLegend(path: string): string | undefined { return undefined; } -function extractReleasedCycleNumbers(content: string): Set<number> { - const released = new Set<number>(); - const cycleReferencePattern = /\b(?<start>\d{4})(?:\s*[–-]\s*(?<end>\d{4}))?\b/gu; +function readBearingSignals(root: string): BearingSignals { + const bearingPath = resolve(root, 'docs/BEARING.md'); + if (!existsSync(bearingPath)) { + return { exists: false, priority: undefined, concerns: [], sections: [] }; + } + + const content = readFileSync(bearingPath, 'utf8'); + const prioritySection = extractMarkdownSection(content, 'Where are we going?'); + const concernsSection = extractMarkdownSection(content, 'What feels wrong?'); + return { + exists: true, + priority: collapseWhitespace(prioritySection), + concerns: extractBearingConcerns(concernsSection), + sections: [ + { name: 'Where are we going?', text: prioritySection, lines: splitNonEmptyLines(prioritySection) }, + { name: 'What feels wrong?', text: concernsSection, lines: splitNonEmptyLines(concernsSection) }, + ], + }; +} + +function extractMarkdownSection(content: string, heading: string): string { + const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + const match = new RegExp(`^## ${escapedHeading}\\n\\n([\\s\\S]*?)(?=\\n## |$)`, 'mu').exec(content); + return (match?.[1] ?? '').trim(); +} + +function splitNonEmptyLines(content: string): string[] { + return content + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function collapseWhitespace(content: string): string | undefined { + const normalized = content.replace(/\s+/gu, ' ').trim(); + return normalized.length === 0 ? undefined : normalized; +} + +function extractBearingConcerns(content: string): string[] { + const bulletLines = splitNonEmptyLines(content) + .filter((line) => line.startsWith('- ')) + .map((line) => line.slice(2).trim()) + .filter((line) => line.length > 0); + if (bulletLines.length > 0) { + return bulletLines; + } + const collapsed = collapseWhitespace(content); + return collapsed === undefined ? [] : [collapsed]; +} + +function rankNextWorkItem( + item: BacklogQueryItem, + status: WorkspaceStatus, + bearing: BearingSignals, +): RankedNextWorkItem { + const laneRank = nextWorkLaneRank(item.lane); + let score = 1000 - (laneRank * 100); + const signals: NextWorkSignal[] = [ + { type: 'lane', value: item.lane, source: 'frontmatter.lane' }, + ]; + const whyNow: string[] = [ + `Lane \`${item.lane}\` is near the front of the current queue.`, + ]; + + if (item.priority !== undefined) { + score += nextWorkPriorityScore(item.priority); + signals.push({ type: 'priority', value: item.priority, source: 'frontmatter.priority' }); + whyNow.push(`Priority is declared as \`${item.priority}\`.`); + } + + if (item.hasAcceptanceCriteria) { + score += 10; + signals.push({ type: 'acceptance_criteria', value: true, source: 'frontmatter.acceptance_criteria' }); + whyNow.push('Acceptance criteria are already declared.'); + } + + if (item.owner !== undefined) { + signals.push({ type: 'owner', value: item.owner, source: 'frontmatter.owner' }); + } + + if (item.blockedBy.length === 0) { + score += 20; + signals.push({ type: 'dependency_ready', value: true, source: 'frontmatter.blocked_by' }); + whyNow.push('No blocking dependencies are declared.'); + } else { + score -= 60; + signals.push({ type: 'blocked_by_count', value: item.blockedBy.length, source: 'frontmatter.blocked_by' }); + whyNow.push(`${item.blockedBy.length} blocking dependenc${item.blockedBy.length === 1 ? 'y is' : 'ies are'} declared.`); + } + + if (status.activeCycles.length > 0) { + signals.push({ type: 'active_cycle_count', value: status.activeCycles.length, source: 'status.activeCycles' }); + } - for (const match of content.matchAll(cycleReferencePattern)) { - if (match.groups === undefined) { + for (const section of bearing.sections) { + const matchingLine = section.lines.find((line) => line.includes(item.stem)); + if (matchingLine === undefined) { continue; } + const bonus = section.name === 'Where are we going?' ? 250 : 180; + score += bonus; + signals.push({ + type: 'bearing_mention', + value: matchingLine, + source: `docs/BEARING.md#${section.name}`, + }); + whyNow.push(`BEARING mentions \`${item.stem}\` under "${section.name}".`); + } + + return { + item, + score, + laneRank, + signals, + whyNow, + bearingOverride: signals.some((signal) => signal.type === 'bearing_mention'), + }; +} + +function nextWorkLaneRank(lane: BacklogQueryItem['lane']): number { + if (lane === 'asap') return 0; + if (lane === 'up-next') return 1; + if (isReleaseLane(lane)) return 1; + if (lane === 'bad-code') return 3; + if (lane === 'inbox') return 4; + if (lane === 'cool-ideas') return 5; + if (lane === 'root') return 7; + return 6; +} + +function nextWorkPriorityScore(priority: string): number { + if (priority === 'critical') return 40; + if (priority === 'high') return 30; + if (priority === 'medium') return 20; + if (priority === 'low') return 10; + return 5; +} - const start = Number.parseInt(match.groups.start, 10); - const endValue = match.groups.end; - const end = endValue === undefined ? start : Number.parseInt(endValue, 10); +function compareRankedNextWorkItems(left: RankedNextWorkItem, right: RankedNextWorkItem): number { + return right.score - left.score + || left.laneRank - right.laneRank + || compareBacklogPaths(left.item, right.item); +} - if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) { +function buildNextWorkWhyNow(entry: RankedNextWorkItem): string[] { + return [...new Set(entry.whyNow)].slice(0, 4); +} + +function nextWorkScoreBand(index: number, total: number): NextWorkScoreBand { + const highestCount = Math.min(total, 2); + const strongCount = Math.min(Math.max(total - highestCount, 0), 3); + if (index < highestCount) { + return 'highest'; + } + if (index < highestCount + strongCount) { + return 'strong'; + } + return 'worth-considering'; +} + +function normalizeNextWorkLimit(value: number | undefined): number { + const limit = value ?? 3; + if (!Number.isInteger(limit) || limit <= 0 || limit > 10) { + throw new MethodError('Next-work limit must be an integer between 1 and 10.'); + } + return limit; +} + +function deriveNextWorkOverrideNotes( + baseline: RankedNextWorkItem[], + ranked: RankedNextWorkItem[], + limit: number, +): string[] { + const baselinePositions = new Map(baseline.map((entry, index) => [entry.item.path, index])); + const finalPositions = new Map(ranked.map((entry, index) => [entry.item.path, index])); + const notes: string[] = []; + + for (const entry of ranked.slice(0, Math.min(limit, 3))) { + if (!entry.bearingOverride) { continue; } + const baselinePosition = baselinePositions.get(entry.item.path); + const finalPosition = finalPositions.get(entry.item.path); + if (baselinePosition === undefined || finalPosition === undefined || finalPosition >= baselinePosition) { + continue; + } + + const movedIntoTopThree = baselinePosition >= 3 && finalPosition < 3; + const jumpedLaneOrder = baseline.some((other) => + nextWorkLaneRank(other.item.lane) < nextWorkLaneRank(entry.item.lane) + && (baselinePositions.get(other.item.path) ?? Number.POSITIVE_INFINITY) < baselinePosition + && (finalPositions.get(other.item.path) ?? Number.POSITIVE_INFINITY) > finalPosition, + ); + if (!movedIntoTopThree && !jumpedLaneOrder) { + continue; + } + + const sections = entry.signals + .filter((signal) => signal.type === 'bearing_mention') + .map((signal) => signal.source.replace('docs/BEARING.md#', '')) + .join(', '); + notes.push(`BEARING elevated \`${entry.item.stem}\` via ${sections} above the default lane ordering.`); + } + + return notes; +} + +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const normalized = value.trim(); + return normalized.length === 0 ? undefined : normalized; +} + +function normalizeRequiredMetadataString(value: string | undefined, field: string): string { + const normalized = normalizeOptionalString(value); + if (normalized === undefined) { + throw new MethodError(`${field} cannot be empty.`); + } + return normalized; +} + +function normalizeBacklogPriority(value: unknown): string | undefined { + const normalized = normalizeOptionalString(value); + return normalized?.toLowerCase(); +} + +function normalizeRequiredBacklogPriority(value: string | undefined): string { + const normalized = normalizeBacklogPriority(value); + if (normalized === undefined) { + throw new MethodError('priority cannot be empty.'); + } + return normalized; +} + +function normalizeBacklogKeywords(value: unknown): string[] { + const seen = new Set<string>(); + const keywords = normalizeDependencyRefs(value) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .filter((entry) => { + const key = entry.toLowerCase(); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + return keywords; +} + +function normalizeRequiredMetadataList(value: readonly string[] | undefined, field: string): string[] { + if (value === undefined) { + throw new MethodError(`${field} requires at least one value.`); + } + const normalized = normalizeBacklogKeywords(value); + if (normalized.length === 0) { + throw new MethodError(`${field} requires at least one non-empty value.`); + } + return normalized; +} + +function applyMetadataEditField<T>(options: { + setValue: T | undefined; + clearValue: boolean; + field: string; + normalize: (value: T) => unknown; + target: Record<string, unknown>; + updatedFields: string[]; +}): void { + if (options.setValue !== undefined && options.clearValue) { + throw new MethodError(`Cannot both set and clear ${options.field}.`); + } + if (options.setValue === undefined && !options.clearValue) { + return; + } + + options.target[options.field] = options.clearValue + ? undefined + : options.normalize(options.setValue as T); + options.updatedFields.push(options.field); +} + +function normalizeDependencyRefs(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((entry) => String(entry).trim()) + .filter((entry) => entry.length > 0); + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + const normalized = String(value).trim(); + return normalized.length === 0 ? [] : [normalized]; + } + return []; +} + +function resolveDependencyItemRef( + ref: string, + items: ReadonlyArray<{ path: string; stem: string; slug: string }>, +): string | undefined { + const normalized = ref.trim(); + if (normalized.length === 0) { + return undefined; + } + + const exactMatches = items.filter((item) => + item.path === normalized + || basename(item.path) === normalized + || item.stem === normalized + || item.slug === normalized); + if (exactMatches.length === 1) { + return exactMatches[0].path; + } + if (exactMatches.length > 1) { + return undefined; + } + + const lower = normalized.toLowerCase(); + const fuzzyMatches = items.filter((item) => + item.path.toLowerCase() === lower + || basename(item.path).toLowerCase() === lower + || item.stem.toLowerCase() === lower + || item.slug.toLowerCase() === lower); + return fuzzyMatches.length === 1 ? fuzzyMatches[0].path : undefined; +} + +function finalizeDependencyItem(item: { + stem: string; + lane: BacklogLane; + path: string; + legend?: string; + slug: string; + title?: string; + blockedBySet: Set<string>; + blocksSet: Set<string>; + unresolvedBlockedBySet: Set<string>; + unresolvedBlocksSet: Set<string>; +}): BacklogDependencyItem { + const blockedBy = [...item.blockedBySet].sort(); + const blocks = [...item.blocksSet].sort(); + const unresolvedBlockedBy = [...item.unresolvedBlockedBySet].sort(); + const unresolvedBlocks = [...item.unresolvedBlocksSet].sort(); + return { + stem: item.stem, + lane: item.lane, + path: item.path, + legend: item.legend, + slug: item.slug, + title: item.title, + blockedBy, + blocks, + unresolvedBlockedBy, + unresolvedBlocks, + ready: blockedBy.length === 0 && unresolvedBlockedBy.length === 0, + }; +} + +function compareBacklogPaths( + left: { path: string }, + right: { path: string }, +): number { + return left.path.localeCompare(right.path); +} + +function compareBacklogQueryItems( + left: BacklogQueryItem, + right: BacklogQueryItem, + sort: BacklogQuerySort, +): number { + if (sort === 'path') { + return compareBacklogPaths(left, right); + } + if (sort === 'priority') { + const priorityRank = compareBacklogQueryPriority(left.priority, right.priority); + if (priorityRank !== 0) { + return priorityRank; + } + } + const laneOrder = orderedBacklogLaneNames([left.lane, right.lane]); + const leftRank = laneOrder.indexOf(left.lane); + const rightRank = laneOrder.indexOf(right.lane); + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + return compareBacklogPaths(left, right); +} + +function compareBacklogQueryPriority( + left: string | undefined, + right: string | undefined, +): number { + return backlogQueryPriorityRank(right) - backlogQueryPriorityRank(left); +} + +function backlogQueryPriorityRank(priority: string | undefined): number { + if (priority === 'critical') return 4; + if (priority === 'high') return 3; + if (priority === 'medium') return 2; + if (priority === 'low') return 1; + return 0; +} + +function filterBacklogQueryItems( + items: readonly BacklogQueryItem[], + filters: { + lane?: BacklogLane; + legend?: string; + priority?: string; + keyword?: string; + owner?: string; + ready?: boolean; + hasAcceptanceCriteria?: boolean; + blockedBy?: string; + blocks?: string; + }, +): BacklogQueryItem[] { + return items.filter((item) => { + if (filters.lane !== undefined && item.lane !== filters.lane) { + return false; + } + if (filters.legend !== undefined && item.legend !== filters.legend) { + return false; + } + if (filters.priority !== undefined && item.priority !== filters.priority) { + return false; + } + if (filters.keyword !== undefined && !item.keywords.some((keyword) => keyword.toLowerCase() === filters.keyword)) { + return false; + } + if (filters.owner !== undefined && item.owner?.toLowerCase() !== filters.owner) { + return false; + } + if (filters.ready !== undefined && (item.blockedBy.length === 0) !== filters.ready) { + return false; + } + if (filters.hasAcceptanceCriteria !== undefined && item.hasAcceptanceCriteria !== filters.hasAcceptanceCriteria) { + return false; + } + if (filters.blockedBy !== undefined && !item.blockedBy.some((ref) => ref.toLowerCase() === filters.blockedBy)) { + return false; + } + if (filters.blocks !== undefined && !item.blocks.some((ref) => ref.toLowerCase() === filters.blocks)) { + return false; + } + return true; + }); +} + +function summarizeNextWorkFilters(filters: { + lane?: BacklogLane; + legend?: string; + priority?: string; + keyword?: string; + owner?: string; +}): string | undefined { + const parts = [ + filters.lane === undefined ? undefined : `lane=${filters.lane}`, + filters.legend === undefined ? undefined : `legend=${filters.legend}`, + filters.priority === undefined ? undefined : `priority=${filters.priority}`, + filters.keyword === undefined ? undefined : `keyword=${filters.keyword}`, + filters.owner === undefined ? undefined : `owner=${filters.owner}`, + ].filter((value): value is string => value !== undefined); + return parts.length === 0 ? undefined : parts.join(', '); +} + +function normalizeBacklogQueryLane(value: string | undefined): BacklogLane | undefined { + if (value === undefined) { + return undefined; + } + const normalized = normalizeBacklogLane(value); + if (normalized === undefined) { + throw new MethodError(`Invalid backlog lane filter: ${value}`); + } + return normalized; +} + +function normalizeBacklogQueryLegend(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + const normalized = value.trim().toUpperCase(); + if (normalized.length === 0 || !LEGEND_CODE_PATTERN.test(normalized)) { + throw new MethodError(`Invalid legend filter: ${value}`); + } + return normalized; +} + +function normalizeBacklogKeywordFilter(value: string | undefined): string | undefined { + const normalized = normalizeOptionalString(value); + return normalized?.toLowerCase(); +} + +function normalizeBacklogOwnerFilter(value: string | undefined): string | undefined { + const normalized = normalizeOptionalString(value); + return normalized?.toLowerCase(); +} + +function normalizeBacklogDependencyFilter(value: string | undefined): string | undefined { + const normalized = normalizeOptionalString(value); + return normalized?.toLowerCase(); +} + +function normalizeBacklogQuerySort(value: string | undefined): BacklogQuerySort { + const normalized = normalizeOptionalString(value); + if (normalized === undefined) { + return 'lane'; + } + if (normalized === 'lane' || normalized === 'priority' || normalized === 'path') { + return normalized; + } + throw new MethodError(`Invalid backlog query sort: ${value}`); +} + +function normalizeBacklogQueryLimit(value: number | undefined): number { + const limit = value ?? 50; + if (!Number.isInteger(limit) || limit <= 0 || limit > 100) { + throw new MethodError('Backlog query limit must be an integer between 1 and 100.'); + } + return limit; +} + +function findDependencyCycles( + nodes: readonly string[], + adjacency: ReadonlyMap<string, ReadonlySet<string>>, +): string[][] { + let index = 0; + const indexByNode = new Map<string, number>(); + const lowlinkByNode = new Map<string, number>(); + const stack: string[] = []; + const onStack = new Set<string>(); + const components: string[][] = []; + + const strongConnect = (node: string) => { + indexByNode.set(node, index); + lowlinkByNode.set(node, index); + index += 1; + stack.push(node); + onStack.add(node); + + for (const next of adjacency.get(node) ?? []) { + if (!indexByNode.has(next)) { + strongConnect(next); + lowlinkByNode.set(node, Math.min(lowlinkByNode.get(node)!, lowlinkByNode.get(next)!)); + } else if (onStack.has(next)) { + lowlinkByNode.set(node, Math.min(lowlinkByNode.get(node)!, indexByNode.get(next)!)); + } + } + + if (lowlinkByNode.get(node) !== indexByNode.get(node)) { + return; + } + + const component: string[] = []; + while (stack.length > 0) { + const current = stack.pop()!; + onStack.delete(current); + component.push(current); + if (current === node) { + break; + } + } - for (let number = start; number <= end; number += 1) { - released.add(number); + const hasSelfLoop = component.length === 1 && (adjacency.get(component[0])?.has(component[0]) ?? false); + if (component.length > 1 || hasSelfLoop) { + components.push(component.sort()); + } + }; + + for (const node of nodes) { + if (!indexByNode.has(node)) { + strongConnect(node); } } - return released; + return components.sort((left, right) => left[0]!.localeCompare(right[0]!)); +} + +function longestDependencyPathTo( + targetPath: string, + itemsByPath: ReadonlyMap<string, BacklogDependencyItem>, +): string[] { + const memo = new Map<string, string[]>(); + + const visit = (path: string): string[] => { + const existing = memo.get(path); + if (existing !== undefined) { + return existing; + } + + const current = itemsByPath.get(path); + if (current === undefined || current.blockedBy.length === 0) { + const basePath = [path]; + memo.set(path, basePath); + return basePath; + } + + let best = [path]; + for (const blocker of current.blockedBy) { + const candidate = [...visit(blocker), path]; + if ( + candidate.length > best.length + || (candidate.length === best.length && candidate.join('>').localeCompare(best.join('>')) < 0) + ) { + best = candidate; + } + } + + memo.set(path, best); + return best; + }; + + return visit(targetPath); +} + +function readBacklogPullContext( + path: string, + fallbackLane: BacklogLane, +): { legend?: string; slug: string; release?: string } { + const stem = fileStem(path); + const filenameMetadata = splitLegend(stem); + const frontmatter = fmReadFrontmatter(path); + const hasExplicitLegend = Object.prototype.hasOwnProperty.call(frontmatter, 'legend'); + const lane = normalizeBacklogLane(frontmatter.lane) ?? fallbackLane; + return { + legend: hasExplicitLegend ? normalizeLegend(frontmatter.legend) : filenameMetadata.legend, + slug: filenameMetadata.slug, + release: inferBacklogRelease(lane, frontmatter.release), + }; +} + +function inferBacklogRelease( + lane: string, + explicitRelease: unknown, +): string | undefined { + const normalized = normalizeOptionalString(explicitRelease); + if (normalized !== undefined) { + if (!isReleaseLane(normalized)) { + throw new MethodError(`Invalid release tag: ${normalized}`); + } + return normalized; + } + return isReleaseLane(lane) ? lane : undefined; +} + +function resolveCyclePacketPaths( + root: string, + paths: ResolvedPaths, + cycleName: string, + release?: string, +): { designDoc: string; retroDoc: string } { + if (release === undefined) { + return { + designDoc: resolve(paths.design, `${cycleName}.md`), + retroDoc: resolve(paths.retro, cycleName, `${cycleName}.md`), + }; + } + + return { + designDoc: resolve(root, 'docs/releases', release, 'design', `${cycleName}.md`), + retroDoc: resolve(root, 'docs/releases', release, 'retros', cycleName, `${cycleName}.md`), + }; +} + +function readCycleFromDoc( + root: string, + paths: ResolvedPaths, + file: string, +): Cycle | undefined { + // Flat design doc: docs/design/<cycleName>.md + if (isFlatDesignDocPath(paths, file)) { + const stem = fileStem(file); + const nameMatch = CYCLE_NAME_PATTERN.exec(stem); + if (nameMatch?.groups === undefined) { + return undefined; + } + return { + name: stem, + slug: nameMatch.groups.slug, + designDoc: file, + retroDoc: inferRetroDocFromDesign(root, paths, file), + }; + } + + // Retro in dir: docs/method/retro/<cycleName>/<cycleName>.md + if (isRetroDocPath(paths, file)) { + const retroDir = basename(dirname(file)); + const stem = fileStem(file); + if (stem !== retroDir) { + return undefined; + } + const nameMatch = CYCLE_NAME_PATTERN.exec(stem); + if (nameMatch?.groups === undefined) { + return undefined; + } + return { + name: stem, + slug: nameMatch.groups.slug, + designDoc: inferDesignDocFromRetro(root, paths, file), + retroDoc: file, + }; + } + + // Flat release design doc: docs/releases/v1.0.0/design/<cycleName>.md + if (isReleaseDesignDocPath(root, file) && dirname(file) !== file) { + const parentDir = basename(dirname(file)); + if (parentDir === 'design') { + const stem = fileStem(file); + const nameMatch = CYCLE_NAME_PATTERN.exec(stem); + if (nameMatch?.groups !== undefined) { + return { + name: stem, + slug: nameMatch.groups.slug, + designDoc: file, + retroDoc: inferRetroDocFromDesign(root, paths, file), + }; + } + } + } + + // Release retro dir: docs/releases/v1.0.0/retros/<cycleName>/<cycleName>.md + if (isReleaseRetroDocPath(root, file)) { + const retroDir = basename(dirname(file)); + const stem = fileStem(file); + if (stem === retroDir) { + const nameMatch = CYCLE_NAME_PATTERN.exec(stem); + if (nameMatch?.groups !== undefined) { + return { + name: stem, + slug: nameMatch.groups.slug, + designDoc: inferDesignDocFromRetro(root, paths, file), + retroDoc: file, + }; + } + } + } + + // Legacy layout: docs/design/<NNNN-slug>/<slug>.md or retro/<NNNN-slug>/<slug>.md + const cycleDir = basename(dirname(file)); + const legacyMatch = LEGACY_CYCLE_PATTERN.exec(cycleDir); + if (legacyMatch?.groups === undefined) { + return undefined; + } + + const slug = legacyMatch.groups.slug; + if (fileStem(file) !== slug) { + return undefined; + } + + if (isDesignDocPath(paths, file) || isReleaseDesignDocPath(root, file)) { + return { + name: cycleDir, + slug, + designDoc: file, + retroDoc: inferRetroDocFromDesign(root, paths, file), + }; + } + if (isRetroDocPath(paths, file) || isReleaseRetroDocPath(root, file)) { + return { + name: cycleDir, + slug, + designDoc: inferDesignDocFromRetro(root, paths, file), + retroDoc: file, + }; + } + return undefined; +} + +function isFlatDesignDocPath(paths: ResolvedPaths, file: string): boolean { + return dirname(file) === paths.design && file.endsWith('.md'); +} + +function isDesignDocPath(paths: ResolvedPaths, file: string): boolean { + return file.startsWith(`${paths.design}/`); +} + +function isRetroDocPath(paths: ResolvedPaths, file: string): boolean { + return file.startsWith(`${paths.retro}/`); +} + +function isReleaseDesignDocPath(root: string, file: string): boolean { + const normalized = normalizeRepoPath(relative(root, file)); + return /^docs\/releases\/v\d+\.\d+\.\d+\/design\//u.test(normalized); +} + +function isReleaseRetroDocPath(root: string, file: string): boolean { + const normalized = normalizeRepoPath(relative(root, file)); + return /^docs\/releases\/v\d+\.\d+\.\d+\/retros\//u.test(normalized); +} + +function inferRetroDocFromDesign(root: string, paths: ResolvedPaths, designDoc: string): string { + if (isReleaseDesignDocPath(root, designDoc)) { + const relPath = normalizeRepoPath(relative(root, designDoc)); + const parentDir = basename(dirname(designDoc)); + const stem = fileStem(designDoc); + // Flat release design: docs/releases/v1.0.0/design/CYCLE.md → retros/CYCLE/CYCLE.md + if (parentDir === 'design') { + const retroPath = relPath.replace(/\/design\/[^/]+\.md$/u, `/retros/${stem}/${stem}.md`); + return resolve(root, retroPath); + } + // Legacy release design: docs/releases/v1.0.0/design/NNNN-slug/slug.md → retros/NNNN-slug/slug.md + return resolve(root, normalizeRepoPath(relative(root, designDoc)).replace('/design/', '/retros/')); + } + // Flat design doc: docs/design/CYCLE.md → docs/method/retro/CYCLE/CYCLE.md + if (isFlatDesignDocPath(paths, designDoc)) { + const stem = fileStem(designDoc); + return resolve(paths.retro, stem, `${stem}.md`); + } + // Legacy design doc: docs/design/<dir>/<slug>.md → docs/method/retro/<dir>/<slug>.md + const cycleDir = basename(dirname(designDoc)); + const slug = fileStem(designDoc); + return resolve(paths.retro, cycleDir, `${slug}.md`); +} + +function inferDesignDocFromRetro(root: string, paths: ResolvedPaths, retroDoc: string): string { + if (isReleaseRetroDocPath(root, retroDoc)) { + const relPath = normalizeRepoPath(relative(root, retroDoc)); + const retroDir = basename(dirname(retroDoc)); + const stem = fileStem(retroDoc); + // New format: dir name = file stem → flat design + if (retroDir === stem) { + const designPath = relPath.replace(/\/retros\/[^/]+\/[^/]+\.md$/u, `/design/${stem}.md`); + return resolve(root, designPath); + } + // Legacy format: dir name = NNNN-slug, file stem = slug → nested design + return resolve(root, normalizeRepoPath(relative(root, retroDoc)).replace('/retros/', '/design/')); + } + const retroDir = basename(dirname(retroDoc)); + const stem = fileStem(retroDoc); + // New format: dir name = cycle name = file stem → flat design doc + if (retroDir === stem) { + return resolve(paths.design, `${stem}.md`); + } + // Legacy format: dir name = NNNN-slug → nested design doc + return resolve(paths.design, retroDir, `${stem}.md`); +} + +function readCycleRelease(cycle: Cycle): string | undefined { + const frontmatter = fmReadFrontmatter(cycle.designDoc); + const release = normalizeOptionalString(frontmatter.release); + if (release === undefined) { + return undefined; + } + if (!isReleaseLane(release)) { + throw new MethodError(`Invalid release tag: ${release}`); + } + return release; +} + +function normalizeRepoPath(value: string): string { + return value.replace(/\\/gu, '/'); } function slugify(value: string): string { @@ -662,15 +2100,33 @@ function fileStem(path: string): string { 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; +function createBacklogBuckets(): WorkspaceStatus['backlog'] { + return Object.fromEntries( + [...LANES, 'root'].map((lane) => [lane, [] as BacklogItem[]]), + ) as WorkspaceStatus['backlog']; +} + +function orderBacklogBuckets(backlog: WorkspaceStatus['backlog']): WorkspaceStatus['backlog'] { + return Object.fromEntries( + orderedBacklogLaneNames(Object.keys(backlog)).map((lane) => [lane, backlog[lane] ?? []]), + ); +} + +function inferBacklogLane(backlogRoot: string, path: string): BacklogLane { + const segments = relative(backlogRoot, path) + .split(/[\\/]/u) + .filter((segment) => segment.length > 0); + if (segments.length < 2) { + return 'root'; } - return 'root'; + + const inferredLane = normalizeBacklogLane(segments[0]); + return inferredLane === undefined || inferredLane === 'root' + ? 'root' + : inferredLane; } -function normalizeBacklogLane(value: string | undefined): Lane | 'root' | undefined { +function normalizeBacklogLane(value: string | undefined): BacklogLane | undefined { const normalized = value?.trim(); if (normalized === undefined || normalized.length === 0) { return undefined; @@ -678,12 +2134,39 @@ function normalizeBacklogLane(value: string | undefined): Lane | 'root' | undefi if (normalized === 'root') { return normalized; } - if ((LANES as readonly string[]).includes(normalized)) { + if (isLaneName(normalized)) { return normalized as Lane; } return undefined; } +function normalizeLiveBacklogLane(value: string | undefined): Lane | undefined { + const normalized = normalizeBacklogLane(value); + if (normalized === undefined || normalized === 'root') { + return undefined; + } + return normalized as Lane; +} + +function normalizeRequestedLegend(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + const normalized = value.trim().toUpperCase(); + if (!LEGEND_CODE_PATTERN.test(normalized)) { + throw new MethodError('Legend codes must be uppercase letters and numbers.'); + } + return normalized; +} + +function normalizeCapturedAt(value: string | undefined): string { + const normalized = value?.trim() ?? new Date().toISOString().slice(0, 10); + if (!FEEDBACK_DATE_PATTERN.test(normalized)) { + throw new MethodError('captured_at must use YYYY-MM-DD.'); + } + return normalized; +} + function normalizeLegend(value: string | undefined): string | undefined { const normalized = value?.trim().toUpperCase(); return normalized === undefined @@ -694,6 +2177,27 @@ function normalizeLegend(value: string | undefined): string | undefined { : normalized; } +function isLiveBacklogFile(backlogRoot: string, file: string): boolean { + if (!file.startsWith(backlogRoot)) { + return false; + } + const relativePath = relative(backlogRoot, file); + return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath); +} + +function renderRetiredBody(reason: string, originalProposal: string, replacement?: string): string { + const replacementLine = replacement === undefined ? '' : `\n\nReplacement: \`${replacement}\``; + return [ + '## Disposition', + '', + `${reason}${replacementLine}`, + '', + '## Original Proposal', + '', + originalProposal.trim(), + ].join('\n'); +} + function sanitizeWitnessOutput(value: string, root: string): string { return value .replaceAll(root, '.') diff --git a/src/mcp.ts b/src/mcp.ts index b06b12b..471cfdf 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,9 +1,9 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { basename, relative } from 'node:path'; +import { relative } from 'node:path'; import { Workspace } from './index.js'; import { GitHubAdapter, type GitHubSyncResult } from './adapters/github.js'; -import { renderDoctorText, runDoctor } from './doctor.js'; +import { renderDoctorMigrateText, renderDoctorRepairText, renderDoctorText, runDoctor, runDoctorMigrate, runDoctorRepair } from './doctor.js'; import type { Cycle, Outcome, WorkspaceStatus } from './domain.js'; import { queryReviewState, renderReviewStateText, type ReviewStateQueryOptions } from './review-state.js'; @@ -22,7 +22,7 @@ export interface McpToolDef { description: string; inputSchema: { type: 'object'; - properties: Record<string, { type: string; description?: string; enum?: string[]; minimum?: number }>; + properties: Record<string, { type: string; description?: string; enum?: string[]; minimum?: number; items?: { type: string } }>; required: string[]; }; } @@ -37,6 +37,27 @@ export const MCP_TOOLS: McpToolDef[] = [ required: ['workspace'], }, }, + { + name: 'method_repair', + description: 'Plan or apply bounded doctor-guided repairs for missing directories, missing scaffold files, and frontmatter stubs.', + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + mode: { type: 'string', enum: ['plan', 'apply'], description: 'Whether to return a repair plan or apply the same bounded repair set.' }, + }, + required: ['workspace', 'mode'], + }, + }, + { + name: 'method_migrate', + description: 'Run doctor, apply the bounded repair set, then re-check the workspace so callers can normalize a repo in one step.', + inputSchema: { + type: 'object', + properties: { ...workspaceProperty }, + required: ['workspace'], + }, + }, { name: 'method_review_state', description: 'Get PR review / merge-readiness state for the current branch or an explicit PR. `pr` and `currentBranch` are mutually exclusive; when `pr` is omitted, current-branch resolution is the default behavior.', @@ -67,13 +88,161 @@ export const MCP_TOOLS: McpToolDef[] = [ description: 'Capture a new raw idea into the inbox', inputSchema: { type: 'object', - properties: { ...workspaceProperty, idea: { type: 'string' }, legend: { type: 'string' }, title: { type: 'string' } }, + properties: { + ...workspaceProperty, + idea: { type: 'string' }, + legend: { type: 'string' }, + title: { type: 'string' }, + body: { type: 'string', description: 'Optional markdown body. Defaults to the idea text when omitted.' }, + source: { type: 'string', description: 'Optional source such as a reviewer, channel, or system.' }, + capturedAt: { type: 'string', description: 'Optional capture date in YYYY-MM-DD format.' }, + }, required: ['workspace', 'idea'], }, }, + { + name: 'method_backlog_add', + description: 'Create a shaped backlog note directly in the requested backlog lane.', + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + lane: { type: 'string', description: 'Destination backlog lane such as `bad-code` or `v1.1.0`.' }, + title: { type: 'string', description: 'Backlog note title used for frontmatter, heading, and slug derivation.' }, + legend: { type: 'string', description: 'Optional legend code prefix such as PROCESS.' }, + body: { type: 'string', description: 'Optional markdown body to place under the heading.' }, + }, + required: ['workspace', 'lane', 'title'], + }, + }, + { + name: 'method_backlog_move', + description: 'Move a live backlog note into another backlog lane.', + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + item: { type: 'string', description: 'Backlog path, stem, or slug that resolves to exactly one live backlog note.' }, + to: { type: 'string', description: 'Destination backlog lane such as `asap`, `bad-code`, or `v1.1.0`.' }, + }, + required: ['workspace', 'item', 'to'], + }, + }, + { + name: 'method_backlog_edit', + description: 'Update explicit schema-backed metadata on a live backlog note without opening arbitrary frontmatter editing.', + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + item: { type: 'string', description: 'Backlog path, stem, or slug that resolves to exactly one live backlog note.' }, + owner: { type: 'string', description: 'Optional owner role to set.' }, + clearOwner: { type: 'boolean', description: 'When true, remove the owner field.' }, + priority: { type: 'string', description: 'Optional priority to set, such as `high` or `medium`.' }, + clearPriority: { type: 'boolean', description: 'When true, remove the priority field.' }, + keywords: { type: 'array', items: { type: 'string' }, description: 'Optional replacement keyword list.' }, + clearKeywords: { type: 'boolean', description: 'When true, remove the keywords field.' }, + blockedBy: { type: 'array', items: { type: 'string' }, description: 'Optional replacement `blocked_by` reference list.' }, + clearBlockedBy: { type: 'boolean', description: 'When true, remove the `blocked_by` field.' }, + blocks: { type: 'array', items: { type: 'string' }, description: 'Optional replacement `blocks` reference list.' }, + clearBlocks: { type: 'boolean', description: 'When true, remove the `blocks` field.' }, + }, + required: ['workspace', 'item'], + }, + }, + { + name: 'method_backlog_query', + description: 'Enumerate live backlog items as structured data with explicit frontmatter metadata such as owner, priority, keywords, and declared dependency refs.', + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + lane: { type: 'string', description: 'Optional backlog lane filter such as `bad-code` or `v1.1.0`.' }, + legend: { type: 'string', description: 'Optional legend filter such as PROCESS.' }, + priority: { type: 'string', description: 'Optional priority filter such as `medium`.' }, + keyword: { type: 'string', description: 'Optional explicit frontmatter keyword filter.' }, + owner: { type: 'string', description: 'Optional explicit frontmatter owner filter.' }, + ready: { type: 'boolean', description: 'Optional readiness filter. `true` returns items without declared `blocked_by` refs; `false` returns blocked items.' }, + hasAcceptanceCriteria: { type: 'boolean', description: 'Optional acceptance-criteria presence filter.' }, + blockedBy: { type: 'string', description: 'Optional declared `blocked_by` reference filter.' }, + blocks: { type: 'string', description: 'Optional declared `blocks` reference filter.' }, + sort: { type: 'string', description: 'Optional backlog query sort mode: `lane`, `priority`, or `path`. Defaults to `lane`.' }, + limit: { type: 'integer', minimum: 1, description: 'Maximum number of returned items. Defaults to 50 and may not exceed 100.' }, + }, + required: ['workspace'], + }, + }, + { + name: 'method_backlog_dependencies', + description: 'Return the live backlog dependency graph from `blocked_by` / `blocks` frontmatter, optionally focusing on one item, ready work, or the critical path.', + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + item: { type: 'string', description: 'Optional backlog path, stem, or slug to focus on.' }, + readyOnly: { type: 'boolean', description: 'When true, request the unblocked layer-0 backlog items.' }, + criticalPath: { type: 'boolean', description: 'When true, include the longest blocker chain to the focused item. Requires `item`.' }, + }, + required: ['workspace'], + }, + }, + { + name: 'method_next_work', + description: 'Return a bounded advisory menu of sensible next backlog items using lane order, declared frontmatter, dependency readiness, current status, and literal BEARING mentions.', + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + lane: { type: 'string', description: 'Optional backlog lane filter such as `asap` or `v1.1.0`.' }, + legend: { type: 'string', description: 'Optional legend filter such as PROCESS.' }, + priority: { type: 'string', description: 'Optional priority filter such as `high`.' }, + keyword: { type: 'string', description: 'Optional explicit frontmatter keyword filter.' }, + owner: { type: 'string', description: 'Optional explicit frontmatter owner filter.' }, + includeBlocked: { type: 'boolean', description: 'When true, keep blocked items in the candidate set even when ready work exists.' }, + limit: { type: 'integer', minimum: 1, description: 'Maximum number of recommendations. Defaults to 3 and may not exceed 10.' }, + }, + required: ['workspace'], + }, + }, + { + name: 'method_signpost_status', + description: 'Report which expected repo signposts exist, which are missing, and which can be initialized by helper commands.', + inputSchema: { + type: 'object', + properties: { ...workspaceProperty }, + required: ['workspace'], + }, + }, + { + name: 'method_signpost_init', + description: 'Initialize a narrowly supported missing canonical signpost such as BEARING, MCP, CLI, GUIDE, or ARCHITECTURE.', + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + name: { type: 'string', description: 'Canonical signpost name or path, such as BEARING or docs/MCP.md.' }, + }, + required: ['workspace', 'name'], + }, + }, + { + name: 'method_retire', + description: 'Retire a live backlog note into the graveyard with an explicit disposition note instead of silently deleting it.', + inputSchema: { + type: 'object', + properties: { + ...workspaceProperty, + item: { type: 'string', description: 'Live backlog path, stem, or slug that resolves to exactly one backlog note.' }, + reason: { type: 'string', description: 'Required retirement reason recorded under a graveyard Disposition section.' }, + replacement: { type: 'string', description: 'Optional replacement path or successor reference to record in the tombstone.' }, + dryRun: { type: 'boolean', description: 'When true, return the planned graveyard move without mutating the repo.' }, + }, + required: ['workspace', 'item', 'reason'], + }, + }, { name: 'method_pull', - description: 'Promote a backlog item into the next numbered cycle', + description: 'Promote a backlog item into a new cycle packet, using release-scoped paths when the backlog item carries release metadata.', inputSchema: { type: 'object', properties: { ...workspaceProperty, item: { type: 'string' } }, required: ['workspace', 'item'] }, }, { @@ -83,7 +252,7 @@ export const MCP_TOOLS: McpToolDef[] = [ }, { name: 'method_close', - description: 'Close an active cycle into a retro', + description: 'Close an active cycle into its retro packet.', inputSchema: { type: 'object', properties: { @@ -100,6 +269,11 @@ export const MCP_TOOLS: McpToolDef[] = [ description: 'Perform the Ship Sync maneuver (update CHANGELOG.md and BEARING.md)', inputSchema: { type: 'object', properties: { ...workspaceProperty }, required: ['workspace'] }, }, + { + name: 'method_sync_refs', + description: 'Refresh generated reference docs without mutating ship-only artifacts such as CHANGELOG.md or docs/BEARING.md.', + inputSchema: { type: 'object', properties: { ...workspaceProperty }, required: ['workspace'] }, + }, { name: 'method_sync_github', description: 'Synchronize backlog with GitHub Issues', @@ -151,9 +325,47 @@ export function createMcpServer(options: CreateMcpServerOptions = {}) { ); } + if (request.params.name === 'method_repair') { + const result = runDoctorRepair(workspacePath, validateRepairMode(args.mode)); + return successResult( + 'method_repair', + renderDoctorRepairText(result), + result, + ); + } + + if (request.params.name === 'method_migrate') { + const result = runDoctorMigrate(workspacePath); + return successResult( + 'method_migrate', + renderDoctorMigrateText(result), + result, + ); + } + const workspace = new Workspace(workspacePath); workspace.ensureInitialized(); + if (request.params.name === 'method_signpost_status') { + const result = workspace.signpostStatus(); + return successResult( + 'method_signpost_status', + `Found ${result.signposts.length} expected signposts with ${result.missing.length} missing.`, + result, + ); + } + + if (request.params.name === 'method_signpost_init') { + const result = await workspace.initSignpost(validateString(args.name, 'name')); + return successResult( + 'method_signpost_init', + result.initializedTargets.length > 0 + ? `Initialized ${result.initializedTargets.join(', ')}` + : `Skipped ${result.skippedPaths.join(', ')}`, + result, + ); + } + if (request.params.name === 'method_review_state') { const pr = validateOptionalInteger(args.pr, 'pr'); const currentBranchArg = validateOptionalBoolean(args.currentBranch, 'currentBranch'); @@ -193,13 +405,18 @@ export function createMcpServer(options: CreateMcpServerOptions = {}) { } if (request.params.name === 'method_inbox') { - const path = workspace.captureIdea( - args.idea as string, - args.legend as string | undefined, - args.title as string | undefined, + const path = workspace.captureIdeaWithMetadata( + validateString(args.idea, 'idea'), + validateOptionalString(args.legend, 'legend'), + validateOptionalString(args.title, 'title'), + { + body: validateOptionalText(args.body, 'body'), + source: validateOptionalString(args.source, 'source'), + capturedAt: validateOptionalString(args.capturedAt, 'capturedAt'), + }, ); const relativePath = relative(workspace.root, path); - const persistedItem = readPersistedInboxResult(workspace, relativePath); + const persistedItem = workspace.describeBacklogPath(relativePath); return successResult( 'method_inbox', `Captured to ${relativePath}`, @@ -207,6 +424,142 @@ export function createMcpServer(options: CreateMcpServerOptions = {}) { ); } + if (request.params.name === 'method_backlog_add') { + const path = workspace.createBacklogItem({ + lane: validateString(args.lane, 'lane'), + title: validateString(args.title, 'title'), + legend: validateOptionalString(args.legend, 'legend'), + body: validateOptionalText(args.body, 'body'), + }); + const relativePath = relative(workspace.root, path); + const persistedItem = workspace.describeBacklogPath(relativePath); + return successResult( + 'method_backlog_add', + `Created ${relativePath}`, + persistedItem, + ); + } + + if (request.params.name === 'method_backlog_move') { + const sourcePath = workspace.resolveBacklogPath(validateString(args.item, 'item')); + const destinationPath = workspace.moveBacklogItem( + sourcePath, + validateString(args.to, 'to'), + ); + const persistedItem = workspace.describeBacklogPath(destinationPath); + return successResult( + 'method_backlog_move', + `Moved ${sourcePath} to ${destinationPath}`, + { + sourcePath, + ...persistedItem, + }, + ); + } + + if (request.params.name === 'method_backlog_edit') { + const result = workspace.editBacklogMetadata(validateString(args.item, 'item'), { + owner: validateOptionalString(args.owner, 'owner'), + clearOwner: validateOptionalBoolean(args.clearOwner, 'clearOwner'), + priority: validateOptionalString(args.priority, 'priority'), + clearPriority: validateOptionalBoolean(args.clearPriority, 'clearPriority'), + keywords: validateOptionalStringArray(args.keywords, 'keywords'), + clearKeywords: validateOptionalBoolean(args.clearKeywords, 'clearKeywords'), + blockedBy: validateOptionalStringArray(args.blockedBy, 'blockedBy'), + clearBlockedBy: validateOptionalBoolean(args.clearBlockedBy, 'clearBlockedBy'), + blocks: validateOptionalStringArray(args.blocks, 'blocks'), + clearBlocks: validateOptionalBoolean(args.clearBlocks, 'clearBlocks'), + }); + return successResult( + 'method_backlog_edit', + summarizeBacklogEdit(result), + result, + ); + } + + if (request.params.name === 'method_backlog_query') { + const limit = validateOptionalInteger(args.limit, 'limit'); + if (limit !== undefined && limit > 100) { + throw new Error('limit must be between 1 and 100.'); + } + const result = workspace.backlogQuery({ + lane: validateOptionalString(args.lane, 'lane'), + legend: validateOptionalString(args.legend, 'legend'), + priority: validateOptionalString(args.priority, 'priority'), + keyword: validateOptionalString(args.keyword, 'keyword'), + owner: validateOptionalString(args.owner, 'owner'), + ready: validateOptionalBoolean(args.ready, 'ready'), + hasAcceptanceCriteria: validateOptionalBoolean(args.hasAcceptanceCriteria, 'hasAcceptanceCriteria'), + blockedBy: validateOptionalString(args.blockedBy, 'blockedBy'), + blocks: validateOptionalString(args.blocks, 'blocks'), + sort: validateOptionalString(args.sort, 'sort'), + limit, + }); + return successResult( + 'method_backlog_query', + summarizeBacklogQuery(result), + result, + ); + } + + if (request.params.name === 'method_backlog_dependencies') { + const item = validateOptionalString(args.item, 'item'); + const readyOnly = validateOptionalBoolean(args.readyOnly, 'readyOnly') ?? false; + const criticalPath = validateOptionalBoolean(args.criticalPath, 'criticalPath') ?? false; + if (readyOnly && item !== undefined) { + throw new Error('method_backlog_dependencies does not accept item when readyOnly is true.'); + } + if (readyOnly && criticalPath) { + throw new Error('method_backlog_dependencies does not combine readyOnly with criticalPath.'); + } + if (criticalPath && item === undefined) { + throw new Error('method_backlog_dependencies requires item when criticalPath is true.'); + } + const result = workspace.backlogDependencies({ item, readyOnly, criticalPath }); + return successResult( + 'method_backlog_dependencies', + summarizeBacklogDependencies(result), + result, + ); + } + + if (request.params.name === 'method_next_work') { + const limit = validateOptionalInteger(args.limit, 'limit'); + if (limit !== undefined && limit > 10) { + throw new Error('limit must be between 1 and 10.'); + } + const result = workspace.nextWork({ + lane: validateOptionalString(args.lane, 'lane'), + legend: validateOptionalString(args.legend, 'legend'), + priority: validateOptionalString(args.priority, 'priority'), + keyword: validateOptionalString(args.keyword, 'keyword'), + owner: validateOptionalString(args.owner, 'owner'), + includeBlocked: validateOptionalBoolean(args.includeBlocked, 'includeBlocked'), + limit, + }); + return successResult( + 'method_next_work', + summarizeNextWork(result), + result, + ); + } + + if (request.params.name === 'method_retire') { + const result = workspace.retireBacklogItem( + validateString(args.item, 'item'), + validateText(args.reason, 'reason'), + validateOptionalString(args.replacement, 'replacement'), + { dryRun: validateOptionalBoolean(args.dryRun, 'dryRun') ?? false }, + ); + return successResult( + 'method_retire', + result.dryRun + ? `Planned retirement of ${result.sourcePath} to ${result.graveyardPath}` + : `Retired ${result.sourcePath} to ${result.graveyardPath}`, + result, + ); + } + if (request.params.name === 'method_pull') { const cycle = workspace.pullItem(args.item as string); return successResult( @@ -266,6 +619,17 @@ export function createMcpServer(options: CreateMcpServerOptions = {}) { ); } + if (request.params.name === 'method_sync_refs') { + const result = workspace.syncRefs(); + return successResult( + 'method_sync_refs', + result.targets.length === 0 + ? 'No generated reference targets.' + : `Refreshed ${result.targets.join(', ')}`, + result, + ); + } + if (request.params.name === 'method_sync_github') { const validatedPush = validateOptionalBoolean(args.push, 'push'); const validatedPull = validateOptionalBoolean(args.pull, 'pull'); @@ -386,23 +750,7 @@ function renderStatusSummaryText(summary: McpStatusSummary): string { ].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 }) { +function relativizeCycle(workspace: Workspace, cycle: { name: string; slug: string; designDoc: string; retroDoc: string }) { return { ...cycle, designDoc: relative(workspace.root, cycle.designDoc), @@ -419,6 +767,70 @@ function summarizeGitHubSync(pushResults: GitHubSyncResult[], pullResults: GitHu return `GitHub sync processed ${changed.length} item(s) with ${failures} error(s).`; } +function summarizeBacklogQuery(result: { + returnedCount: number; + totalCount: number; + truncated: boolean; + filters: { lane?: string; legend?: string; priority?: string; keyword?: string; owner?: string; ready?: boolean; hasAcceptanceCriteria?: boolean; blockedBy?: string; blocks?: string; sort: string }; +}): string { + const filters = [ + result.filters.lane === undefined ? undefined : `lane=${result.filters.lane}`, + result.filters.legend === undefined ? undefined : `legend=${result.filters.legend}`, + result.filters.priority === undefined ? undefined : `priority=${result.filters.priority}`, + result.filters.keyword === undefined ? undefined : `keyword=${result.filters.keyword}`, + result.filters.owner === undefined ? undefined : `owner=${result.filters.owner}`, + result.filters.ready === undefined ? undefined : `ready=${result.filters.ready}`, + result.filters.hasAcceptanceCriteria === undefined ? undefined : `hasAcceptanceCriteria=${result.filters.hasAcceptanceCriteria}`, + result.filters.blockedBy === undefined ? undefined : `blockedBy=${result.filters.blockedBy}`, + result.filters.blocks === undefined ? undefined : `blocks=${result.filters.blocks}`, + `sort=${result.filters.sort}`, + ].filter((value): value is string => value !== undefined); + const prefix = filters.length === 0 ? 'Backlog query' : `Backlog query (${filters.join(', ')})`; + return `${prefix}: returned ${result.returnedCount} of ${result.totalCount} item(s)${result.truncated ? ' (truncated)' : ''}.`; +} + +function summarizeBacklogEdit(result: { + stem: string; + updatedFields: string[]; +}): string { + return `Updated backlog metadata on ${result.stem}: ${result.updatedFields.join(', ')}.`; +} + +function summarizeNextWork(result: { + recommendations: Array<{ title: string; lane: string; scoreBand: string }>; + selection_notes: string[]; +}): string { + if (result.recommendations.length === 0) { + return 'No next-work recommendations are currently available.'; + } + const summary = result.recommendations + .map((item) => `${item.title} (${item.lane}, ${item.scoreBand})`) + .join('; '); + return `Next-work menu: ${summary}${result.selection_notes.length > 0 ? `. Notes: ${result.selection_notes[0]}` : ''}`; +} + +function summarizeBacklogDependencies(result: { + items: Array<{ stem: string }>; + edges: unknown[]; + ready: Array<{ stem: string }>; + cycles: string[][]; + focus?: { item: { stem: string }; criticalPath: Array<{ stem: string }>; criticalPathReason?: string }; + query: { readyOnly: boolean; criticalPath: boolean }; +}): string { + if (result.query.readyOnly) { + return result.ready.length === 0 + ? 'No ready backlog items.' + : `Ready backlog items: ${result.ready.map((item) => item.stem).join(', ')}`; + } + if (result.focus !== undefined) { + if (result.query.criticalPath) { + return result.focus.criticalPathReason ?? `Critical path to ${result.focus.item.stem}: ${result.focus.criticalPath.map((item) => item.stem).join(' -> ')}`; + } + return `Dependency view for ${result.focus.item.stem}.`; + } + return `Dependency graph: ${result.items.length} item(s), ${result.edges.length} edge(s), ${result.ready.length} ready, ${result.cycles.length} cycle(s).`; +} + function successResult(tool: string, text: string, result: unknown) { return toolResult(tool, text, result, false); } @@ -447,41 +859,49 @@ function toolResult(tool: string, text: string, result: unknown, isError: boolea }; } -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 = /^(?<legend>[A-Z][A-Z0-9]*)_(?<slug>.+)$/u.exec(stem); - if (match?.groups === undefined) { - return { slug: stem }; +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 { legend: match.groups.legend, slug: match.groups.slug }; + return normalized; } -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 validateString(value: unknown, name: string): string { + if (typeof value !== 'string') { + throw new Error(`${name} must be a string.`); + } + const normalized = value.trim(); + if (normalized.length === 0) { + throw new Error(`${name} must not be empty.`); + } + return normalized; } -function validateOptionalString(value: unknown, name: string): string | undefined { +function validateOptionalText(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) { + return value; +} + +function validateText(value: unknown, name: string): string { + if (typeof value !== 'string') { + throw new Error(`${name} must be a string.`); + } + if (value.trim().length === 0) { throw new Error(`${name} must not be empty.`); } - return normalized; + return value; } function validateOptionalBoolean(value: unknown, name: string): boolean | undefined { @@ -511,9 +931,26 @@ function validateOptionalInteger(value: unknown, name: string): number | undefin return value; } +function validateOptionalStringArray(value: unknown, name: string): string[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + throw new Error(`${name} must be an array of strings.`); + } + 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.'); } + +function validateRepairMode(value: unknown): 'plan' | 'apply' { + if (value === 'plan' || value === 'apply') { + return value; + } + throw new Error('mode must be one of: plan, apply.'); +} diff --git a/src/renderers.ts b/src/renderers.ts index b35e1bd..0384d44 100644 --- a/src/renderers.ts +++ b/src/renderers.ts @@ -1,5 +1,5 @@ import * as path from 'node:path'; -import type { Cycle, Outcome, WorkspaceStatus } from './domain.js'; +import { isCanonicalLane, orderedBacklogLaneNames, type BacklogItem, type Cycle, type Outcome, type WorkspaceStatus } from './domain.js'; import { readHeading } from './frontmatter.js'; export function titleCase(value: string): string { @@ -12,11 +12,15 @@ 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 asap = status.backlog.asap ?? []; + const nextUp = asap.slice(0, 2); + const customPriorityLane = firstCustomPriorityLane(status); const frictionLines = deriveBearingFriction(status); const priorityLine = nextUp.length > 0 ? `Current priority: pull ${nextUp.map(i => `\`${i.stem}\``).join(' or ')} to continue the system's maturity.` - : 'Current priority: no explicit `asap` or `up-next` item is currently recorded.'; + : customPriorityLane !== undefined + ? `Current priority: focus \`${customPriorityLane.lane}\` by pulling ${customPriorityLane.items.map((item) => `\`${item.stem}\``).join(' or ')}.` + : 'Current priority: no explicit `asap` item is currently recorded.'; const shipLines = latestShips.map(cycle => { const title = readHeading(cycle.designDoc) || titleCase(cycle.slug); @@ -54,15 +58,18 @@ export function renderBearing(status: WorkspaceStatus, closedCycles: Cycle[], co function deriveBearingFriction(status: WorkspaceStatus): string[] { const lines: string[] = []; + const inbox = status.backlog.inbox ?? []; + const asap = status.backlog.asap ?? []; + const badCode = status.backlog['bad-code'] ?? []; - if (status.backlog.inbox.length > 0) { - lines.push(`- Inbox still holds ${status.backlog.inbox.length} untriaged item(s).`); + if (inbox.length > 0) { + lines.push(`- Inbox still holds ${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 (asap.length > 0) { + lines.push(`- ${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 (badCode.length > 0) { + lines.push(`- ${badCode.length} bad-code item(s) remain tracked.`); } if (status.activeCycles.length > 0) { lines.push(`- ${status.activeCycles.length} active cycle(s) are still open.`); @@ -73,6 +80,21 @@ function deriveBearingFriction(status: WorkspaceStatus): string[] { : ['- No acute coordination pain is currently recorded.']; } +function firstCustomPriorityLane(status: WorkspaceStatus): { lane: string; items: BacklogItem[] } | undefined { + for (const lane of orderedBacklogLaneNames(Object.keys(status.backlog))) { + if (lane === 'root' || isCanonicalLane(lane)) { + continue; + } + + const items = (status.backlog[lane] ?? []).slice(0, 2); + if (items.length > 0) { + return { lane, items }; + } + } + + return undefined; +} + export function renderWitnessDoc(options: { cycle: Cycle; testResult: string; @@ -87,10 +109,10 @@ export function renderWitnessDoc(options: { : options.driftResult; return [ '---', - `title: "Verification Witness for Cycle ${options.cycle.number}"`, + `title: "Verification Witness for Cycle ${options.cycle.name}"`, '---', '', - `# Verification Witness for Cycle ${options.cycle.number}`, + `# Verification Witness for Cycle ${options.cycle.name}`, '', `This witness proves that \`${title}\` now carries the required`, 'behavior and adheres to the repo invariants.', @@ -120,6 +142,7 @@ export function renderDesignDoc(options: { legend?: string; source: string; backlogBody: string; + release?: string; }): string { const legendValue = options.legend ?? 'none'; const sourcePath = normalizeRepoPath(options.source); @@ -128,6 +151,7 @@ export function renderDesignDoc(options: { `title: ${yamlString(options.title)}`, `legend: ${yamlString(legendValue)}`, `cycle: ${yamlString(options.cycleName)}`, + ...(options.release === undefined ? [] : [`release: ${yamlString(options.release)}`]), `source_backlog: ${yamlString(sourcePath)}`, '---', '', @@ -141,6 +165,10 @@ export function renderDesignDoc(options: { '- Human: Backlog operator', '- Agent: Implementation agent', '', + 'These labels are abstract roles. In this design, `user` means the served', + 'perspective, like in a user story, not a literal named person or', + 'specific agent instance.', + '', '## Hill', '', 'TBD', @@ -186,6 +214,7 @@ export function renderRetroDoc(options: { root: string; outcome: Outcome; witnessDir: string; + release?: 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.'); @@ -197,6 +226,7 @@ export function renderRetroDoc(options: { '---', `title: ${yamlString(title)}`, `cycle: ${yamlString(options.cycle.name)}`, + ...(options.release === undefined ? [] : [`release: ${yamlString(options.release)}`]), `design_doc: ${yamlString(designDoc)}`, `outcome: ${options.outcome}`, 'drift_check: yes', diff --git a/tests/api.test.ts b/tests/api.test.ts index 63200d9..69aa695 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -19,6 +19,10 @@ function createTempRoot(): string { return root; } +function ensureBacklogLane(root: string, lane: string): void { + mkdirSync(join(root, 'docs/method/backlog', lane), { recursive: true }); +} + describe('Method API', () => { it('A new `src/index.ts` (or similar) exports a `Method` class or functions that return structured data (not strings).', () => { // Workspace is the "Method" class here. @@ -56,7 +60,7 @@ describe('Method API', () => { expect(statusBefore.backlog.inbox[0].stem).toBe('PROCESS_my-title'); const cycle = workspace.pullItem('PROCESS_my-title'); - expect(cycle.name).toMatch(/^0001-my-title$/); + expect(cycle.name).toBe('PROCESS_my-title'); const statusAfter = workspace.status(); expect(statusAfter.backlog.inbox.length).toBe(0); @@ -64,6 +68,743 @@ describe('Method API', () => { expect(statusAfter.activeCycles[0].name).toBe(cycle.name); }); + it('programmatically creates a shaped backlog item in an explicit custom lane.', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + const path = workspace.createBacklogItem({ + lane: 'v1.1.0', + title: 'Backlog Add', + legend: 'PROCESS', + body: 'Backlog body.\n', + }); + + expect(path).toContain('docs/method/backlog/v1.1.0/PROCESS_backlog-add.md'); + expect(readFileSync(path, 'utf8')).toContain('# Backlog Add'); + expect(readFileSync(path, 'utf8')).toContain('Backlog body.'); + + const status = workspace.status(); + expect(status.backlog['v1.1.0']).toContainEqual({ + stem: 'PROCESS_backlog-add', + lane: 'v1.1.0', + path: 'docs/method/backlog/v1.1.0/PROCESS_backlog-add.md', + legend: 'PROCESS', + slug: 'backlog-add', + }); + }); + + it('builds a live backlog dependency graph, reports ready work, and preserves dependency arrays during lane moves.', () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_foundation.md'), + [ + '---', + 'title: "Foundation"', + 'legend: PROCESS', + 'lane: up-next', + '---', + '', + '# Foundation', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_build.md'), + [ + '---', + 'title: "Build"', + 'legend: PROCESS', + 'lane: up-next', + 'blocked_by:', + ' - foundation', + 'blocks:', + ' - finish', + '---', + '', + '# Build', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_finish.md'), + [ + '---', + 'title: "Finish"', + 'legend: PROCESS', + 'lane: up-next', + 'blocked_by:', + ' - build', + '---', + '', + '# Finish', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const report = workspace.backlogDependencies({ item: 'finish', criticalPath: true }); + + expect(report.ready.map((item) => item.stem)).toEqual(['PROCESS_foundation']); + expect(report.edges).toHaveLength(2); + expect(report.edges).toContainEqual({ + blocker: 'docs/method/backlog/up-next/PROCESS_foundation.md', + blocked: 'docs/method/backlog/up-next/PROCESS_build.md', + }); + expect(report.edges).toContainEqual({ + blocker: 'docs/method/backlog/up-next/PROCESS_build.md', + blocked: 'docs/method/backlog/up-next/PROCESS_finish.md', + }); + expect(report.focus?.item.stem).toBe('PROCESS_finish'); + expect(report.focus?.blockers.map((item) => item.stem)).toEqual(['PROCESS_build']); + expect(report.focus?.criticalPath.map((item) => item.stem)).toEqual([ + 'PROCESS_foundation', + 'PROCESS_build', + 'PROCESS_finish', + ]); + expect(report.cycles).toEqual([]); + + const moved = workspace.moveBacklogItem('docs/method/backlog/up-next/PROCESS_build.md', 'bad-code'); + const movedContent = readFileSync(join(root, moved), 'utf8'); + expect(movedContent).toContain('lane: bad-code'); + expect(movedContent).toMatch(/blocked_by:\n\s*-\s*foundation/mu); + expect(movedContent).toMatch(/blocks:\n\s*-\s*finish/mu); + }); + + it('returns bounded structured backlog query results including explicit keywords and frontmatter metadata.', () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_query-foundation.md'), + [ + '---', + 'title: "Query Foundation"', + 'legend: PROCESS', + 'lane: up-next', + 'owner: "METHOD maintainers"', + 'priority: medium', + 'keywords:', + ' - agent', + ' - query', + 'blocked_by:', + ' - setup', + 'acceptance_criteria:', + ' - "Has a query API"', + '---', + '', + '# Query Foundation', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_query-polish.md'), + [ + '---', + 'title: "Query Polish"', + 'legend: PROCESS', + 'lane: up-next', + 'priority: medium', + 'keywords:', + ' - agent', + ' - polish', + '---', + '', + '# Query Polish', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const result = workspace.backlogQuery({ lane: 'up-next', keyword: 'agent', limit: 1 }); + + expect(result.filters).toEqual({ + lane: 'up-next', + legend: undefined, + priority: undefined, + keyword: 'agent', + owner: undefined, + ready: undefined, + hasAcceptanceCriteria: undefined, + blockedBy: undefined, + blocks: undefined, + sort: 'lane', + limit: 1, + }); + expect(result.totalCount).toBe(2); + expect(result.returnedCount).toBe(1); + expect(result.truncated).toBe(true); + expect(result.items[0]).toEqual({ + stem: 'PROCESS_query-foundation', + lane: 'up-next', + path: 'docs/method/backlog/up-next/PROCESS_query-foundation.md', + legend: 'PROCESS', + slug: 'query-foundation', + title: 'Query Foundation', + owner: 'METHOD maintainers', + priority: 'medium', + keywords: ['agent', 'query'], + blockedBy: ['setup'], + blocks: [], + hasAcceptanceCriteria: true, + }); + }); + + it('filters backlog queries by explicit owner and can include blocked matches in next-work results.', () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_blocked-owner-match.md'), + [ + '---', + 'title: "Blocked Owner Match"', + 'legend: PROCESS', + 'lane: up-next', + 'owner: "Core Team"', + 'priority: medium', + 'keywords:', + ' - roadmap', + 'blocked_by:', + ' - setup', + '---', + '', + '# Blocked Owner Match', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/cool-ideas/PROCESS_ready-owner-match.md'), + [ + '---', + 'title: "Ready Owner Match"', + 'legend: PROCESS', + 'lane: cool-ideas', + 'owner: "Core Team"', + 'priority: medium', + 'keywords:', + ' - roadmap', + '---', + '', + '# Ready Owner Match', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_other-owner.md'), + [ + '---', + 'title: "Other Owner"', + 'legend: PROCESS', + 'lane: up-next', + 'owner: "Platform Team"', + 'priority: medium', + 'keywords:', + ' - roadmap', + '---', + '', + '# Other Owner', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const query = workspace.backlogQuery({ owner: 'core team', limit: 10 }); + expect(query.filters.owner).toBe('core team'); + expect(query.items.map((item) => item.stem)).toEqual([ + 'PROCESS_blocked-owner-match', + 'PROCESS_ready-owner-match', + ]); + + const readyOnly = workspace.nextWork({ owner: 'core team', keyword: 'roadmap', limit: 5 }); + expect(readyOnly.recommendations.map((item) => item.path)).toEqual([ + 'docs/method/backlog/cool-ideas/PROCESS_ready-owner-match.md', + ]); + expect(readyOnly.selection_notes).toContain('Skipped 1 blocked backlog item(s) because unblocked work is available.'); + expect(readyOnly.selection_notes).toContain('Applied filters: keyword=roadmap, owner=core team.'); + + const includeBlocked = workspace.nextWork({ owner: 'core team', keyword: 'roadmap', includeBlocked: true, limit: 5 }); + expect(includeBlocked.recommendations.map((item) => item.path)).toEqual([ + 'docs/method/backlog/up-next/PROCESS_blocked-owner-match.md', + 'docs/method/backlog/cool-ideas/PROCESS_ready-owner-match.md', + ]); + expect(includeBlocked.selection_notes).toContain('Blocked items were included because `include-blocked` was requested.'); + expect(includeBlocked.selection_notes).toContain('Applied filters: keyword=roadmap, owner=core team.'); + }); + + it('filters backlog queries by readiness, acceptance criteria presence, and declared dependency refs.', () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_query-foundation.md'), + [ + '---', + 'title: "Query Foundation"', + 'legend: PROCESS', + 'lane: up-next', + 'keywords:', + ' - query', + 'blocked_by:', + ' - setup', + 'blocks:', + ' - finish', + 'acceptance_criteria:', + ' - "Has a query API"', + '---', + '', + '# Query Foundation', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_query-polish.md'), + [ + '---', + 'title: "Query Polish"', + 'legend: PROCESS', + 'lane: up-next', + 'blocks:', + ' - finish', + '---', + '', + '# Query Polish', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const result = workspace.backlogQuery({ + ready: false, + hasAcceptanceCriteria: true, + blockedBy: 'SETUP', + blocks: 'finish', + limit: 10, + }); + + expect(result.filters.ready).toBe(false); + expect(result.filters.hasAcceptanceCriteria).toBe(true); + expect(result.filters.blockedBy).toBe('setup'); + expect(result.filters.blocks).toBe('finish'); + expect(result.items.map((item) => item.stem)).toEqual(['PROCESS_query-foundation']); + expect(result.items[0].hasAcceptanceCriteria).toBe(true); + expect(result.items[0].blockedBy).toEqual(['setup']); + expect(result.items[0].blocks).toEqual(['finish']); + }); + + it('sorts backlog query results by explicit priority or path when requested.', () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_alpha-low.md'), + [ + '---', + 'title: "Alpha Low"', + 'legend: PROCESS', + 'lane: up-next', + 'priority: low', + '---', + '', + '# Alpha Low', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/bad-code/PROCESS_beta-critical.md'), + [ + '---', + 'title: "Beta Critical"', + 'legend: PROCESS', + 'lane: bad-code', + 'priority: critical', + '---', + '', + '# Beta Critical', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/cool-ideas/PROCESS_gamma-unset.md'), + [ + '---', + 'title: "Gamma Unset"', + 'legend: PROCESS', + 'lane: cool-ideas', + '---', + '', + '# Gamma Unset', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const prioritySorted = workspace.backlogQuery({ sort: 'priority', limit: 10 }); + expect(prioritySorted.filters.sort).toBe('priority'); + expect(prioritySorted.items.map((item) => item.stem)).toEqual([ + 'PROCESS_beta-critical', + 'PROCESS_alpha-low', + 'PROCESS_gamma-unset', + ]); + + const pathSorted = workspace.backlogQuery({ sort: 'path', limit: 10 }); + expect(pathSorted.filters.sort).toBe('path'); + expect(pathSorted.items.map((item) => item.stem)).toEqual([ + 'PROCESS_beta-critical', + 'PROCESS_gamma-unset', + 'PROCESS_alpha-low', + ]); + }); + + it('edits schema-backed backlog metadata with normalized set and clear behavior.', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + workspace.createBacklogItem({ + lane: 'up-next', + title: 'Metadata Edit', + legend: 'PROCESS', + body: 'Body.\n', + }); + + const updated = workspace.editBacklogMetadata('metadata-edit', { + owner: 'Core Team', + priority: 'HIGH', + keywords: ['roadmap', 'Roadmap', 'query'], + blockedBy: ['setup', 'setup'], + blocks: ['finish', 'Finish'], + }); + + expect(updated.updatedFields).toEqual(['owner', 'priority', 'keywords', 'blocked_by', 'blocks']); + expect(updated.owner).toBe('Core Team'); + expect(updated.priority).toBe('high'); + expect(updated.keywords).toEqual(['roadmap', 'query']); + expect(updated.blockedBy).toEqual(['setup']); + expect(updated.blocks).toEqual(['finish']); + + const afterSet = readFileSync(join(root, updated.path), 'utf8'); + expect(afterSet).toContain('owner: Core Team'); + expect(afterSet).toContain('priority: high'); + expect(afterSet).toContain('keywords:'); + expect(afterSet).toContain('blocked_by:'); + expect(afterSet).toContain('blocks:'); + + const cleared = workspace.editBacklogMetadata('metadata-edit', { + clearOwner: true, + clearPriority: true, + clearKeywords: true, + clearBlockedBy: true, + clearBlocks: true, + }); + + expect(cleared.updatedFields).toEqual(['owner', 'priority', 'keywords', 'blocked_by', 'blocks']); + expect(cleared.owner).toBeUndefined(); + expect(cleared.priority).toBeUndefined(); + expect(cleared.keywords).toEqual([]); + expect(cleared.blockedBy).toEqual([]); + expect(cleared.blocks).toEqual([]); + + const afterClear = readFileSync(join(root, cleared.path), 'utf8'); + expect(afterClear).not.toContain('owner:'); + expect(afterClear).not.toContain('priority:'); + expect(afterClear).not.toContain('keywords:'); + expect(afterClear).not.toContain('blocked_by:'); + expect(afterClear).not.toContain('blocks:'); + }); + + it('returns an advisory next-work menu that can cite BEARING when it elevates an item above default lane order.', () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_up-next-foundation.md'), + [ + '---', + 'title: "Up Next Foundation"', + 'legend: PROCESS', + 'lane: up-next', + 'priority: medium', + '---', + '', + '# Up Next Foundation', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/bad-code/PROCESS_bad-fix.md'), + [ + '---', + 'title: "Bad Fix"', + 'legend: PROCESS', + 'lane: bad-code', + 'priority: medium', + '---', + '', + '# Bad Fix', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/cool-ideas/PROCESS_bearing-star.md'), + [ + '---', + 'title: "Bearing Star"', + 'legend: PROCESS', + 'lane: cool-ideas', + 'priority: medium', + 'keywords:', + ' - roadmap', + '---', + '', + '# Bearing Star', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/BEARING.md'), + [ + '---', + 'title: "BEARING"', + 'generated_at: 2026-04-11T00:00:00.000Z', + 'generator: "test"', + 'generated_from_commit: "test-sha"', + 'provenance_level: artifact_history', + '---', + '', + '# BEARING', + '', + '## Where are we going?', + '', + 'Current priority: explore `PROCESS_bearing-star` next.', + '', + '## What just shipped?', + '', + '- None yet.', + '', + '## What feels wrong?', + '', + '- No acute coordination pain is currently recorded.', + ].join('\n'), + 'utf8', + ); + + const result = workspace.nextWork({ limit: 3 }); + + expect(result.summary.bearing_priority).toContain('PROCESS_bearing-star'); + expect(result.recommendations.map((item) => item.path)).toEqual([ + 'docs/method/backlog/up-next/PROCESS_up-next-foundation.md', + 'docs/method/backlog/cool-ideas/PROCESS_bearing-star.md', + 'docs/method/backlog/bad-code/PROCESS_bad-fix.md', + ]); + expect(result.recommendations[1]?.signals).toContainEqual({ + type: 'bearing_mention', + value: 'Current priority: explore `PROCESS_bearing-star` next.', + source: 'docs/BEARING.md#Where are we going?', + }); + expect(result.selection_notes).toContainEqual(expect.stringContaining('BEARING elevated `PROCESS_bearing-star`')); + }); + + it('programmatically captures outside-in notes directly into inbox with stable intake metadata.', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + const path = workspace.captureIdeaWithMetadata('Missing API Surfaces', undefined, 'Missing API Surfaces', { + source: 'cross-repo usage', + body: 'Observed while operating METHOD from another repo.\n', + capturedAt: '2026-04-11', + }); + + expect(path).toContain('docs/method/backlog/inbox/missing-api-surfaces.md'); + expect(readFileSync(path, 'utf8')).toContain('# Missing API Surfaces'); + expect(readFileSync(path, 'utf8')).toContain('source: "cross-repo usage"'); + expect(readFileSync(path, 'utf8')).toContain('captured_at: "2026-04-11"'); + expect(workspace.describeBacklogPath(workspace.resolveRepoPath(path))).toEqual({ + stem: 'missing-api-surfaces', + lane: 'inbox', + path: 'docs/method/backlog/inbox/missing-api-surfaces.md', + legend: undefined, + slug: 'missing-api-surfaces', + title: 'Missing API Surfaces', + release: undefined, + source: 'cross-repo usage', + captured_at: '2026-04-11', + }); + }); + + it('pulls release-lane backlog work into release-scoped design and retro packets.', async () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + workspace.createBacklogItem({ + lane: 'v2.4.5', + title: 'Release Scope', + legend: 'PROCESS', + body: 'Original release body.\n', + }); + + const cycle = workspace.pullItem('release-scope'); + expect(cycle.designDoc).toBe(join(root, 'docs/releases/v2.4.5/design/PROCESS_release-scope.md')); + expect(cycle.retroDoc).toBe(join(root, 'docs/releases/v2.4.5/retros/PROCESS_release-scope/PROCESS_release-scope.md')); + expect(readFileSync(cycle.designDoc, 'utf8')).toContain('release: "v2.4.5"'); + + await workspace.closeCycle(cycle.name, true, 'hill-met'); + expect(readFileSync(cycle.retroDoc, 'utf8')).toContain('release: "v2.4.5"'); + expect(existsSync(join(root, 'docs/releases/v2.4.5/retros/PROCESS_release-scope/witness/verification.md'))).toBe(true); + }); + + it('preserves release metadata when work is promoted from a release lane into asap before pull.', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + const created = workspace.createBacklogItem({ + lane: 'v2.4.5', + title: 'Release ASAP', + legend: 'PROCESS', + body: 'Queue me next.\n', + }); + const moved = workspace.moveBacklogItem(workspace.resolveRepoPath(created), 'asap'); + expect(readFileSync(join(root, moved), 'utf8')).toContain('release: v2.4.5'); + + const cycle = workspace.pullItem('release-asap'); + expect(cycle.designDoc).toBe(join(root, 'docs/releases/v2.4.5/design/PROCESS_release-asap.md')); + }); + + it('programmatically retires a live backlog item into the graveyard with a disposition tombstone.', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + workspace.createBacklogItem({ + lane: 'up-next', + title: 'Retire Me', + legend: 'PROCESS', + body: 'Original proposal body.\n', + }); + + const retired = workspace.retireBacklogItem( + 'retire-me', + 'Superseded by the broader release-scope cycle.', + 'docs/design/0040-release-scope/release-scope.md', + ); + + expect(retired.ok).toBe(true); + expect(retired.sourcePath).toBe('docs/method/backlog/up-next/PROCESS_retire-me.md'); + expect(retired.graveyardPath).toBe('docs/method/graveyard/PROCESS_retire-me.md'); + expect(retired.updatedFiles).toEqual([ + 'docs/method/backlog/up-next/PROCESS_retire-me.md', + 'docs/method/graveyard/PROCESS_retire-me.md', + ]); + expect(existsSync(join(root, retired.sourcePath))).toBe(false); + expect(readFileSync(join(root, retired.graveyardPath), 'utf8')).toContain('lane: graveyard'); + expect(readFileSync(join(root, retired.graveyardPath), 'utf8')).toContain('## Disposition'); + expect(readFileSync(join(root, retired.graveyardPath), 'utf8')).toContain('Superseded by the broader release-scope cycle.'); + expect(readFileSync(join(root, retired.graveyardPath), 'utf8')).toContain('Replacement: `docs/design/0040-release-scope/release-scope.md`'); + expect(readFileSync(join(root, retired.graveyardPath), 'utf8')).toContain('## Original Proposal'); + expect(readFileSync(join(root, retired.graveyardPath), 'utf8')).toContain('Original proposal body.'); + }); + + it('can preview backlog retirement without mutating the repo.', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + workspace.createBacklogItem({ + lane: 'up-next', + title: 'Retire Me', + legend: 'PROCESS', + body: 'Original proposal body.\n', + }); + + const preview = workspace.retireBacklogItem('retire-me', 'No longer relevant.', undefined, { dryRun: true }); + + expect(preview.ok).toBe(true); + expect(preview.dryRun).toBe(true); + expect(preview.sourcePath).toBe('docs/method/backlog/up-next/PROCESS_retire-me.md'); + expect(preview.graveyardPath).toBe('docs/method/graveyard/PROCESS_retire-me.md'); + expect(existsSync(join(root, preview.sourcePath))).toBe(true); + expect(existsSync(join(root, preview.graveyardPath))).toBe(false); + }); + + it('reports expected signposts and can initialize narrowly supported missing signposts.', async () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + const statusBefore = workspace.signpostStatus(); + expect(statusBefore.missing).toContain('README.md'); + expect(statusBefore.missing).toContain('docs/BEARING.md'); + expect(statusBefore.signposts).toContainEqual(expect.objectContaining({ + name: 'BEARING', + path: 'docs/BEARING.md', + kind: 'Generated', + exists: false, + initable: true, + })); + expect(statusBefore.signposts).toContainEqual(expect.objectContaining({ + name: 'VISION', + path: 'docs/VISION.md', + kind: 'Generated', + exists: false, + initable: false, + })); + + const initialized = await workspace.initSignpost('BEARING'); + expect(initialized.ok).toBe(true); + expect(initialized.requested).toBe('BEARING'); + expect(initialized.initializedTargets).toEqual(['docs/BEARING.md']); + expect(initialized.skippedPaths).toEqual([]); + expect(readFileSync(join(root, 'docs/BEARING.md'), 'utf8')).toContain('# BEARING'); + + const skipped = await workspace.initSignpost('BEARING'); + expect(skipped.initializedTargets).toEqual([]); + expect(skipped.skippedPaths).toEqual(['docs/BEARING.md']); + }); + 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); @@ -151,6 +892,7 @@ describe('Method API', () => { 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); + ensureBacklogLane(root, 'up-next'); const workspace = new Workspace(root); writeFileSync( @@ -180,6 +922,38 @@ describe('Method API', () => { }); }); + it('Allows repo-defined backlog lanes such as `v1.1.0` and preserves them in status output.', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_release-scope.md'), + '# Release Scope\n\nBody\n', + 'utf8', + ); + + const moved = workspace.moveBacklogItem( + 'docs/method/backlog/inbox/PROCESS_release-scope.md', + 'v1.1.0', + ); + + expect(moved).toBe('docs/method/backlog/v1.1.0/PROCESS_release-scope.md'); + expect(workspace.readFrontmatter(moved)).toMatchObject({ + lane: 'v1.1.0', + legend: 'PROCESS', + }); + + const status = workspace.status(); + expect(status.backlog['v1.1.0']).toContainEqual({ + stem: 'PROCESS_release-scope', + lane: 'v1.1.0', + path: 'docs/method/backlog/v1.1.0/PROCESS_release-scope.md', + legend: 'PROCESS', + slug: 'release-scope', + }); + }); + it('Rejects moving backlog paths that escape the workspace root.', () => { const root = createTempRoot(); const outsideRoot = createTempRoot(); @@ -229,7 +1003,7 @@ describe('Method API', () => { ); const cycle = workspace.pullItem('PROCESS_frontmatter-pull'); - expect(cycle.name).toBe('0001-frontmatter-pull'); + expect(cycle.name).toBe('SYNTH_frontmatter-pull'); expect(readFileSync(cycle.designDoc, 'utf8')).toContain('Legend: SYNTH'); }); @@ -310,9 +1084,7 @@ describe('Method API', () => { 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 }); + const designDoc = join(root, 'docs/design/PROCESS_frontmatter-design.md'); writeFileSync( designDoc, [ @@ -349,4 +1121,55 @@ describe('Method API', () => { it('The `method` CLI continues to work exactly as before for all commands (`status`, `inbox`, `pull`, etc.).', () => { // Verified by running the full cli.test.ts suite. }); + + it('Does `method pull` create a flat design doc at `docs/design/<LEGEND>_<slug>.md` instead of a nested directory?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + workspace.captureIdea('Flat Layout', 'PROCESS', 'Flat Layout Test'); + const cycle = workspace.pullItem('PROCESS_flat-layout-test'); + + expect(cycle.name).toBe('PROCESS_flat-layout-test'); + expect(cycle.designDoc).toBe(join(root, 'docs/design/PROCESS_flat-layout-test.md')); + expect(existsSync(join(root, 'docs/design/PROCESS_flat-layout-test.md'))).toBe(true); + // No subdirectory should exist + expect(existsSync(join(root, 'docs/design/PROCESS_flat-layout-test'))).toBe(false); + }); + + it('Does `readCycleFromDoc()` discover both flat and legacy nested design docs?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + // Create a flat design doc + writeFileSync( + join(root, 'docs/design/PROCESS_flat-cycle.md'), + '# Flat Cycle\n\nLegend: PROCESS\n', + 'utf8', + ); + + // Create a legacy nested design doc + mkdirSync(join(root, 'docs/design/0001-legacy-cycle'), { recursive: true }); + writeFileSync( + join(root, 'docs/design/0001-legacy-cycle/legacy-cycle.md'), + '# Legacy Cycle\n\nLegend: PROCESS\n', + 'utf8', + ); + + const status = workspace.status(); + const cycleNames = status.activeCycles.map((cycle) => cycle.name); + expect(cycleNames).toContain('PROCESS_flat-cycle'); + expect(cycleNames).toContain('0001-legacy-cycle'); + }); + + it('Does `generateReferenceDocs()` return the same target list that the CLI prints?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + + const result = workspace.syncRefs(); + expect(result.targets).toEqual(['ARCHITECTURE.md', 'docs/CLI.md', 'docs/MCP.md', 'docs/GUIDE.md']); + expect(Array.isArray(result.updated)).toBe(true); + }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 37998f4..40acd89 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,6 +1,8 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { initWorkspace } from '../src/index.js'; import { runCli } from '../src/cli.js'; @@ -29,6 +31,10 @@ function createTempRoot(): string { return root; } +function ensureBacklogLane(root: string, lane: string): void { + mkdirSync(join(root, 'docs/method/backlog', lane), { recursive: true }); +} + describe('method CLI', () => { it('scaffolds a workspace with init', async () => { const root = createTempRoot(); @@ -60,7 +66,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('Close an active cycle into its retro packet.'); expect(stdout.output).toContain('--outcome hill-met|partial|not-met'); }); @@ -123,6 +129,117 @@ describe('method CLI', () => { expect(stderr.output).toContain('Usage: method doctor [--json]'); }); + it('starts `method mcp` from a non-METHOD cwd because MCP callers pass explicit workspace arguments per tool.', async () => { + const root = createTempRoot(); + const client = new Client({ name: 'cli-test-probe', version: '1.0.0' }); + const transport = new StdioClientTransport({ + command: join(process.cwd(), 'node_modules/.bin/tsx'), + args: [join(process.cwd(), 'src/cli.ts'), 'mcp'], + cwd: root, + }); + + try { + await client.connect(transport); + const result = await client.listTools(); + + expect(result.tools.length).toBeGreaterThan(0); + expect(result.tools.map((tool) => tool.name)).toContain('method_status'); + } finally { + await client.close(); + } + }); + + it('plans and applies bounded doctor-guided repairs through the CLI JSON surface.', async () => { + const root = createTempRoot(); + initWorkspace(root); + rmSync(join(root, 'docs/design'), { recursive: true, force: true }); + rmSync(join(root, 'docs/method/release-runbook.md'), { recursive: true, force: true }); + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md'), + '# Missing Frontmatter\n\nBody\n', + 'utf8', + ); + + const planStdout = new MemoryWriter(); + const planExitCode = await runCli(['repair', '--plan', '--json'], { + cwd: root, + stdout: planStdout, + stderr: new MemoryWriter(), + }); + + const plan = JSON.parse(planStdout.output); + expect(planExitCode).toBe(0); + expect(plan.mode).toBe('plan'); + expect(plan.repairs.map((repair: { status: string }) => repair.status)).toEqual(['planned', 'planned', 'planned']); + + const applyStdout = new MemoryWriter(); + const applyExitCode = await runCli(['repair', '--apply', '--json'], { + cwd: root, + stdout: applyStdout, + stderr: new MemoryWriter(), + }); + + const applied = JSON.parse(applyStdout.output); + expect(applyExitCode).toBe(0); + expect(applied.mode).toBe('apply'); + expect(applied.repairs.map((repair: { status: string }) => repair.status)).toEqual(['applied', 'applied', 'applied']); + expect(readFile(root, 'docs/method/release-runbook.md')).toContain('# Release Runbook'); + expect(readFile(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md')).toMatch(/^---\ntitle: "Missing Frontmatter"\n---\n\n# Missing Frontmatter/mu); + }); + + it('runs doctor, applies the bounded repair set, and re-checks through `method migrate --json`.', async () => { + const root = createTempRoot(); + initWorkspace(root); + rmSync(join(root, 'docs/design'), { recursive: true, force: true }); + rmSync(join(root, 'docs/method/release-runbook.md'), { recursive: true, force: true }); + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md'), + '# Missing Frontmatter\n\nBody\n', + 'utf8', + ); + const stdout = new MemoryWriter(); + + const exitCode = await runCli(['migrate', '--json'], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + + const result = JSON.parse(stdout.output); + expect(exitCode).toBe(0); + expect(result.changed).toBe(true); + expect(result.initialReport.status).toBe('error'); + expect(result.repair.mode).toBe('apply'); + expect(result.repair.repairs.map((repair: { status: string }) => repair.status)).toEqual(['applied', 'applied', 'applied']); + expect(readFile(root, 'docs/method/release-runbook.md')).toContain('# Release Runbook'); + expect(readFile(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md')).toMatch(/^---\ntitle: "Missing Frontmatter"\n---\n\n# Missing Frontmatter/mu); + }); + + it('Does `method sync refs` print only generated reference targets and leave ship-only artifacts untouched?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const stdout = new MemoryWriter(); + const changelogBefore = readFile(root, 'CHANGELOG.md'); + const bearingPath = join(root, 'docs/BEARING.md'); + expect(existsSync(bearingPath)).toBe(false); + + const exitCode = await runCli(['sync', 'refs'], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + + expect(exitCode).toBe(0); + expect(stdout.output).toContain('Refreshed ARCHITECTURE.md'); + expect(stdout.output).toContain('Refreshed docs/CLI.md'); + expect(stdout.output).toContain('Refreshed docs/MCP.md'); + expect(stdout.output).toContain('Refreshed docs/GUIDE.md'); + expect(stdout.output).not.toContain('CHANGELOG.md'); + expect(stdout.output).not.toContain('docs/BEARING.md'); + expect(readFile(root, 'CHANGELOG.md')).toBe(changelogBefore); + expect(existsSync(bearingPath)).toBe(false); + }); + it('captures backlog ideas in inbox', async () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); @@ -139,6 +256,769 @@ describe('method CLI', () => { .toContain('# What if Method had a CLI?'); }); + it('creates shaped backlog notes directly in a requested custom lane and returns JSON when asked.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + writeFileSync(join(root, 'backlog-body.md'), 'Line one.\n\nLine two.\n', 'utf8'); + const stdout = new MemoryWriter(); + + const exitCode = await runCli([ + 'backlog', + 'add', + '--lane', + 'v1.1.0', + '--title', + 'Backlog Add', + '--legend', + 'PROCESS', + '--body-file', + 'backlog-body.md', + '--json', + ], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + + const result = JSON.parse(stdout.output); + expect(exitCode).toBe(0); + expect(result.path).toBe('docs/method/backlog/v1.1.0/PROCESS_backlog-add.md'); + expect(result.lane).toBe('v1.1.0'); + expect(result.legend).toBe('PROCESS'); + expect(result.title).toBe('Backlog Add'); + expect(result.stem).toBe('PROCESS_backlog-add'); + expect(result.slug).toBe('backlog-add'); + expect(readFile(root, 'docs/method/backlog/v1.1.0/PROCESS_backlog-add.md')).toContain('Line two.'); + }); + + it('rejects malformed backlog lanes before creating any file.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + const stderr = new MemoryWriter(); + + const exitCode = await runCli([ + 'backlog', + 'add', + '--lane', + '../oops', + '--title', + 'Backlog Add', + ], { + cwd: root, + stdout: new MemoryWriter(), + stderr, + }); + + expect(exitCode).toBe(1); + expect(stderr.output).toContain('Backlog lane must be a live lane'); + expect(existsSync(join(root, 'docs/method/backlog/oops'))).toBe(false); + }); + + it('moves backlog notes between lanes using path, stem, or slug resolution and returns JSON when asked.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_move-me.md'), + [ + '---', + 'title: "Move Me"', + 'legend: PROCESS', + 'lane: inbox', + '---', + '', + '# Move Me', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + const stdout = new MemoryWriter(); + + const exitCode = await runCli([ + 'backlog', + 'move', + 'move-me', + '--to', + 'v1.1.0', + '--json', + ], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + + const result = JSON.parse(stdout.output); + expect(exitCode).toBe(0); + expect(result.sourcePath).toBe('docs/method/backlog/inbox/PROCESS_move-me.md'); + expect(result.path).toBe('docs/method/backlog/v1.1.0/PROCESS_move-me.md'); + expect(result.lane).toBe('v1.1.0'); + expect(result.legend).toBe('PROCESS'); + expect(existsSync(join(root, 'docs/method/backlog/inbox/PROCESS_move-me.md'))).toBe(false); + expect(readFile(root, 'docs/method/backlog/v1.1.0/PROCESS_move-me.md')).toContain('lane: v1.1.0'); + }); + + it('queries backlog items by explicit frontmatter metadata, readiness, dependency filters, and sort mode and returns bounded JSON results.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_query-foundation.md'), + [ + '---', + 'title: "Query Foundation"', + 'legend: PROCESS', + 'lane: up-next', + 'owner: "METHOD maintainers"', + 'priority: medium', + 'keywords:', + ' - agent', + ' - query', + 'blocked_by:', + ' - setup', + 'acceptance_criteria:', + ' - "Has a query API"', + '---', + '', + '# Query Foundation', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_query-polish.md'), + [ + '---', + 'title: "Query Polish"', + 'legend: PROCESS', + 'lane: up-next', + 'priority: medium', + 'keywords:', + ' - agent', + ' - polish', + '---', + '', + '# Query Polish', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + const stdout = new MemoryWriter(); + + const exitCode = await runCli([ + 'backlog', + 'list', + '--lane', + 'up-next', + '--keyword', + 'agent', + '--owner', + 'METHOD maintainers', + '--blocked', + '--has-acceptance-criteria', + '--blocked-by', + 'setup', + '--sort', + 'priority', + '--limit', + '1', + '--json', + ], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + + const result = JSON.parse(stdout.output); + expect(exitCode).toBe(0); + expect(result.totalCount).toBe(1); + expect(result.returnedCount).toBe(1); + expect(result.truncated).toBe(false); + expect(result.items[0].stem).toBe('PROCESS_query-foundation'); + expect(result.filters.owner).toBe('method maintainers'); + expect(result.filters.ready).toBe(false); + expect(result.filters.hasAcceptanceCriteria).toBe(true); + expect(result.filters.blockedBy).toBe('setup'); + expect(result.filters.sort).toBe('priority'); + expect(result.items[0].keywords).toEqual(['agent', 'query']); + expect(result.items[0].blockedBy).toEqual(['setup']); + expect(result.items[0].hasAcceptanceCriteria).toBe(true); + }); + + it('rejects conflicting backlog list readiness flags.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + const stderr = new MemoryWriter(); + + const exitCode = await runCli(['backlog', 'list', '--ready', '--blocked'], { + cwd: root, + stdout: new MemoryWriter(), + stderr, + }); + + expect(exitCode).toBe(1); + expect(stderr.output).toContain('Cannot combine `--ready` with `--blocked`.'); + }); + + it('edits schema-backed backlog metadata through the CLI JSON surface.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_metadata-edit.md'), + [ + '---', + 'title: "Metadata Edit"', + 'legend: PROCESS', + 'lane: up-next', + '---', + '', + '# Metadata Edit', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + const stdout = new MemoryWriter(); + + const exitCode = await runCli([ + 'backlog', + 'edit', + 'metadata-edit', + '--owner', + 'Core Team', + '--priority', + 'HIGH', + '--keyword', + 'roadmap', + '--keyword', + 'query', + '--blocked-by', + 'setup', + '--blocks', + 'finish', + '--json', + ], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + + const result = JSON.parse(stdout.output); + expect(exitCode).toBe(0); + expect(result.updatedFields).toEqual(['owner', 'priority', 'keywords', 'blocked_by', 'blocks']); + expect(result.owner).toBe('Core Team'); + expect(result.priority).toBe('high'); + expect(result.keywords).toEqual(['roadmap', 'query']); + expect(result.blockedBy).toEqual(['setup']); + expect(result.blocks).toEqual(['finish']); + }); + + it('returns a bounded next-work menu and reports BEARING-driven elevation through the CLI JSON surface.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_up-next-foundation.md'), + [ + '---', + 'title: "Up Next Foundation"', + 'legend: PROCESS', + 'lane: up-next', + 'priority: medium', + '---', + '', + '# Up Next Foundation', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/bad-code/PROCESS_bad-fix.md'), + [ + '---', + 'title: "Bad Fix"', + 'legend: PROCESS', + 'lane: bad-code', + 'priority: medium', + '---', + '', + '# Bad Fix', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/cool-ideas/PROCESS_bearing-star.md'), + [ + '---', + 'title: "Bearing Star"', + 'legend: PROCESS', + 'lane: cool-ideas', + 'priority: medium', + '---', + '', + '# Bearing Star', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/BEARING.md'), + [ + '---', + 'title: "BEARING"', + 'generated_at: 2026-04-11T00:00:00.000Z', + 'generator: "test"', + 'generated_from_commit: "test-sha"', + 'provenance_level: artifact_history', + '---', + '', + '# BEARING', + '', + '## Where are we going?', + '', + 'Current priority: explore `PROCESS_bearing-star` next.', + '', + '## What just shipped?', + '', + '- None yet.', + '', + '## What feels wrong?', + '', + '- No acute coordination pain is currently recorded.', + ].join('\n'), + 'utf8', + ); + const stdout = new MemoryWriter(); + + const exitCode = await runCli(['next', '--limit', '3', '--json'], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + + const result = JSON.parse(stdout.output); + expect(exitCode).toBe(0); + expect(result.recommendations.map((item: { path: string }) => item.path)).toEqual([ + 'docs/method/backlog/up-next/PROCESS_up-next-foundation.md', + 'docs/method/backlog/cool-ideas/PROCESS_bearing-star.md', + 'docs/method/backlog/bad-code/PROCESS_bad-fix.md', + ]); + expect(result.selection_notes).toContainEqual(expect.stringContaining('BEARING elevated `PROCESS_bearing-star`')); + }); + + it('filters next-work recommendations by explicit metadata and can keep blocked items when requested.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_blocked-owner-match.md'), + [ + '---', + 'title: "Blocked Owner Match"', + 'legend: PROCESS', + 'lane: up-next', + 'owner: "Core Team"', + 'priority: medium', + 'keywords:', + ' - roadmap', + 'blocked_by:', + ' - setup', + '---', + '', + '# Blocked Owner Match', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/cool-ideas/PROCESS_ready-owner-match.md'), + [ + '---', + 'title: "Ready Owner Match"', + 'legend: PROCESS', + 'lane: cool-ideas', + 'owner: "Core Team"', + 'priority: medium', + 'keywords:', + ' - roadmap', + '---', + '', + '# Ready Owner Match', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + const stdout = new MemoryWriter(); + + const exitCode = await runCli([ + 'next', + '--keyword', + 'roadmap', + '--owner', + 'Core Team', + '--include-blocked', + '--limit', + '5', + '--json', + ], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + + const result = JSON.parse(stdout.output); + expect(exitCode).toBe(0); + expect(result.recommendations.map((item: { path: string }) => item.path)).toEqual([ + 'docs/method/backlog/up-next/PROCESS_blocked-owner-match.md', + 'docs/method/backlog/cool-ideas/PROCESS_ready-owner-match.md', + ]); + expect(result.selection_notes).toContain('Blocked items were included because `include-blocked` was requested.'); + expect(result.selection_notes).toContain('Applied filters: keyword=roadmap, owner=core team.'); + }); + + it('reports ready work and critical paths through the backlog dependency JSON surface.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_foundation.md'), + [ + '---', + 'title: "Foundation"', + 'legend: PROCESS', + 'lane: up-next', + '---', + '', + '# Foundation', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_build.md'), + [ + '---', + 'title: "Build"', + 'legend: PROCESS', + 'lane: up-next', + 'blocked_by:', + ' - foundation', + 'blocks:', + ' - finish', + '---', + '', + '# Build', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_finish.md'), + [ + '---', + 'title: "Finish"', + 'legend: PROCESS', + 'lane: up-next', + 'blocked_by:', + ' - build', + '---', + '', + '# Finish', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const readyStdout = new MemoryWriter(); + const readyExitCode = await runCli(['backlog', 'deps', '--ready', '--json'], { + cwd: root, + stdout: readyStdout, + stderr: new MemoryWriter(), + }); + + const ready = JSON.parse(readyStdout.output); + expect(readyExitCode).toBe(0); + expect(ready.query.readyOnly).toBe(true); + expect(ready.ready.map((item: { stem: string }) => item.stem)).toEqual(['PROCESS_foundation']); + + const focusStdout = new MemoryWriter(); + const focusExitCode = await runCli(['backlog', 'deps', 'finish', '--critical-path', '--json'], { + cwd: root, + stdout: focusStdout, + stderr: new MemoryWriter(), + }); + + const focus = JSON.parse(focusStdout.output); + expect(focusExitCode).toBe(0); + expect(focus.focus.item.stem).toBe('PROCESS_finish'); + expect(focus.focus.blockers.map((item: { stem: string }) => item.stem)).toEqual(['PROCESS_build']); + expect(focus.focus.criticalPath.map((item: { stem: string }) => item.stem)).toEqual([ + 'PROCESS_foundation', + 'PROCESS_build', + 'PROCESS_finish', + ]); + }); + + it('requires explicit --yes before mutating backlog retirement from the CLI.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_retire-me.md'), + [ + '---', + 'title: "Retire Me"', + 'legend: PROCESS', + 'lane: up-next', + '---', + '', + '# Retire Me', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + const stderr = new MemoryWriter(); + + const exitCode = await runCli([ + 'retire', + 'retire-me', + '--reason', + 'Superseded by another cycle.', + ], { + cwd: root, + stdout: new MemoryWriter(), + stderr, + }); + + expect(exitCode).toBe(1); + expect(stderr.output).toContain('requires `--yes` unless `--dry-run` is set'); + expect(existsSync(join(root, 'docs/method/backlog/up-next/PROCESS_retire-me.md'))).toBe(true); + }); + + it('can preview and apply backlog retirement through the CLI JSON surface.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_retire-me.md'), + [ + '---', + 'title: "Retire Me"', + 'legend: PROCESS', + 'lane: up-next', + '---', + '', + '# Retire Me', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const previewStdout = new MemoryWriter(); + const previewExitCode = await runCli([ + 'retire', + 'retire-me', + '--reason', + 'Superseded by another cycle.', + '--replacement', + 'docs/design/0040-release-scope/release-scope.md', + '--dry-run', + '--json', + ], { + cwd: root, + stdout: previewStdout, + stderr: new MemoryWriter(), + }); + + const preview = JSON.parse(previewStdout.output); + expect(previewExitCode).toBe(0); + expect(preview.dryRun).toBe(true); + expect(preview.sourcePath).toBe('docs/method/backlog/up-next/PROCESS_retire-me.md'); + expect(preview.graveyardPath).toBe('docs/method/graveyard/PROCESS_retire-me.md'); + expect(existsSync(join(root, 'docs/method/backlog/up-next/PROCESS_retire-me.md'))).toBe(true); + expect(existsSync(join(root, 'docs/method/graveyard/PROCESS_retire-me.md'))).toBe(false); + + const applyStdout = new MemoryWriter(); + const applyExitCode = await runCli([ + 'retire', + 'retire-me', + '--reason', + 'Superseded by another cycle.', + '--replacement', + 'docs/design/0040-release-scope/release-scope.md', + '--yes', + '--json', + ], { + cwd: root, + stdout: applyStdout, + stderr: new MemoryWriter(), + }); + + const applied = JSON.parse(applyStdout.output); + expect(applyExitCode).toBe(0); + expect(applied.dryRun).toBe(false); + expect(applied.sourcePath).toBe('docs/method/backlog/up-next/PROCESS_retire-me.md'); + expect(applied.graveyardPath).toBe('docs/method/graveyard/PROCESS_retire-me.md'); + expect(existsSync(join(root, applied.sourcePath))).toBe(false); + expect(readFile(root, applied.graveyardPath)).toContain('## Disposition'); + expect(readFile(root, applied.graveyardPath)).toContain('Superseded by another cycle.'); + expect(readFile(root, applied.graveyardPath)).toContain('## Original Proposal'); + }); + + it('reports expected signposts and initializes supported missing signposts through the CLI JSON surface.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + + const statusStdout = new MemoryWriter(); + const statusExitCode = await runCli(['signpost', 'status', '--json'], { + cwd: root, + stdout: statusStdout, + stderr: new MemoryWriter(), + }); + + const status = JSON.parse(statusStdout.output); + expect(statusExitCode).toBe(0); + expect(status.missing).toContain('README.md'); + expect(status.missing).toContain('docs/BEARING.md'); + expect(status.signposts).toContainEqual(expect.objectContaining({ + name: 'BEARING', + path: 'docs/BEARING.md', + exists: false, + initable: true, + })); + + const initStdout = new MemoryWriter(); + const initExitCode = await runCli(['signpost', 'init', 'BEARING', '--json'], { + cwd: root, + stdout: initStdout, + stderr: new MemoryWriter(), + }); + + const initialized = JSON.parse(initStdout.output); + expect(initExitCode).toBe(0); + expect(initialized.requested).toBe('BEARING'); + expect(initialized.initializedTargets).toEqual(['docs/BEARING.md']); + expect(readFile(root, 'docs/BEARING.md')).toContain('# BEARING'); + }); + + it('captures inbox items with explicit source metadata and returns JSON when asked.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + writeFileSync(join(root, 'inbox-body.md'), 'Observed while operating METHOD elsewhere.\n', 'utf8'); + const stdout = new MemoryWriter(); + + const exitCode = await runCli([ + 'inbox', + 'Feedback note worth triaging', + '--legend', + 'PROCESS', + '--title', + 'Missing API Surfaces', + '--source', + 'cross-repo usage', + '--body-file', + 'inbox-body.md', + '--captured-at', + '2026-04-11', + '--json', + ], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + + const result = JSON.parse(stdout.output); + expect(exitCode).toBe(0); + expect(result.path).toBe('docs/method/backlog/inbox/PROCESS_missing-api-surfaces.md'); + expect(result.lane).toBe('inbox'); + expect(result.legend).toBe('PROCESS'); + expect(result.title).toBe('Missing API Surfaces'); + expect(result.source).toBe('cross-repo usage'); + expect(result.captured_at).toBe('2026-04-11'); + expect(readFile(root, 'docs/method/backlog/inbox/PROCESS_missing-api-surfaces.md')).toContain('Observed while operating METHOD elsewhere.'); + }); + + it('rejects the removed feedback command and points operators at inbox metadata capture.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + const stdout = new MemoryWriter(); + const stderr = new MemoryWriter(); + + const exitCode = await runCli([ + 'feedback', + 'add', + '--title', + 'Missing API Surfaces', + ], { + cwd: root, + stdout, + stderr, + }); + + expect(exitCode).toBe(1); + expect(stdout.output).toBe(''); + expect(stderr.output).toContain('`feedback` was removed'); + expect(stderr.output).toContain('method inbox'); + }); + + it('pulls release-lane backlog into release-scoped cycle packets from the CLI.', async () => { + const root = createTempRoot(); + await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); + ensureBacklogLane(root, 'v2.4.5'); + writeFileSync( + join(root, 'docs/method/backlog/v2.4.5/PROCESS_release-scope.md'), + [ + '---', + 'title: "Release Scope"', + 'legend: PROCESS', + 'lane: v2.4.5', + 'release: "v2.4.5"', + '---', + '', + '# Release Scope', + '', + 'Release-scoped packet.', + '', + ].join('\n'), + 'utf8', + ); + const pullStdout = new MemoryWriter(); + + const pullExitCode = await runCli(['pull', 'PROCESS_release-scope'], { + cwd: root, + stdout: pullStdout, + stderr: new MemoryWriter(), + }); + + expect(pullExitCode).toBe(0); + expect(pullStdout.output).toContain('Pulled into PROCESS_release-scope'); + expectFile(root, 'docs/releases/v2.4.5/design/PROCESS_release-scope.md'); + expect(readFile(root, 'docs/releases/v2.4.5/design/PROCESS_release-scope.md')).toContain('release: "v2.4.5"'); + + const closeExitCode = await runCli(['close', 'PROCESS_release-scope', '--drift-check', 'yes', '--outcome', 'hill-met'], { + cwd: root, + stdout: new MemoryWriter(), + stderr: new MemoryWriter(), + }); + + expect(closeExitCode).toBe(0); + expectFile(root, 'docs/releases/v2.4.5/retros/PROCESS_release-scope/PROCESS_release-scope.md'); + expect(readFile(root, 'docs/releases/v2.4.5/retros/PROCESS_release-scope/PROCESS_release-scope.md')).toContain('release: "v2.4.5"'); + }); + it('captures backlog ideas even when the inbox lane directory is absent', async () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); @@ -173,17 +1053,19 @@ describe('method CLI', () => { }); expect(exitCode).toBe(0); - 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(stdout.output).toContain('Pulled into PROCESS_strand-lifecycle'); + expectFile(root, 'docs/design/PROCESS_strand-lifecycle.md'); + const designDoc = readFile(root, 'docs/design/PROCESS_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+"PROCESS"$/mu); - expect(designDoc).toMatch(/^cycle:\s+"0001-strand-lifecycle"$/mu); + expect(designDoc).toMatch(/^cycle:\s+"PROCESS_strand-lifecycle"$/mu); expect(designDoc).toMatch(/^source_backlog:\s+"docs\/method\/backlog\/asap\/PROCESS_strand-lifecycle\.md"$/mu); expect(designDoc).toContain('Legend: PROCESS'); expect(designDoc).toContain('- Human: Backlog operator'); expect(designDoc).toContain('- Agent: Implementation agent'); + expect(designDoc).toContain('These labels are abstract roles.'); + expect(designDoc).toContain('like in a user story'); expect(designDoc).toContain('Mechanical state transitions for strands.'); expect(designDoc).toContain('## Accessibility and Assistive Reading'); expect(designDoc).toContain('## Localization and Directionality'); @@ -193,9 +1075,8 @@ describe('method CLI', () => { 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 }); writeFileSync( - join(root, 'docs/design/0001-method-cli/method-cli.md'), + join(root, 'docs/design/method-cli.md'), '# Method CLI\n\nLegend: none\n\n## Playback Questions\n\n- [ ] TBD\n', 'utf8', ); @@ -208,16 +1089,16 @@ describe('method CLI', () => { }); expect(exitCode).toBe(0); - 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'); - const retroDoc = readFile(root, 'docs/method/retro/0001-method-cli/method-cli.md'); + expect(stdout.output).toContain('Closed method-cli'); + expectFile(root, 'docs/method/retro/method-cli/method-cli.md'); + expectFile(root, 'docs/method/retro/method-cli/witness'); + const retroDoc = readFile(root, 'docs/method/retro/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).toMatch(/^design_doc:\s+"docs\/design\/method-cli\.md"$/mu); + expect(retroDoc).not.toContain('Design: `docs/design/method-cli.md`'); expect(retroDoc).not.toContain('Outcome: partial'); expect(retroDoc).not.toContain('Drift check: yes'); }); @@ -248,9 +1129,9 @@ describe('method CLI', () => { }); expect(exitCode).toBe(0); - const designDoc = readFile(root, 'docs/design/0001-scaffold-contract/scaffold-contract.md'); + const designDoc = readFile(root, 'docs/design/PROCESS_scaffold-contract.md'); expect(designDoc).toMatch(/^legend:\s+"PROCESS"$/mu); - expect(designDoc).toMatch(/^cycle:\s+"0001-scaffold-contract"$/mu); + expect(designDoc).toMatch(/^cycle:\s+"PROCESS_scaffold-contract"$/mu); expect(designDoc).not.toContain('- Human: TBD'); expect(designDoc).not.toContain('- Agent: TBD'); }); @@ -258,14 +1139,13 @@ describe('method CLI', () => { 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'), + join(root, 'docs/design/PROCESS_scaffold-retro.md'), [ '---', 'title: "Scaffold Retro"', 'legend: PROCESS', - 'cycle: "0001-scaffold-retro"', + 'cycle: "PROCESS_scaffold-retro"', 'source_backlog: "docs/method/backlog/asap/PROCESS_scaffold-retro.md"', '---', '', @@ -280,17 +1160,17 @@ describe('method CLI', () => { 'utf8', ); - const exitCode = await runCli(['close', '0001-scaffold-retro', '--drift-check', 'yes', '--outcome', 'hill-met'], { + const exitCode = await runCli(['close', 'PROCESS_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'); + const retroDoc = readFile(root, 'docs/method/retro/PROCESS_scaffold-retro/PROCESS_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).toMatch(/^design_doc:\s+"docs\/design\/PROCESS_scaffold-retro\.md"$/mu); expect(retroDoc).not.toContain('Outcome: TBD'); }); @@ -302,25 +1182,25 @@ describe('method CLI', () => { '# Braille\n\nIdea\n', 'utf8', ); - mkdirSync(join(root, 'docs/design/0001-method-cli'), { recursive: true }); writeFileSync( - join(root, 'docs/design/0001-method-cli/method-cli.md'), + join(root, 'docs/design/PROCESS_method-cli.md'), '# Method CLI\n\nLegend: PROCESS\n\n## Hill\n\nTBD\n', 'utf8', ); const stdout = new MemoryWriter(); const exitCode = await runCli(['status'], { cwd: root, stdout, stderr: new MemoryWriter() }); + const legendSection = stdout.output.split('--- Legend Health ---').at(1) ?? ''; expect(exitCode).toBe(0); expect(stdout.output).toContain('Backlog'); - expect(stdout.output).toContain('0001-method-cli'); - expect(stdout.output).toContain('SYNTH'); - expect(stdout.output).toContain('PROCESS'); - expect(stdout.output).not.toContain('PROTO'); - expect(stdout.output).not.toContain('FEAT'); - expect(stdout.output).not.toContain('VIZ'); - expect(stdout.output).not.toContain('TUI'); + expect(stdout.output).toContain('PROCESS_method-cli'); + expect(legendSection).toContain('SYNTH'); + expect(legendSection).toContain('PROCESS'); + expect(legendSection).not.toContain('PROTO'); + expect(legendSection).not.toContain('FEAT'); + expect(legendSection).not.toContain('VIZ'); + expect(legendSection).not.toContain('TUI'); }); it('shows status for a clone-like workspace even when empty lane directories are absent', async () => { @@ -445,8 +1325,7 @@ describe('method CLI', () => { it('refuses close when the drift check is not complete', 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'); + writeFileSync(join(root, 'docs/design/method-cli.md'), '# Method CLI\n\nLegend: none\n', 'utf8'); const stderr = new MemoryWriter(); const exitCode = await runCli(['close', '--drift-check', 'no', '--outcome', 'partial'], { @@ -462,8 +1341,7 @@ describe('method CLI', () => { 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'); + writeFileSync(join(root, 'docs/design/method-cli.md'), '# Method CLI\n\nLegend: none\n', 'utf8'); const stderr = new MemoryWriter(); const exitCode = await runCli(['close', '--drift-check', 'yes'], { @@ -481,8 +1359,7 @@ describe('method CLI', () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); writeDesignDoc(root, { - cycleName: '0001-drift-detector', - slug: 'drift-detector', + cycleName: 'PROCESS_drift-detector', title: 'Drift Detector', body: [ 'Legend: PROCESS', @@ -520,8 +1397,7 @@ describe('method CLI', () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); writeDesignDoc(root, { - cycleName: '0001-drift-detector', - slug: 'drift-detector', + cycleName: 'PROCESS_drift-detector', title: 'Drift Detector', body: [ 'Legend: PROCESS', @@ -551,7 +1427,7 @@ describe('method CLI', () => { expect(exitCode).toBe(2); expect(stdout.output).toContain('Playback-question drift found.'); - expect(stdout.output).toContain('docs/design/0001-drift-detector/drift-detector.md'); + expect(stdout.output).toContain('docs/design/PROCESS_drift-detector.md'); expect(stdout.output).toContain('Human: Can I see a concise drift report?'); expect(stdout.output).toContain('Agent: Does a near miss still count as unmatched?'); // Near-miss hints are shown when test descriptions are close but not exact @@ -562,8 +1438,7 @@ describe('method CLI', () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); writeDesignDoc(root, { - cycleName: '0001-drift-detector', - slug: 'drift-detector', + cycleName: 'PROCESS_drift-detector', title: 'Drift Detector', body: [ 'Legend: PROCESS', @@ -605,8 +1480,7 @@ describe('method CLI', () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); writeDesignDoc(root, { - cycleName: '0001-drift-detector', - slug: 'drift-detector', + cycleName: 'PROCESS_drift-detector', title: 'Drift Detector', body: [ 'Legend: PROCESS', @@ -640,8 +1514,7 @@ describe('method CLI', () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); writeDesignDoc(root, { - cycleName: '0001-drift-detector', - slug: 'drift-detector', + cycleName: 'PROCESS_drift-detector', title: 'Drift Detector', body: [ 'Legend: PROCESS', @@ -718,12 +1591,12 @@ describe('method CLI', () => { // Pull should work against custom paths const pullOut = new MemoryWriter(); await runCli(['pull', 'PROC_custom-path-test'], { cwd: root, stdout: pullOut, stderr: new MemoryWriter() }); - expect(existsSync(join(root, '.method/design/0001-custom-path-test/custom-path-test.md'))).toBe(true); + expect(existsSync(join(root, '.method/design/PROC_custom-path-test.md'))).toBe(true); // Status should reflect it const statusOut = new MemoryWriter(); await runCli(['status'], { cwd: root, stdout: statusOut, stderr: new MemoryWriter() }); - expect(statusOut.output).toContain('0001-custom-path-test'); + expect(statusOut.output).toContain('PROC_custom-path-test'); }); it('shows help for the drift command', async () => { @@ -796,12 +1669,10 @@ function readFile(root: string, relativePath: string): string { function writeDesignDoc( root: string, - options: { cycleName: string; slug: string; title: string; body: string }, + options: { cycleName: string; title: string; body: string }, ): void { - const designDir = join(root, 'docs/design', options.cycleName); - mkdirSync(designDir, { recursive: true }); writeFileSync( - join(designDir, `${options.slug}.md`), + join(root, 'docs/design', `${options.cycleName}.md`), `# ${options.title}\n\n${options.body}\n`, 'utf8', ); @@ -829,8 +1700,7 @@ async function runDriftCleanScenario(): Promise<{ exitCode: number; stdout: Memo const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); writeDesignDoc(root, { - cycleName: '0001-drift-detector', - slug: 'drift-detector', + cycleName: 'PROCESS_drift-detector', title: 'Drift Detector', body: [ 'Legend: PROCESS', @@ -869,8 +1739,7 @@ async function runDriftFoundScenario(): Promise<{ exitCode: number; stdout: Memo const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); writeDesignDoc(root, { - cycleName: '0001-drift-detector', - slug: 'drift-detector', + cycleName: 'PROCESS_drift-detector', title: 'Drift Detector', body: [ 'Legend: PROCESS', @@ -897,8 +1766,7 @@ async function runDriftMissingCycleScenario(): Promise<{ exitCode: number; stder const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); writeDesignDoc(root, { - cycleName: '0001-drift-detector', - slug: 'drift-detector', + cycleName: 'PROCESS_drift-detector', title: 'Drift Detector', body: [ 'Legend: PROCESS', diff --git a/tests/docs.test.ts b/tests/docs.test.ts index fa1f3eb..9a903d4 100644 --- a/tests/docs.test.ts +++ b/tests/docs.test.ts @@ -14,6 +14,7 @@ function readBacklogDoc(filename: string): string { const matches = [ ...walkMarkdownFiles('docs/method/backlog'), ...walkMarkdownFiles('docs/design'), + ...walkMarkdownFiles('docs/releases'), ].filter((relativePath) => relativePath.endsWith(`/${filename}`)); if (matches.length === 0) { @@ -100,7 +101,7 @@ describe('METHOD docs', () => { expect(readme).toContain('It is updated during ship sync after merge.'); expect(readme).toContain('-> PR/review -> main'); expect(process).toContain('METHOD cycles run as a calm pull-design-test-playback-close-review-ship-sync loop.'); - expect(process).toContain('6. Close the cycle packet with a retro in `docs/method/retro/<cycle>/`.'); + expect(process).toMatch(/6\.\s+Close the cycle packet with a retro in either[\s\S]*`docs\/method\/retro\/<cycle>\/<cycle>\.md`[\s\S]*`docs\/releases\/<version>\/retros\/<cycle>\/<cycle>\.md`[\s\S]*cycle carries release scope\./u); expect(process).toContain('7. Review the complete cycle packet on a branch or PR.'); expect(process).toContain('8. After merge, update repo-level ship surfaces on `main` such as'); expect(process).toContain('reflect merged `main` state, not branch-local closeout state.'); @@ -171,6 +172,19 @@ describe('METHOD docs', () => { } }); + it('formalizes human, agent, and user as abstract seats in doctrine', () => { + const invariant = readRepoFile('docs/invariants/sponsor-abstractness.md'); + const readme = readRepoFile('README.md'); + const process = readRepoFile('docs/method/process.md'); + + expect(invariant).toContain('same way it is in a user story'); + expect(invariant).toContain('not a specific individual, account, model brand, or'); + expect(readme).toContain('like in a user story'); + expect(readme).toContain('It does not mean a specific named'); + expect(process).toContain('like a user story'); + expect(process).toContain('not a literal named'); + }); + it('keeps this repo inbox aligned with the current legend split', () => { const untaggedItems = inboxItemNames().filter((entry) => !/^(PROCESS|SYNTH)_/u.test(entry)); @@ -321,8 +335,8 @@ describe('METHOD docs', () => { const docs = walkMarkdownFiles('docs'); for (const doc of docs) { const content = readRepoFile(doc); - const isDesign = doc.startsWith('docs/design/'); - const isRetro = doc.startsWith('docs/method/retro/') && !doc.includes('/witness/'); + const isDesign = doc.startsWith('docs/design/') || doc.includes('/design/'); + const isRetro = (doc.startsWith('docs/method/retro/') || doc.includes('/retros/')) && !doc.includes('/witness/'); if (isDesign) { expect(content, `${doc} (design) must have a legend field in frontmatter`).toMatch(/^legend:\s+\S+$/mu); @@ -350,13 +364,20 @@ describe('METHOD docs', () => { 'cycle', 'sponsors', 'source_backlog', + 'release', 'design_doc', 'outcome', 'drift_check', 'lane', 'owner', 'priority', + 'keywords', 'acceptance_criteria', + 'blocked_by', + 'blocks', + 'captured_at', + 'source', + 'status', 'github_issue_id', 'github_issue_url', 'github_labels', @@ -410,7 +431,7 @@ describe('METHOD docs', () => { it('docs/method/process.md contains the branching and commitment rules.', () => { const process = readRepoFile('docs/method/process.md'); - expect(process).toContain('cycles/####-slug'); + expect(process).toContain('cycles/<LEGEND>_<slug>'); expect(process).toContain('stage and commit all modified files'); }); @@ -419,9 +440,9 @@ describe('METHOD docs', () => { expect(process).toContain('## Workflow'); }); - it('`docs.test.ts` validates that the policy includes specific naming patterns (e.g., `####-slug`).', () => { + it('`docs.test.ts` validates that the policy includes specific naming patterns (e.g., `<LEGEND>_<slug>`).', () => { const process = readRepoFile('docs/method/process.md'); - expect(process).toContain('####-slug'); + expect(process).toContain('<LEGEND>_<slug>'); expect(process).toContain('maint-slug'); }); @@ -612,7 +633,7 @@ describe('METHOD docs', () => { expect(readme).toContain('npm test'); }); - it('defines shaped releases as artifacts, not versioned backlog lanes', () => { + it('defines shaped releases as artifacts while allowing version-target backlog lanes as planning input', () => { const release = readRepoFile('docs/method/release.md'); expect(release).toContain('docs/method/releases/vX.Y.Z/release.md'); @@ -620,7 +641,8 @@ describe('METHOD docs', () => { expect(release).toContain('docs/releases/vX.Y.Z.md'); expect(release).toContain('`CHANGELOG.md` remains the ledger'); expect(release).toContain('Releases aggregate shipped work.'); - expect(release).toMatch(/They do not create\s+`docs\/method\/backlog\/<version>\/`/u); + expect(release).toContain('docs/method/backlog/v1.1.0/'); + expect(release).toContain('planning input, not release truth'); expect(release).toMatch(/The release design names and justifies the intended version/u); }); @@ -652,16 +674,16 @@ describe('METHOD docs', () => { const readme = readRepoFile('README.md'); const process = readRepoFile('docs/method/process.md'); - // Both docs must name the cycles/####-slug pattern + // Both docs must name the cycles/<LEGEND>_<slug> pattern expect(readme).toContain('cycles/'); - expect(process).toContain('cycles/####-slug'); + expect(process).toContain('cycles/<LEGEND>_<slug>'); - // process.md Rules section must use cycles/####-slug, not bare cycles/<cycle_name> + // process.md Rules section must use cycles/<LEGEND>_<slug>, not bare cycles/<cycle_name> expect(process).not.toContain('cycles/<cycle_name>'); // Branch naming section must show cycles/ prefix expect(process).toContain('cycles/'); - expect(process).toContain('`cycles/####-slug`'); + expect(process).toContain('`cycles/<LEGEND>_<slug>`'); }); it('The RED step in README.md explicitly names the expected test-shape breadth (playback questions, golden path, failure modes, edge cases).', () => { @@ -716,7 +738,18 @@ describe('METHOD docs', () => { // CLI doc names every command expect(cli).toContain('method init'); + expect(cli).toContain('method migrate'); expect(cli).toContain('method inbox'); + expect(cli).toContain('method backlog add'); + expect(cli).toContain('method backlog move'); + expect(cli).toContain('method backlog edit'); + expect(cli).toContain('method backlog list'); + expect(cli).toContain('method backlog deps'); + expect(cli).toContain('method next'); + expect(cli).toContain('method retire'); + expect(cli).toContain('method signpost status'); + expect(cli).toContain('method signpost init'); + expect(cli).toContain('method repair'); expect(cli).toContain('method pull'); expect(cli).toContain('method close'); expect(cli).toContain('method drift'); @@ -724,6 +757,7 @@ describe('METHOD docs', () => { expect(cli).toContain('method status'); expect(cli).toContain('method mcp'); expect(cli).toContain('method sync'); + expect(cli).toContain('sync refs'); expect(cli).toContain('--push'); expect(cli).toContain('--pull'); expect(cli).toContain('Ship Sync'); @@ -732,11 +766,23 @@ describe('METHOD docs', () => { // MCP doc names every tool expect(mcp).toContain('method_status'); expect(mcp).toContain('method_review_state'); + expect(mcp).toContain('method_migrate'); expect(mcp).toContain('method_inbox'); + expect(mcp).toContain('method_backlog_add'); + expect(mcp).toContain('method_backlog_move'); + expect(mcp).toContain('method_backlog_edit'); + expect(mcp).toContain('method_backlog_query'); + expect(mcp).toContain('method_backlog_dependencies'); + expect(mcp).toContain('method_next_work'); + expect(mcp).toContain('method_signpost_status'); + expect(mcp).toContain('method_signpost_init'); + expect(mcp).toContain('method_retire'); + expect(mcp).toContain('method_repair'); expect(mcp).toContain('method_pull'); expect(mcp).toContain('method_close'); expect(mcp).toContain('method_drift'); expect(mcp).toContain('method_sync_ship'); + expect(mcp).toContain('method_sync_refs'); expect(mcp).toContain('method_sync_github'); expect(mcp).toContain('method_capture_witness'); expect(mcp).toMatch(/### `method_status`[\s\S]*- `summary` \(optional\) `boolean`/u); @@ -747,6 +793,24 @@ describe('METHOD docs', () => { // README references both docs expect(readme).toContain('docs/CLI.md'); expect(readme).toContain('docs/MCP.md'); + expect(readme).toContain('method migrate'); + expect(readme).toContain('method backlog list'); + expect(readme).toContain('method backlog deps'); + expect(readme).toContain('method backlog edit'); + expect(readme).toContain('method next'); + expect(readme).toContain('method retire'); + expect(readme).toContain('method signpost status'); + expect(readme).toContain('method signpost init'); + expect(readme).toContain('method_backlog_edit'); + expect(readme).toContain('method_backlog_query'); + expect(readme).toContain('method_backlog_dependencies'); + expect(readme).toContain('method_next_work'); + expect(readme).toContain('method_retire'); + expect(readme).toContain('method_signpost_status'); + expect(readme).toContain('method_signpost_init'); + expect(readme).toContain('method repair'); + expect(readme).toContain('method_repair'); + expect(readme).toContain('method_migrate'); }); it('documents release-note surfaces in the repo structure and release guidance', () => { diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index d229b3f..519b0ee 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -1,10 +1,10 @@ import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, 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 } from '../src/index.js'; -import { runDoctor } from '../src/doctor.js'; +import { runDoctor, runDoctorMigrate, runDoctorRepair } from '../src/doctor.js'; const tempRoots: string[] = []; @@ -72,6 +72,36 @@ describe('doctor engine', () => { expect(badFrontmatter?.fix).toContain('Fix the YAML syntax'); }); + it('attaches structured repair hints to safe deterministic issues such as missing directories, scaffold files, and frontmatter stubs.', () => { + const root = createTempRoot(); + initWorkspace(root); + rmSync(join(root, 'docs/design'), { recursive: true, force: true }); + rmSync(join(root, 'docs/method/release-runbook.md'), { recursive: true, force: true }); + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md'), + '# Missing Frontmatter\n\nBody\n', + 'utf8', + ); + + const report = runDoctor(root); + + expect(report.issues).toContainEqual(expect.objectContaining({ + code: 'missing-directory', + path: 'docs/design', + repair: { kind: 'create-directory', targetPath: 'docs/design' }, + })); + expect(report.issues).toContainEqual(expect.objectContaining({ + code: 'missing-file', + path: 'docs/method/release-runbook.md', + repair: { kind: 'restore-file', targetPath: 'docs/method/release-runbook.md' }, + })); + expect(report.issues).toContainEqual(expect.objectContaining({ + code: 'missing-frontmatter', + path: 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md', + repair: { kind: 'frontmatter-stub', targetPath: 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md' }, + })); + }); + it('skips path-based checks when malformed JSON makes the configured workspace paths unknowable.', () => { const root = createTempRoot(); writeFileSync(join(root, '.method.json'), '{ broken json }\n', 'utf8'); @@ -133,7 +163,6 @@ describe('doctor engine', () => { initWorkspace(root); execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' }); rmSync(join(root, 'docs/method/backlog/asap'), { recursive: true, force: true }); - rmSync(join(root, 'docs/method/backlog/up-next'), { recursive: true, force: true }); const report = runDoctor(root); @@ -142,10 +171,6 @@ describe('doctor engine', () => { code: 'missing-directory', path: 'docs/method/backlog/asap', })); - expect(report.issues).not.toContainEqual(expect.objectContaining({ - code: 'missing-directory', - path: 'docs/method/backlog/up-next', - })); expect(report.issues).toContainEqual(expect.objectContaining({ code: 'git-hooks-not-configured', check: 'git-hooks', @@ -155,6 +180,34 @@ describe('doctor engine', () => { })); }); + it('treats repo-defined backlog lanes such as `v1.1.0` as recognized lane directories.', () => { + const root = createTempRoot(); + initWorkspace(root); + mkdirSync(join(root, 'docs/method/backlog/v1.1.0'), { recursive: true }); + writeFileSync( + join(root, 'docs/method/backlog/v1.1.0/PROCESS_release-scope.md'), + [ + '---', + 'title: "Release Scope"', + 'legend: PROCESS', + 'lane: v1.1.0', + '---', + '', + '# Release Scope', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const report = runDoctor(root); + + expect(report.issues).not.toContainEqual(expect.objectContaining({ + code: 'orphaned-backlog-item', + path: 'docs/method/backlog/v1.1.0/PROCESS_release-scope.md', + })); + }); + it('reports a configured hooks path that exists as a file without collapsing to git-hooks-unavailable.', () => { const root = createTempRoot(); initWorkspace(root); @@ -172,4 +225,156 @@ describe('doctor engine', () => { code: 'git-hooks-unavailable', })); }); + + it('plans and applies the bounded doctor repair set without inventing new mutations outside the repair hints.', () => { + const root = createTempRoot(); + initWorkspace(root); + rmSync(join(root, 'docs/design'), { recursive: true, force: true }); + rmSync(join(root, 'docs/method/release-runbook.md'), { recursive: true, force: true }); + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md'), + '# Missing Frontmatter\n\nBody\n', + 'utf8', + ); + + const plan = runDoctorRepair(root, 'plan'); + expect(plan.ok).toBe(true); + expect(plan.selectedIssues.map((issue) => issue.code)).toEqual([ + 'missing-directory', + 'missing-file', + 'missing-frontmatter', + ]); + expect(plan.repairs.map((repair) => repair.status)).toEqual(['planned', 'planned', 'planned']); + + const applied = runDoctorRepair(root, 'apply'); + expect(applied.ok).toBe(true); + expect(applied.repairs.map((repair) => repair.status)).toEqual(['applied', 'applied', 'applied']); + expect(applied.touchedPaths).toEqual([ + 'docs/design', + 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md', + 'docs/method/release-runbook.md', + ]); + expect(readFileSync(join(root, 'docs/method/release-runbook.md'), 'utf8')).toContain('# Release Runbook'); + expect(readFileSync(join(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md'), 'utf8')).toMatch(/^---\ntitle: "Missing Frontmatter"\n---\n\n# Missing Frontmatter/mu); + expect(applied.unresolvedIssues).not.toContainEqual(expect.objectContaining({ code: 'missing-directory', path: 'docs/design' })); + expect(applied.unresolvedIssues).not.toContainEqual(expect.objectContaining({ code: 'missing-file', path: 'docs/method/release-runbook.md' })); + expect(applied.unresolvedIssues).not.toContainEqual(expect.objectContaining({ code: 'missing-frontmatter', path: 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md' })); + }); + + it('Does `method doctor` detect legacy nested design doc directories and offer to flatten them?', () => { + const root = createTempRoot(); + initWorkspace(root); + mkdirSync(join(root, 'docs/design/0001-legacy-test'), { recursive: true }); + writeFileSync( + join(root, 'docs/design/0001-legacy-test/legacy-test.md'), + '---\ntitle: "Legacy Test"\nlegend: "PROCESS"\ncycle: "0001-legacy-test"\n---\n\n# Legacy Test\n', + 'utf8', + ); + + const report = runDoctor(root); + const legacyIssue = report.issues.find((issue) => issue.code === 'legacy-design-layout'); + + expect(legacyIssue).toBeDefined(); + expect(legacyIssue?.severity).toBe('warning'); + expect(legacyIssue?.fix).toContain('method doctor --repair'); + expect(legacyIssue?.repair?.kind).toBe('flatten-design-doc'); + }); + + it('Does `method_doctor` report `legacy-design-layout` warnings with `flatten-design-doc` repair hints?', () => { + const root = createTempRoot(); + initWorkspace(root); + mkdirSync(join(root, 'docs/design/0002-another-legacy'), { recursive: true }); + writeFileSync( + join(root, 'docs/design/0002-another-legacy/another-legacy.md'), + '---\ntitle: "Another Legacy"\nlegend: "PROCESS"\ncycle: "0002-another-legacy"\n---\n\n# Another Legacy\n', + 'utf8', + ); + + const report = runDoctor(root); + const legacyIssues = report.issues.filter((issue) => issue.code === 'legacy-design-layout'); + + expect(legacyIssues.length).toBe(1); + expect(legacyIssues[0].repair).toEqual({ + kind: 'flatten-design-doc', + targetPath: 'docs/design/0002-another-legacy', + }); + }); + + it('detects legacy nested design doc directories and offers flatten-design-doc repair.', () => { + const root = createTempRoot(); + initWorkspace(root); + mkdirSync(join(root, 'docs/design/0001-method-cli'), { recursive: true }); + writeFileSync( + join(root, 'docs/design/0001-method-cli/method-cli.md'), + [ + '---', + 'title: "Method CLI"', + 'legend: "PROCESS"', + 'cycle: "0001-method-cli"', + '---', + '', + '# Method CLI', + ].join('\n'), + 'utf8', + ); + + const report = runDoctor(root); + const legacyIssue = report.issues.find((issue) => issue.code === 'legacy-design-layout'); + + expect(legacyIssue).toBeDefined(); + expect(legacyIssue?.path).toBe('docs/design/0001-method-cli'); + expect(legacyIssue?.repair?.kind).toBe('flatten-design-doc'); + }); + + it('flatten-design-doc repair moves nested design doc to flat file and updates cycle frontmatter.', () => { + const root = createTempRoot(); + initWorkspace(root); + mkdirSync(join(root, 'docs/design/0001-method-cli'), { recursive: true }); + writeFileSync( + join(root, 'docs/design/0001-method-cli/method-cli.md'), + [ + '---', + 'title: "Method CLI"', + 'legend: "PROCESS"', + 'cycle: "0001-method-cli"', + '---', + '', + '# Method CLI', + ].join('\n'), + 'utf8', + ); + + const result = runDoctorRepair(root, 'apply'); + const flattenRepair = result.repairs.find((repair) => repair.kind === 'flatten-design-doc'); + + expect(flattenRepair?.status).toBe('applied'); + expect(existsSync(join(root, 'docs/design/PROCESS_method-cli.md'))).toBe(true); + expect(existsSync(join(root, 'docs/design/0001-method-cli'))).toBe(false); + + const content = readFileSync(join(root, 'docs/design/PROCESS_method-cli.md'), 'utf8'); + expect(content).toContain('cycle: "PROCESS_method-cli"'); + }); + + it('runs doctor, applies the bounded repair set, and re-checks the workspace through one migrate result.', () => { + const root = createTempRoot(); + initWorkspace(root); + rmSync(join(root, 'docs/design'), { recursive: true, force: true }); + rmSync(join(root, 'docs/method/release-runbook.md'), { recursive: true, force: true }); + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md'), + '# Missing Frontmatter\n\nBody\n', + 'utf8', + ); + + const result = runDoctorMigrate(root); + + expect(result.ok).toBe(true); + expect(result.changed).toBe(true); + expect(result.initialReport.status).toBe('error'); + expect(result.repair.mode).toBe('apply'); + expect(result.repair.repairs.map((repair) => repair.status)).toEqual(['applied', 'applied', 'applied']); + expect(result.finalReport.issues).not.toContainEqual(expect.objectContaining({ code: 'missing-directory', path: 'docs/design' })); + expect(result.finalReport.issues).not.toContainEqual(expect.objectContaining({ code: 'missing-file', path: 'docs/method/release-runbook.md' })); + expect(result.finalReport.issues).not.toContainEqual(expect.objectContaining({ code: 'missing-frontmatter', path: 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md' })); + }); }); diff --git a/tests/domain.test.ts b/tests/domain.test.ts index 069eee6..d9fbf81 100644 --- a/tests/domain.test.ts +++ b/tests/domain.test.ts @@ -12,8 +12,7 @@ describe('Domain Models', () => { // Invalid Cycle (wrong types) expect(() => CycleSchema.parse({ - name: '0001-test', - number: '1', // should be number + name: 123, slug: 'test', designDoc: 'path', retroDoc: 'path', @@ -21,18 +20,17 @@ describe('Domain Models', () => { // Valid Cycle const validCycle = { - name: '0001-test', - number: 1, + name: 'PROCESS_test', slug: 'test', - designDoc: 'docs/design/0001-test/test.md', - retroDoc: 'docs/method/retro/0001-test/test.md', + designDoc: 'docs/design/PROCESS_test.md', + retroDoc: 'docs/method/retro/PROCESS_test/PROCESS_test.md', }; expect(CycleSchema.parse(validCycle)).toEqual(validCycle); - // Invalid BacklogItem (invalid lane) + // Invalid BacklogItem (malformed lane) expect(() => BacklogItemSchema.parse({ stem: 'test', - lane: 'invalid-lane', + lane: '../invalid-lane', path: 'path', slug: 'test', })).toThrow(); diff --git a/tests/drift.test.ts b/tests/drift.test.ts index 7a93330..d4a78ae 100644 --- a/tests/drift.test.ts +++ b/tests/drift.test.ts @@ -21,18 +21,18 @@ function createTempRoot(): string { } function setupCycleWithQuestions(root: string, questions: string[]): Cycle { - const designDir = resolve(root, 'docs/design/0001-test-cycle'); + const designDir = resolve(root, 'docs/design'); mkdirSync(designDir, { recursive: true }); - const designDoc = resolve(designDir, 'test-cycle.md'); + const designDoc = resolve(designDir, 'PROCESS_test-cycle.md'); const questionLines = questions.map((q) => `- [ ] ${q}`).join('\n'); writeFileSync(designDoc, `# Test Cycle\n\n## Playback Questions\n\n### Human\n\n${questionLines}\n\n## Non-goals\n`); return { - name: '0001-test-cycle', + name: 'PROCESS_test-cycle', slug: 'test-cycle', designDoc, - retroDoc: resolve(root, 'docs/method/retro/0001-test-cycle/test-cycle.md'), + retroDoc: resolve(root, 'docs/method/retro/PROCESS_test-cycle/PROCESS_test-cycle.md'), }; } diff --git a/tests/exec.test.ts b/tests/exec.test.ts index 5892e95..0503d03 100644 --- a/tests/exec.test.ts +++ b/tests/exec.test.ts @@ -39,7 +39,7 @@ describe('Async Exec', () => { workspace.captureIdea('Test Idea', 'PROCESS', 'Async Test'); workspace.pullItem('PROCESS_async-test'); - const result = workspace.captureWitness('0001-async-test'); + const result = workspace.captureWitness('PROCESS_async-test'); expect(result).toBeInstanceOf(Promise); const path = await result; expect(path).toContain('witness/verification.md'); @@ -53,10 +53,10 @@ describe('Async Exec', () => { workspace.captureIdea('Test Idea', 'PROCESS', 'Close Test'); workspace.pullItem('PROCESS_close-test'); - const result = workspace.closeCycle('0001-close-test', true, 'hill-met'); + const result = workspace.closeCycle('PROCESS_close-test', true, 'hill-met'); expect(result).toBeInstanceOf(Promise); const cycle = await result; - expect(cycle.name).toBe('0001-close-test'); + expect(cycle.name).toBe('PROCESS_close-test'); }); it('METHOD_TEST mock path still works and returns the same format.', async () => { diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 33f7893..71a6337 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -26,6 +26,10 @@ function createTempRoot(): string { return root; } +function ensureBacklogLane(root: string, lane: string): void { + mkdirSync(join(root, 'docs/method/backlog', lane), { recursive: true }); +} + class MemoryWriter { output = ''; @@ -81,13 +85,25 @@ describe('MCP Server', () => { const toolNames = result.tools.map((t: any) => t.name); expect(toolNames).toContain('method_doctor'); + expect(toolNames).toContain('method_repair'); + expect(toolNames).toContain('method_migrate'); expect(toolNames).toContain('method_status'); expect(toolNames).toContain('method_review_state'); expect(toolNames).toContain('method_inbox'); + expect(toolNames).toContain('method_backlog_add'); + expect(toolNames).toContain('method_backlog_move'); + expect(toolNames).toContain('method_backlog_edit'); + expect(toolNames).toContain('method_backlog_query'); + expect(toolNames).toContain('method_backlog_dependencies'); + expect(toolNames).toContain('method_next_work'); + expect(toolNames).toContain('method_signpost_status'); + expect(toolNames).toContain('method_signpost_init'); + expect(toolNames).toContain('method_retire'); expect(toolNames).toContain('method_pull'); expect(toolNames).toContain('method_close'); expect(toolNames).toContain('method_drift'); expect(toolNames).toContain('method_sync_ship'); + expect(toolNames).toContain('method_sync_refs'); expect(toolNames).toContain('method_capture_witness'); // Every tool must require cwd @@ -175,6 +191,440 @@ describe('MCP Server', () => { expect(mcpResult.content[0].text).toContain('Status: error'); }); + it('Does `method_backlog_dependencies` match the CLI JSON contract for ready work and critical paths?', async () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_foundation.md'), + [ + '---', + 'title: "Foundation"', + 'legend: PROCESS', + 'lane: up-next', + '---', + '', + '# Foundation', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_build.md'), + [ + '---', + 'title: "Build"', + 'legend: PROCESS', + 'lane: up-next', + 'blocked_by:', + ' - foundation', + 'blocks:', + ' - finish', + '---', + '', + '# Build', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_finish.md'), + [ + '---', + 'title: "Finish"', + 'legend: PROCESS', + 'lane: up-next', + 'blocked_by:', + ' - build', + '---', + '', + '# Finish', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const callToolHandler = createCallToolHarness(); + const stdout = new MemoryWriter(); + + const cliExitCode = await runCli(['backlog', 'deps', 'finish', '--critical-path', '--json'], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + const mcpResult = await callToolHandler({ + params: { + name: 'method_backlog_dependencies', + arguments: { + workspace: root, + item: 'finish', + criticalPath: true, + }, + }, + }); + + expect(cliExitCode).toBe(0); + expect(mcpResult.isError).toBe(false); + expect(mcpResult.structuredContent.tool).toBe('method_backlog_dependencies'); + expect(mcpResult.structuredContent.result).toEqual(JSON.parse(stdout.output)); + expect(mcpResult.structuredContent.result.focus.item.stem).toBe('PROCESS_finish'); + expect(mcpResult.content[0].text).toContain('Critical path to PROCESS_finish'); + }); + + it('Does `method_backlog_query` match the CLI JSON contract for explicit keywords, owners, readiness, sort mode, and bounded filtering?', async () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_query-foundation.md'), + [ + '---', + 'title: "Query Foundation"', + 'legend: PROCESS', + 'lane: up-next', + 'owner: "METHOD maintainers"', + 'priority: medium', + 'keywords:', + ' - agent', + ' - query', + 'blocked_by:', + ' - setup', + 'acceptance_criteria:', + ' - "Has a query API"', + '---', + '', + '# Query Foundation', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_query-polish.md'), + [ + '---', + 'title: "Query Polish"', + 'legend: PROCESS', + 'lane: up-next', + 'priority: medium', + 'keywords:', + ' - agent', + ' - polish', + '---', + '', + '# Query Polish', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const callToolHandler = createCallToolHarness(); + const stdout = new MemoryWriter(); + + const cliExitCode = await runCli([ + 'backlog', + 'list', + '--lane', + 'up-next', + '--keyword', + 'agent', + '--owner', + 'METHOD maintainers', + '--blocked', + '--has-acceptance-criteria', + '--blocked-by', + 'setup', + '--sort', + 'priority', + '--limit', + '1', + '--json', + ], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + const mcpResult = await callToolHandler({ + params: { + name: 'method_backlog_query', + arguments: { + workspace: root, + lane: 'up-next', + keyword: 'agent', + owner: 'METHOD maintainers', + ready: false, + hasAcceptanceCriteria: true, + blockedBy: 'setup', + sort: 'priority', + limit: 1, + }, + }, + }); + + expect(cliExitCode).toBe(0); + expect(mcpResult.isError).toBe(false); + expect(mcpResult.structuredContent.tool).toBe('method_backlog_query'); + expect(mcpResult.structuredContent.result).toEqual(JSON.parse(stdout.output)); + expect(mcpResult.structuredContent.result.items[0].keywords).toEqual(['agent', 'query']); + expect(mcpResult.content[0].text).toContain('Backlog query'); + }); + + it('Does `method_backlog_edit` match the CLI JSON contract for schema-backed metadata updates?', async () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_metadata-edit.md'), + [ + '---', + 'title: "Metadata Edit"', + 'legend: PROCESS', + 'lane: up-next', + '---', + '', + '# Metadata Edit', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const callToolHandler = createCallToolHarness(); + const stdout = new MemoryWriter(); + + const cliExitCode = await runCli([ + 'backlog', + 'edit', + 'metadata-edit', + '--owner', + 'Core Team', + '--priority', + 'HIGH', + '--keyword', + 'roadmap', + '--keyword', + 'query', + '--blocked-by', + 'setup', + '--blocks', + 'finish', + '--json', + ], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + const mcpResult = await callToolHandler({ + params: { + name: 'method_backlog_edit', + arguments: { + workspace: root, + item: 'metadata-edit', + owner: 'Core Team', + priority: 'HIGH', + keywords: ['roadmap', 'query'], + blockedBy: ['setup'], + blocks: ['finish'], + }, + }, + }); + + expect(cliExitCode).toBe(0); + expect(mcpResult.isError).toBe(false); + expect(mcpResult.structuredContent.tool).toBe('method_backlog_edit'); + expect(mcpResult.structuredContent.result).toEqual(JSON.parse(stdout.output)); + expect(mcpResult.content[0].text).toContain('Updated backlog metadata'); + }); + + it('Does `method_next_work` match the CLI JSON contract for filtered recommendations and optional blocked inclusion?', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-11T12:48:24.000Z')); + try { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_blocked-owner-match.md'), + [ + '---', + 'title: "Blocked Owner Match"', + 'legend: PROCESS', + 'lane: up-next', + 'owner: "Core Team"', + 'priority: medium', + 'keywords:', + ' - roadmap', + 'blocked_by:', + ' - setup', + '---', + '', + '# Blocked Owner Match', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + writeFileSync( + join(root, 'docs/method/backlog/cool-ideas/PROCESS_ready-owner-match.md'), + [ + '---', + 'title: "Ready Owner Match"', + 'legend: PROCESS', + 'lane: cool-ideas', + 'owner: "Core Team"', + 'priority: medium', + 'keywords:', + ' - roadmap', + '---', + '', + '# Ready Owner Match', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + + const callToolHandler = createCallToolHarness(); + const stdout = new MemoryWriter(); + + const cliExitCode = await runCli(['next', '--keyword', 'roadmap', '--owner', 'Core Team', '--include-blocked', '--limit', '3', '--json'], { + cwd: root, + stdout, + stderr: new MemoryWriter(), + }); + const mcpResult = await callToolHandler({ + params: { + name: 'method_next_work', + arguments: { + workspace: root, + keyword: 'roadmap', + owner: 'Core Team', + includeBlocked: true, + limit: 3, + }, + }, + }); + + expect(cliExitCode).toBe(0); + expect(mcpResult.isError).toBe(false); + expect(mcpResult.structuredContent.tool).toBe('method_next_work'); + expect(mcpResult.structuredContent.result).toEqual(JSON.parse(stdout.output)); + expect(mcpResult.content[0].text).toContain('Next-work menu'); + expect(mcpResult.structuredContent.result.selection_notes).toContain('Applied filters: keyword=roadmap, owner=core team.'); + } finally { + vi.useRealTimers(); + } + }); + + it('Does `method_repair` plan and apply the same bounded repair set through MCP structured results?', async () => { + const root = createTempRoot(); + initWorkspace(root); + rmSync(join(root, 'docs/design'), { recursive: true, force: true }); + rmSync(join(root, 'docs/method/release-runbook.md'), { recursive: true, force: true }); + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md'), + '# Missing Frontmatter\n\nBody\n', + 'utf8', + ); + const callToolHandler = createCallToolHarness(); + + const plan = await callToolHandler({ + params: { + name: 'method_repair', + arguments: { + workspace: root, + mode: 'plan', + }, + }, + }); + + expect(plan.isError).toBe(false); + expect(plan.structuredContent.tool).toBe('method_repair'); + expect(plan.structuredContent.result.mode).toBe('plan'); + expect(plan.structuredContent.result.repairs.map((repair: { status: string }) => repair.status)).toEqual(['planned', 'planned', 'planned']); + + const applied = await callToolHandler({ + params: { + name: 'method_repair', + arguments: { + workspace: root, + mode: 'apply', + }, + }, + }); + + expect(applied.isError).toBe(false); + expect(applied.structuredContent.tool).toBe('method_repair'); + expect(applied.structuredContent.result.mode).toBe('apply'); + expect(applied.structuredContent.result.repairs.map((repair: { status: string }) => repair.status)).toEqual(['applied', 'applied', 'applied']); + expect(readFileSync(join(root, 'docs/method/release-runbook.md'), 'utf8')).toContain('# Release Runbook'); + expect(readFileSync(join(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md'), 'utf8')).toMatch(/^---\ntitle: "Missing Frontmatter"\n---\n\n# Missing Frontmatter/mu); + }); + + it('Does `method_migrate` apply the bounded repair set and return both before/after doctor reports?', async () => { + const root = createTempRoot(); + initWorkspace(root); + rmSync(join(root, 'docs/design'), { recursive: true, force: true }); + rmSync(join(root, 'docs/method/release-runbook.md'), { recursive: true, force: true }); + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_missing-frontmatter.md'), + '# Missing Frontmatter\n\nBody\n', + 'utf8', + ); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_migrate', + arguments: { + workspace: root, + }, + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.tool).toBe('method_migrate'); + expect(result.structuredContent.result.changed).toBe(true); + expect(result.structuredContent.result.initialReport.status).toBe('error'); + expect(result.structuredContent.result.repair.mode).toBe('apply'); + expect(result.structuredContent.result.repair.repairs.map((repair: { status: string }) => repair.status)).toEqual(['applied', 'applied', 'applied']); + expect(readFileSync(join(root, 'docs/method/release-runbook.md'), 'utf8')).toContain('# Release Runbook'); + }); + + it('Does `method_sync_refs` return structured content with the refreshed targets?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const callToolHandler = createCallToolHarness(); + const changelogBefore = readFileSync(join(root, 'CHANGELOG.md'), 'utf8'); + const bearingPath = join(root, 'docs/BEARING.md'); + expect(existsSync(bearingPath)).toBe(false); + + const result = await callToolHandler({ + params: { + name: 'method_sync_refs', + arguments: { + workspace: root, + }, + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.tool).toBe('method_sync_refs'); + expect(result.structuredContent.result.targets).toEqual([ + 'ARCHITECTURE.md', + 'docs/CLI.md', + 'docs/MCP.md', + 'docs/GUIDE.md', + ]); + expect(result.content[0].text).toContain('Refreshed ARCHITECTURE.md, docs/CLI.md, docs/MCP.md, docs/GUIDE.md'); + expect(readFileSync(join(root, 'CHANGELOG.md'), 'utf8')).toBe(changelogBefore); + expect(existsSync(bearingPath)).toBe(false); + }); + it('Does the CLI `--json` output exactly match the MCP `structuredContent.result` contract?', async () => { const root = createTempRoot(); initWorkspace(root); @@ -396,18 +846,18 @@ describe('MCP Server', () => { }); 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.name).toBe('PROCESS_test-idea-from-mcp'); expect(pullResult.structuredContent.result.cycle.designDoc).toBe( - 'docs/design/0001-test-idea-from-mcp/test-idea-from-mcp.md', + 'docs/design/PROCESS_test-idea-from-mcp.md', ); const captureResult = await callToolHandler({ - params: { name: 'method_capture_witness', arguments: { workspace: root, cycle: '0001-test-idea-from-mcp' } }, + params: { name: 'method_capture_witness', arguments: { workspace: root, cycle: 'PROCESS_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', + 'docs/method/retro/PROCESS_test-idea-from-mcp/witness/verification.md', ); const closeResult = await callToolHandler({ @@ -415,7 +865,7 @@ describe('MCP Server', () => { name: 'method_close', arguments: { workspace: root, - cycle: '0001-test-idea-from-mcp', + cycle: 'PROCESS_test-idea-from-mcp', driftCheck: true, outcome: 'hill-met', }, @@ -423,9 +873,9 @@ describe('MCP Server', () => { }); 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.name).toBe('PROCESS_test-idea-from-mcp'); expect(closeResult.structuredContent.result.cycle.retroDoc).toBe( - 'docs/method/retro/0001-test-idea-from-mcp/test-idea-from-mcp.md', + 'docs/method/retro/PROCESS_test-idea-from-mcp/PROCESS_test-idea-from-mcp.md', ); } finally { if (originalMethodTest === undefined) { @@ -437,6 +887,280 @@ describe('MCP Server', () => { } }); + it('Does `method_backlog_add` create a shaped backlog note in a requested custom lane and return stable structured content?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_backlog_add', + arguments: { + workspace: root, + lane: 'v1.1.0', + title: 'Backlog Add', + legend: 'process', + body: 'Body from mcp.\n', + }, + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.tool).toBe('method_backlog_add'); + expect(result.structuredContent.result.path).toBe('docs/method/backlog/v1.1.0/PROCESS_backlog-add.md'); + expect(result.structuredContent.result.lane).toBe('v1.1.0'); + expect(result.structuredContent.result.legend).toBe('PROCESS'); + expect(result.structuredContent.result.title).toBe('Backlog Add'); + expect(result.structuredContent.result.stem).toBe('PROCESS_backlog-add'); + expect(result.structuredContent.result.slug).toBe('backlog-add'); + expect(readFileSync(join(root, 'docs/method/backlog/v1.1.0/PROCESS_backlog-add.md'), 'utf8')).toContain('Body from mcp.'); + }); + + it('Does `method_backlog_add` reject malformed lane names instead of writing outside the backlog tree?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_backlog_add', + arguments: { + workspace: root, + lane: '../oops', + title: 'Backlog Add', + }, + }, + }); + + expect(result.isError).toBe(true); + expect(result.structuredContent.tool).toBe('method_backlog_add'); + expect(result.structuredContent.error.message).toContain('Backlog lane must be a live lane'); + }); + + it('Does `method_backlog_move` move a live backlog note and return both source and destination identity?', async () => { + const root = createTempRoot(); + initWorkspace(root); + writeFileSync( + join(root, 'docs/method/backlog/inbox/PROCESS_move-me.md'), + [ + '---', + 'title: "Move Me"', + 'legend: PROCESS', + 'lane: inbox', + '---', + '', + '# Move Me', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_backlog_move', + arguments: { + workspace: root, + item: 'move-me', + to: 'v1.1.0', + }, + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.tool).toBe('method_backlog_move'); + expect(result.structuredContent.result.sourcePath).toBe('docs/method/backlog/inbox/PROCESS_move-me.md'); + expect(result.structuredContent.result.path).toBe('docs/method/backlog/v1.1.0/PROCESS_move-me.md'); + expect(result.structuredContent.result.lane).toBe('v1.1.0'); + expect(result.structuredContent.result.legend).toBe('PROCESS'); + expect(readFileSync(join(root, 'docs/method/backlog/v1.1.0/PROCESS_move-me.md'), 'utf8')).toContain('lane: v1.1.0'); + }); + + it('Does `method_backlog_move` reject unresolved item selectors before mutating the repo?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_backlog_move', + arguments: { + workspace: root, + item: 'missing-item', + to: 'v1.1.0', + }, + }, + }); + + expect(result.isError).toBe(true); + expect(result.structuredContent.tool).toBe('method_backlog_move'); + expect(result.structuredContent.error.message).toContain('Could not find backlog item'); + }); + + it('Does `method_retire` preview and retire a live backlog note with the same structured result contract?', async () => { + const root = createTempRoot(); + initWorkspace(root); + ensureBacklogLane(root, 'up-next'); + writeFileSync( + join(root, 'docs/method/backlog/up-next/PROCESS_retire-me.md'), + [ + '---', + 'title: "Retire Me"', + 'legend: PROCESS', + 'lane: up-next', + '---', + '', + '# Retire Me', + '', + 'Body', + ].join('\n'), + 'utf8', + ); + const callToolHandler = createCallToolHarness(); + + const preview = await callToolHandler({ + params: { + name: 'method_retire', + arguments: { + workspace: root, + item: 'retire-me', + reason: 'Superseded by another cycle.', + replacement: 'docs/design/0040-release-scope/release-scope.md', + dryRun: true, + }, + }, + }); + + expect(preview.isError).toBe(false); + expect(preview.structuredContent.tool).toBe('method_retire'); + expect(preview.structuredContent.result.dryRun).toBe(true); + expect(preview.structuredContent.result.sourcePath).toBe('docs/method/backlog/up-next/PROCESS_retire-me.md'); + expect(preview.structuredContent.result.graveyardPath).toBe('docs/method/graveyard/PROCESS_retire-me.md'); + expect(existsSync(join(root, 'docs/method/backlog/up-next/PROCESS_retire-me.md'))).toBe(true); + + const applied = await callToolHandler({ + params: { + name: 'method_retire', + arguments: { + workspace: root, + item: 'retire-me', + reason: 'Superseded by another cycle.', + replacement: 'docs/design/0040-release-scope/release-scope.md', + }, + }, + }); + + expect(applied.isError).toBe(false); + expect(applied.structuredContent.tool).toBe('method_retire'); + expect(applied.structuredContent.result.dryRun).toBe(false); + expect(applied.structuredContent.result.sourcePath).toBe('docs/method/backlog/up-next/PROCESS_retire-me.md'); + expect(applied.structuredContent.result.graveyardPath).toBe('docs/method/graveyard/PROCESS_retire-me.md'); + expect(readFileSync(join(root, 'docs/method/graveyard/PROCESS_retire-me.md'), 'utf8')).toContain('## Disposition'); + expect(readFileSync(join(root, 'docs/method/graveyard/PROCESS_retire-me.md'), 'utf8')).toContain('Replacement: `docs/design/0040-release-scope/release-scope.md`'); + }); + + it('Do signpost MCP tools report expected signposts and initialize supported missing signposts?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const callToolHandler = createCallToolHarness(); + + const status = await callToolHandler({ + params: { + name: 'method_signpost_status', + arguments: { + workspace: root, + }, + }, + }); + + expect(status.isError).toBe(false); + expect(status.structuredContent.tool).toBe('method_signpost_status'); + expect(status.structuredContent.result.missing).toContain('docs/BEARING.md'); + expect(status.structuredContent.result.signposts).toContainEqual(expect.objectContaining({ + name: 'BEARING', + path: 'docs/BEARING.md', + exists: false, + initable: true, + })); + + const initialized = await callToolHandler({ + params: { + name: 'method_signpost_init', + arguments: { + workspace: root, + name: 'BEARING', + }, + }, + }); + + expect(initialized.isError).toBe(false); + expect(initialized.structuredContent.tool).toBe('method_signpost_init'); + expect(initialized.structuredContent.result.initializedTargets).toEqual(['docs/BEARING.md']); + expect(readFileSync(join(root, 'docs/BEARING.md'), 'utf8')).toContain('# BEARING'); + }); + + it('Does `method_inbox` capture explicit source metadata and return stable structured content?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_inbox', + arguments: { + workspace: root, + idea: 'Feedback note worth triaging', + legend: 'PROCESS', + title: 'Missing API Surfaces', + source: 'cross-repo usage', + body: 'Observed while operating METHOD elsewhere.\n', + capturedAt: '2026-04-11', + }, + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.tool).toBe('method_inbox'); + expect(result.structuredContent.result.path).toBe('docs/method/backlog/inbox/PROCESS_missing-api-surfaces.md'); + expect(result.structuredContent.result.lane).toBe('inbox'); + expect(result.structuredContent.result.legend).toBe('PROCESS'); + expect(result.structuredContent.result.title).toBe('Missing API Surfaces'); + expect(result.structuredContent.result.source).toBe('cross-repo usage'); + expect(result.structuredContent.result.captured_at).toBe('2026-04-11'); + expect(readFileSync(join(root, 'docs/method/backlog/inbox/PROCESS_missing-api-surfaces.md'), 'utf8')).toContain('Observed while operating METHOD elsewhere.'); + }); + + it('Does `method_pull` use release-scoped cycle packet paths when the backlog item carries release metadata?', async () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + workspace.createBacklogItem({ + lane: 'v2.4.5', + title: 'Release Scope', + legend: 'PROCESS', + body: 'Release-scoped packet.\n', + }); + const callToolHandler = createCallToolHarness(); + + const result = await callToolHandler({ + params: { + name: 'method_pull', + arguments: { + workspace: root, + item: 'PROCESS_release-scope', + }, + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent.tool).toBe('method_pull'); + expect(result.structuredContent.result.cycle.designDoc).toBe( + 'docs/releases/v2.4.5/design/PROCESS_release-scope.md', + ); + expect(readFileSync(join(root, 'docs/releases/v2.4.5/design/PROCESS_release-scope.md'), 'utf8')).toContain('release: "v2.4.5"'); + }); + it('Does `method_inbox` normalize persisted legends before returning structured content?', async () => { const root = createTempRoot(); initWorkspace(root); @@ -456,7 +1180,7 @@ describe('MCP Server', () => { 'utf8', ); - const captureIdea = vi.spyOn(Workspace.prototype, 'captureIdea').mockReturnValue(persistedPath); + const captureIdea = vi.spyOn(Workspace.prototype, 'captureIdeaWithMetadata').mockReturnValue(persistedPath); const callToolHandler = createCallToolHarness(); const result = await callToolHandler({ @@ -502,7 +1226,7 @@ describe('MCP Server', () => { name: 'method_close', arguments: { workspace: root, - cycle: '0001-close-validation', + cycle: 'PROCESS_close-validation', driftCheck: 'false', outcome: 'hill-met', }, @@ -516,7 +1240,7 @@ describe('MCP Server', () => { name: 'method_close', arguments: { workspace: root, - cycle: '0001-close-validation', + cycle: 'PROCESS_close-validation', driftCheck: true, outcome: 'done', }, diff --git a/tests/ship-sync.test.ts b/tests/ship-sync.test.ts index b15a6ff..107fbaa 100644 --- a/tests/ship-sync.test.ts +++ b/tests/ship-sync.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -48,9 +48,9 @@ describe('Ship Sync', () => { initWorkspace(root); const workspace = new Workspace(root); - // Create a backlog item and move it to up-next - workspace.captureIdea('Next priority', 'PROCESS', 'Up Next'); - workspace.moveBacklogItem('docs/method/backlog/inbox/PROCESS_up-next.md', 'up-next'); + // Create a backlog item and move it to asap + workspace.captureIdea('Next priority', 'PROCESS', 'ASAP Priority'); + workspace.moveBacklogItem('docs/method/backlog/inbox/PROCESS_asap-priority.md', 'asap'); const itemPath = workspace.captureIdea('Feature Z', 'PROCESS', 'Just Shipped'); const cycle = workspace.pullItem('PROCESS_just-shipped'); @@ -62,7 +62,7 @@ describe('Ship Sync', () => { // Verify BEARING const bearing = readFileSync(join(root, 'docs/BEARING.md'), 'utf8'); expect(bearing).toContain(`- \`${cycle.name}\`: Just Shipped`); - expect(bearing).toContain('PROCESS_up-next'); + expect(bearing).toContain('PROCESS_asap-priority'); }); it('`src/index.ts` provides a `shipSync()` method that performs the orchestration.', () => { @@ -96,7 +96,7 @@ describe('Ship Sync', () => { expect(countSecond).toBe(1); }); - it('treats cycle ranges and numeric references in release sections as already shipped.', async () => { + it('treats cycle name references in release sections as already shipped.', async () => { const root = createTempRoot(); initWorkspace(root); const workspace = new Workspace(root); @@ -120,7 +120,8 @@ describe('Ship Sync', () => { '', '## v0.1.0', '', - 'Released cycle work: 0001–0002.', + `- Feature A (${cycleOne.name})`, + `- Feature B (${cycleTwo.name})`, ].join('\n'), 'utf8', ); @@ -141,6 +142,23 @@ describe('Ship Sync', () => { expect(result.updated).not.toContain('CHANGELOG.md'); }); + it('Does `method sync refs` refresh only `ARCHITECTURE.md`, `docs/CLI.md`, `docs/MCP.md`, and `docs/GUIDE.md` without mutating `CHANGELOG.md` or `docs/BEARING.md`?', () => { + const root = createTempRoot(); + initWorkspace(root); + const workspace = new Workspace(root); + const changelogBefore = readFileSync(join(root, 'CHANGELOG.md'), 'utf8'); + const bearingPath = join(root, 'docs/BEARING.md'); + expect(existsSync(bearingPath)).toBe(false); + + const result = workspace.syncRefs(); + + expect(result.targets).toEqual(['ARCHITECTURE.md', 'docs/CLI.md', 'docs/MCP.md', 'docs/GUIDE.md']); + expect(result.targets).not.toContain('CHANGELOG.md'); + expect(result.targets).not.toContain('docs/BEARING.md'); + expect(readFileSync(join(root, 'CHANGELOG.md'), 'utf8')).toBe(changelogBefore); + expect(existsSync(bearingPath)).toBe(false); + }); + it('Does generated `BEARING.md` stop claiming witness generation is not automated?', async () => { const root = createTempRoot(); initWorkspace(root); @@ -232,7 +250,7 @@ describe('Ship Sync', () => { const workspace = new Workspace(root); const bearing = renderBearing(workspace.status(), [], 'test-sha'); - expect(bearing).toContain('Current priority: no explicit `asap` or `up-next` item is currently recorded.'); + expect(bearing).toContain('Current priority: no explicit `asap` item is currently recorded.'); expect(bearing).toContain('No acute coordination pain is currently recorded.'); expect(bearing).not.toContain('Current priority: pull TBD'); expect(bearing).not.toContain('Backlog maintenance is still largely manual.'); diff --git a/tests/witness.test.ts b/tests/witness.test.ts index 5c32c5e..7d65d60 100644 --- a/tests/witness.test.ts +++ b/tests/witness.test.ts @@ -52,10 +52,10 @@ describe('Automated Witness Capture', () => { // No need to spy manually now, index.ts handles it via METHOD_TEST const witnessPath = await workspace.captureWitness(cycle.name); - expect(witnessPath).toContain('docs/method/retro/0001-witness-test/witness/verification.md'); - + expect(witnessPath).toContain('docs/method/retro/PROCESS_witness-test/witness/verification.md'); + const content = readFileSync(witnessPath, 'utf8'); - expect(content).toContain('# Verification Witness for Cycle 1'); + expect(content).toContain('# Verification Witness for Cycle PROCESS_witness-test'); expect(content).toContain('[MOCK] Output for npm test'); expect(content).toContain(expectedDrift); expect(content).not.toContain('No drift output captured.'); @@ -145,12 +145,12 @@ describe('Automated Witness Capture', () => { it('Normalizes generated frontmatter paths to POSIX separators.', () => { const root = createTempRoot(); - const designDocPath = join(root, 'docs', 'design', '0001-windows-paths', 'windows-paths.md'); + const designDocPath = join(root, 'docs', 'design', 'PROCESS_windows-paths.md'); mkdirSync(dirname(designDocPath), { recursive: true }); writeFileSync(designDocPath, '# Windows Paths\n', 'utf8'); const designContent = renderDesignDoc({ - cycleName: '0001-windows-paths', + cycleName: 'PROCESS_windows-paths', title: 'Windows Paths', source: 'docs\\method\\backlog\\asap\\PROCESS_windows-paths.md', backlogBody: 'Body', @@ -159,17 +159,16 @@ describe('Automated Witness Capture', () => { const retroContent = renderRetroDoc({ cycle: { - name: '0001-windows-paths', - number: 1, + name: 'PROCESS_windows-paths', slug: 'windows-paths', designDoc: designDocPath, retroDoc: join(root, 'unused.md'), }, root, outcome: 'partial', - witnessDir: 'docs\\method\\retro\\0001-windows-paths\\witness', + witnessDir: 'docs\\method\\retro\\PROCESS_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`'); + expect(retroContent).toContain('design_doc: "docs/design/PROCESS_windows-paths.md"'); + expect(retroContent).toContain('Add artifacts under `docs/method/retro/PROCESS_windows-paths/witness`'); }); });