From ed9a5913d2a674a1fc9fbe7602a81f6218b4b85f Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 11 Apr 2026 02:02:18 -0700 Subject: [PATCH 1/5] docs(feedback): capture missing method api surfaces --- .../2026-04-11-missing-api-surfaces.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/method/feedback/2026-04-11-missing-api-surfaces.md diff --git a/docs/method/feedback/2026-04-11-missing-api-surfaces.md b/docs/method/feedback/2026-04-11-missing-api-surfaces.md new file mode 100644 index 0000000..6c650ee --- /dev/null +++ b/docs/method/feedback/2026-04-11-missing-api-surfaces.md @@ -0,0 +1,169 @@ +--- +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: "unprocessed" +--- + +# 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 ` exists +- `method pull ` 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 --to up-next` +- `method retire --reason ...` +- `method feedback archive --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. From 31636375c7bb4efddcc386ce80bc88feed500c0c Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 11 Apr 2026 02:51:05 -0700 Subject: [PATCH 2/5] docs(backlog): file task dependency DAG feature request --- .../inbox/PROCESS_task-dependency-dag.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/method/backlog/inbox/PROCESS_task-dependency-dag.md diff --git a/docs/method/backlog/inbox/PROCESS_task-dependency-dag.md b/docs/method/backlog/inbox/PROCESS_task-dependency-dag.md new file mode 100644 index 0000000..cfa9659 --- /dev/null +++ b/docs/method/backlog/inbox/PROCESS_task-dependency-dag.md @@ -0,0 +1,26 @@ +# 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/`. From be28a50ec9b0fc5ca9b1bfd75a8e9b9a26307409 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 12 Apr 2026 17:10:34 -0700 Subject: [PATCH 3/5] fix(cli): allow MCP startup outside METHOD workspaces --- src/cli.ts | 14 +++++++------- tests/cli.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 5680759..be67da6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -57,6 +57,13 @@ export async function runCli( } return report.status === 'error' ? 1 : 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(() => {}); + } const workspace = new Workspace(root); workspace.ensureInitialized(); if (parsed.command === 'inbox') { @@ -101,13 +108,6 @@ export async function runCli( } 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(() => {}); - } if (parsed.command === 'sync') { if (parsed.adapter === 'github') { const token = workspace.config.github_token; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 37998f4..6ad6a03 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'; @@ -123,6 +125,26 @@ 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('captures backlog ideas in inbox', async () => { const root = createTempRoot(); await runCli(['init'], { cwd: root, stdout: new MemoryWriter(), stderr: new MemoryWriter() }); From 3ea8e3429a43e92d84f5d6ec2d4a3377f98d4784 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 12 Apr 2026 17:28:46 -0700 Subject: [PATCH 4/5] feat: flat design docs with legend-prefixed cycle names Replace directory-per-cycle design docs (docs/design//.md) with flat files (docs/design/_.md). Cycle names are now legend-prefixed slugs (e.g. PROCESS_doctor-command) instead of numbered (0039-doctor-command), removing sequential numbering that doesn't scale for concurrent users. - Remove `number` field from CycleSchema - Design docs are flat files directly in docs/design/ - Retros keep directory layout for witness subdirs - Release-scoped design docs also flatten - Doctor detects legacy nested design doc dirs - Doctor repair flattens legacy dirs and updates frontmatter - Legacy NNNN-slug layout remains discoverable for backward compat - Delete extractReleasedCycleNumbers (number-based CHANGELOG detection) - Update process.md branch naming and path conventions BREAKING CHANGE: Cycle names no longer use sequential numeric prefixes. The `number` field has been removed from the Cycle type. Design docs are now flat files instead of directory-per-cycle. --- ARCHITECTURE.md | 4 +- README.md | 84 +- docs/CLI.md | 59 +- docs/GUIDE.md | 23 +- docs/MCP.md | 128 +- .../yaml-frontmatter-schema.md | 8 +- .../generated-reference-sync-coupling.md} | 55 +- docs/invariants/sponsor-abstractness.md | 15 +- .../PROCESS_i18n-string-extraction.md | 2 +- .../PROCESS_interactive-scaffolder.md | 2 +- .../PROCESS_multi-forge-adapter.md | 2 +- .../PROCESS_semantic-drift-detector.md | 2 +- .../PROCESS_backlog-authoring-command.md | 55 + .../v1.1.0/PROCESS_backlog-move-command.md | 55 + .../v1.1.0/PROCESS_doctor-repair-command.md | 59 + .../v1.1.0/PROCESS_feedback-command.md | 55 + .../PROCESS_retire-command.md | 2 +- .../PROCESS_signpost-helper-commands.md | 51 + .../PROCESS_structured-repair-hints.md | 2 +- .../PROCESS_task-dependency-dag.md | 8 + docs/method/feedback/README.md | 35 +- .../2026-04-11-missing-api-surfaces.md | 24 +- docs/method/process.md | 51 +- docs/method/release.md | 27 +- flyingrobots-method-1.0.0.tgz | Bin 0 -> 55790 bytes src/adapters/github.ts | 9 +- src/cli-args.ts | 832 +++++++- src/cli-renderer.ts | 192 +- src/cli.ts | 67 +- src/doctor.ts | 353 +++- src/domain.ts | 160 +- src/feedback-surface.ts | 85 + src/frontmatter.ts | 47 +- src/generate.ts | 197 +- src/index.ts | 1752 +++++++++++++++-- src/mcp.ts | 537 ++++- src/renderers.ts | 52 +- tests/api.test.ts | 782 +++++++- tests/cli.test.ts | 960 ++++++++- tests/docs.test.ts | 88 +- tests/doctor.test.ts | 180 +- tests/domain.test.ts | 14 +- tests/drift.test.ts | 8 +- tests/exec.test.ts | 6 +- tests/mcp.test.ts | 746 ++++++- tests/ship-sync.test.ts | 34 +- tests/witness.test.ts | 19 +- 47 files changed, 7413 insertions(+), 515 deletions(-) rename docs/{method/backlog/bad-code/PROCESS_generated-reference-sync-coupling.md => design/0039-generated-reference-sync-coupling/generated-reference-sync-coupling.md} (69%) rename docs/method/backlog/{inbox => cool-ideas}/PROCESS_i18n-string-extraction.md (94%) rename docs/method/backlog/{inbox => cool-ideas}/PROCESS_interactive-scaffolder.md (94%) rename docs/method/backlog/{inbox => cool-ideas}/PROCESS_multi-forge-adapter.md (93%) rename docs/method/backlog/{inbox => cool-ideas}/PROCESS_semantic-drift-detector.md (97%) create mode 100644 docs/method/backlog/v1.1.0/PROCESS_backlog-authoring-command.md create mode 100644 docs/method/backlog/v1.1.0/PROCESS_backlog-move-command.md create mode 100644 docs/method/backlog/v1.1.0/PROCESS_doctor-repair-command.md create mode 100644 docs/method/backlog/v1.1.0/PROCESS_feedback-command.md rename docs/method/backlog/{cool-ideas => v1.1.0}/PROCESS_retire-command.md (99%) create mode 100644 docs/method/backlog/v1.1.0/PROCESS_signpost-helper-commands.md rename docs/method/backlog/{cool-ideas => v1.1.0}/PROCESS_structured-repair-hints.md (99%) rename docs/method/backlog/{inbox => v1.1.0}/PROCESS_task-dependency-dag.md (88%) rename docs/method/feedback/{ => archive}/2026-04-11-missing-api-surfaces.md (88%) create mode 100644 flyingrobots-method-1.0.0.tgz create mode 100644 src/feedback-surface.ts 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/method/backlog/bad-code/PROCESS_generated-reference-sync-coupling.md b/docs/design/0039-generated-reference-sync-coupling/generated-reference-sync-coupling.md similarity index 69% rename from docs/method/backlog/bad-code/PROCESS_generated-reference-sync-coupling.md rename to docs/design/0039-generated-reference-sync-coupling/generated-reference-sync-coupling.md index 8ef1fb3..950644d 100644 --- a/docs/method/backlog/bad-code/PROCESS_generated-reference-sync-coupling.md +++ b/docs/design/0039-generated-reference-sync-coupling/generated-reference-sync-coupling.md @@ -1,18 +1,55 @@ --- 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." +legend: "PROCESS" +cycle: "0039-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 + +## Hill + +TBD + +## Playback Questions + +### Human + +- [ ] TBD + +### Agent + +- [ ] TBD + +## Accessibility and Assistive Reading + +- Linear truth / reduced-complexity posture: TBD +- Non-visual or alternate-reading expectations: TBD + +## Localization and Directionality + +- Locale / wording / formatting assumptions: TBD +- Logical direction / layout assumptions: TBD + +## Agent Inspectability and Explainability + +- What must be explicit and deterministic for agents: TBD +- What must be attributable, evidenced, or governed: TBD + +## Non-goals + +- [ ] TBD + +## 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 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/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/inbox/PROCESS_task-dependency-dag.md b/docs/method/backlog/v1.1.0/PROCESS_task-dependency-dag.md similarity index 88% rename from docs/method/backlog/inbox/PROCESS_task-dependency-dag.md rename to docs/method/backlog/v1.1.0/PROCESS_task-dependency-dag.md index cfa9659..66ea573 100644 --- a/docs/method/backlog/inbox/PROCESS_task-dependency-dag.md +++ b/docs/method/backlog/v1.1.0/PROCESS_task-dependency-dag.md @@ -1,3 +1,11 @@ +--- +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 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/2026-04-11-missing-api-surfaces.md b/docs/method/feedback/archive/2026-04-11-missing-api-surfaces.md similarity index 88% rename from docs/method/feedback/2026-04-11-missing-api-surfaces.md rename to docs/method/feedback/archive/2026-04-11-missing-api-surfaces.md index 6c650ee..cc07cd0 100644 --- a/docs/method/feedback/2026-04-11-missing-api-surfaces.md +++ b/docs/method/feedback/archive/2026-04-11-missing-api-surfaces.md @@ -2,7 +2,7 @@ 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: "unprocessed" +status: "archived" --- # Missing METHOD API surfaces found during repo work @@ -167,3 +167,25 @@ 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/flyingrobots-method-1.0.0.tgz b/flyingrobots-method-1.0.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..ab1b5746759f0d9dcb0175896c20871931a9c34b GIT binary patch literal 55790 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 T-{=39`|tk;v_(PA0PF(*+f4nV literal 0 HcmV?d00001 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 be67da6..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,13 +47,19 @@ 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(); @@ -67,10 +70,19 @@ export async function runCli( 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`); @@ -96,16 +108,8 @@ 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 === 'sync') { @@ -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..23cd326 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, [ diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 6ad6a03..40acd89 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -31,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(); @@ -62,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'); }); @@ -145,6 +149,97 @@ describe('method CLI', () => { } }); + 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() }); @@ -161,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() }); @@ -195,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'); @@ -215,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', ); @@ -230,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'); }); @@ -270,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'); }); @@ -280,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"', '---', '', @@ -302,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'); }); @@ -324,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 () => { @@ -467,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'], { @@ -484,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'], { @@ -503,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', @@ -542,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', @@ -573,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 @@ -584,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', @@ -627,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', @@ -662,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', @@ -740,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 () => { @@ -818,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', ); @@ -851,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', @@ -891,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', @@ -919,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..2a792d7 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,117 @@ 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('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..fde3fa5 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 only the generated reference targets and avoid ship-only artifacts?', 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..7c440e1 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('refreshes generated reference docs without mutating ship-only artifacts.', () => { + 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`'); }); }); From d168fb6cb0f027451174558733c877b0d1881044 Mon Sep 17 00:00:00 2001 From: James Ross <james@flyingrobots.dev> Date: Sun, 12 Apr 2026 18:13:04 -0700 Subject: [PATCH 5/5] docs: fill in design doc, close cycle PROCESS_generated-reference-sync-coupling - Flatten design doc from legacy nested dir to docs/design/PROCESS_*.md - Fill in hill, playback questions, and contract sections - Add 5 new tests matching playback questions (drift clean) - Close cycle with hill-met outcome and retro summary --- .../generated-reference-sync-coupling.md | 94 ------------------- ...OCESS_generated-reference-sync-coupling.md | 94 +++++++++++++++++++ ...OCESS_generated-reference-sync-coupling.md | 51 ++++++++++ .../witness/verification.md | 40 ++++++++ tests/api.test.ts | 51 ++++++++++ tests/doctor.test.ts | 39 ++++++++ tests/mcp.test.ts | 2 +- tests/ship-sync.test.ts | 2 +- 8 files changed, 277 insertions(+), 96 deletions(-) delete mode 100644 docs/design/0039-generated-reference-sync-coupling/generated-reference-sync-coupling.md create mode 100644 docs/design/PROCESS_generated-reference-sync-coupling.md create mode 100644 docs/method/retro/PROCESS_generated-reference-sync-coupling/PROCESS_generated-reference-sync-coupling.md create mode 100644 docs/method/retro/PROCESS_generated-reference-sync-coupling/witness/verification.md diff --git a/docs/design/0039-generated-reference-sync-coupling/generated-reference-sync-coupling.md b/docs/design/0039-generated-reference-sync-coupling/generated-reference-sync-coupling.md deleted file mode 100644 index 950644d..0000000 --- a/docs/design/0039-generated-reference-sync-coupling/generated-reference-sync-coupling.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: "Generated Reference Sync Coupling" -legend: "PROCESS" -cycle: "0039-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 - -## Hill - -TBD - -## Playback Questions - -### Human - -- [ ] TBD - -### Agent - -- [ ] TBD - -## Accessibility and Assistive Reading - -- Linear truth / reduced-complexity posture: TBD -- Non-visual or alternate-reading expectations: TBD - -## Localization and Directionality - -- Locale / wording / formatting assumptions: TBD -- Logical direction / layout assumptions: TBD - -## Agent Inspectability and Explainability - -- What must be explicit and deterministic for agents: TBD -- What must be attributable, evidenced, or governed: TBD - -## Non-goals - -- [ ] TBD - -## 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. - -## 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/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/<LEGEND>_<slug>.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/<NNNN>-<slug>/<slug>.md`) uses sequential numeric +prefixes that collide when multiple operators work concurrently. The +flat layout (`docs/design/<LEGEND>_<slug>.md`) with legend-prefixed +cycle names eliminates both the unnecessary nesting and the numbering +bottleneck. 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/tests/api.test.ts b/tests/api.test.ts index 23cd326..69aa695 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1121,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/doctor.test.ts b/tests/doctor.test.ts index 2a792d7..519b0ee 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -261,6 +261,45 @@ describe('doctor engine', () => { 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); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index fde3fa5..71a6337 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -595,7 +595,7 @@ describe('MCP Server', () => { expect(readFileSync(join(root, 'docs/method/release-runbook.md'), 'utf8')).toContain('# Release Runbook'); }); - it('Does `method_sync_refs` return only the generated reference targets and avoid ship-only artifacts?', async () => { + it('Does `method_sync_refs` return structured content with the refreshed targets?', async () => { const root = createTempRoot(); initWorkspace(root); const callToolHandler = createCallToolHarness(); diff --git a/tests/ship-sync.test.ts b/tests/ship-sync.test.ts index 7c440e1..107fbaa 100644 --- a/tests/ship-sync.test.ts +++ b/tests/ship-sync.test.ts @@ -142,7 +142,7 @@ describe('Ship Sync', () => { expect(result.updated).not.toContain('CHANGELOG.md'); }); - it('refreshes generated reference docs without mutating ship-only artifacts.', () => { + 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);