diff --git a/docs/design/0007-cli-module-split/cli-module-split.md b/docs/design/0007-cli-module-split/cli-module-split.md new file mode 100644 index 0000000..2770bb0 --- /dev/null +++ b/docs/design/0007-cli-module-split/cli-module-split.md @@ -0,0 +1,141 @@ +# CLI Module Split + +Source backlog item: `docs/method/backlog/asap/PROCESS_cli-module-split.md` +Legend: PROCESS + +## Sponsors + +- Human: I can inspect the CLI and find command parsing, workspace + mutations, drift logic, and file-collection behavior in separate, + named modules instead of spelunking a 1,000-line monolith. +- Agent: I can change one CLI behavior with a narrow edit scope because + the code is split by owned behavior, not piled into a single entry + file or dissolved into vague utility soup. + +## Hill + +METHOD keeps the current CLI command surface and behavior, but +reorganizes the implementation so `src/cli.ts` becomes a thin entry +point and runtime-owned modules carry argument parsing/help, workspace +operations, and drift detection in separate homes while workspace-local +document scaffolding stays with the workspace module unless it becomes +independently meaningful. + +## Playback Questions + +### Human + +- [ ] Can I open the CLI code and identify where argument parsing ends + and workspace behavior begins without reading the whole command + stack top to bottom? +- [ ] If I need to review or change only drift behavior, can I find that + logic in one small module instead of tracing through unrelated + `init`, `pull`, `close`, and `status` code? + +### Agent + +- [ ] Does `src/cli.ts` stay a thin entry point that wires command + parsing and execution together rather than owning most of the + runtime behavior itself? +- [ ] Are the resulting modules named by owned behavior, with stable + tests proving that the CLI still preserves its existing contract? + +## Accessibility and Assistive Reading + +- Linear truth / reduced-complexity posture: the module layout should be + readable in a terminal or plain editor. File names should describe the + behavior they own so a reviewer can follow the CLI flow without IDE + indexing. +- Non-visual or alternate-reading expectations: this cycle should reduce + navigation burden for screen-reader users, terminal-only operators, + and agents by shortening files and making the seams more explicit in + committed paths and test names. + +## Localization and Directionality + +- Locale / wording / formatting assumptions: internal symbol and module + names may remain English. No localization work is part of this cycle. +- Logical direction / layout assumptions: the split should improve code + navigation through textual structure, not left/right editor panes or + IDE-specific affordances. + +## Agent Inspectability and Explainability + +- What must be explicit and deterministic for agents: the module + boundaries and moved responsibilities must be committed in the repo. + A reviewer should be able to point to a concrete file for CLI entry, + arg parsing/help text, workspace operations, drift behavior, and the + workspace-local scaffolding those behaviors still depend on. +- What must be attributable, evidenced, or governed: tests must prove + the CLI contract still holds after the split. This cycle should not + hide behavioral changes inside a refactor. If outputs or command + semantics change, that change needs an explicit test and design note. + +## Non-goals + +- [ ] Adding new CLI commands or changing the current command surface. +- [ ] Introducing a large public library API for the internal modules. +- [ ] Converting the repo away from TypeScript or rewriting the CLI in + plain JavaScript as part of this cycle. +- [ ] Sneaking in drift-feature expansion such as near-miss hints, + witness packaging, or broader test discovery. +- [ ] Splitting the file into generic `utils` buckets that weaken + ownership instead of clarifying it. + +## Decisions To Make + +- Which module boundaries are real enough to earn first-class files in + this cycle. + Final direction: split by owned behavior, not by syntactic category. + The first cut should likely separate: + - `src/cli.ts` as entry point plus exported `runCli` + - `src/cli-args.ts` for parse/help behavior + - `src/workspace.ts` for workspace mutation/query behavior + - `src/drift.ts` for playback-question extraction and drift matching + - `src/fs-helpers.ts` and/or `src/text-helpers.ts` only where a helper + is genuinely shared and still semantically coherent +- Whether `Workspace` should remain the primary runtime object. + Current bias: yes. Keep a real runtime-backed workspace type, but make + it smaller by moving drift-only logic out of the class first. + Workspace-local document scaffolding and markdown traversal may remain + in `src/workspace.ts` during this cycle if they stay internal to + workspace behavior. +- How far to take the split in one cycle. + Current bias: enough to remove the monolith risk now, not a full + architectural rewrite. + +## Backlog Context + +`src/cli.ts` is carrying too many concerns at once: command parsing, +workspace logic, file discovery, markdown extraction, normalization, +and the drift algorithm all live together. Split the CLI into smaller +runtime-owned modules so the code shape matches the repo's own doctrine +about traceability and single responsibility. + +Session context: + +- The `method drift` PR shipped a useful feature, but review surfaced a + real structural problem: the CLI entry point is becoming a monolith. +- Current `src/cli.ts` is 1,056 lines and mixes: + - top-level command execution + - arg parsing and usage text + - workspace mutation/query behavior + - drift extraction and matching + - recursive file collection + - markdown parsing and text normalization helpers +- The repo's newer system-style JavaScript doctrine pushes toward + runtime-owned modules with clear responsibilities. A successful split + should make the code more honest, not just more fragmented. + +What this surfaced: + +- `cli.ts` should become a thin entry point that preserves the exported + CLI contract. +- `Workspace` is still probably the right runtime object, but it should + stop owning drift-only logic and helper grab-bags. +- The split should be behavior-centered. Replacing one monolith with a + handful of ambiguous helper files would be a lateral move, not an + improvement. +- Existing CLI tests give this cycle a strong safety rail: the refactor + can stay honest by preserving those contracts while tightening file + boundaries. diff --git a/docs/method/backlog/asap/PROCESS_cli-module-split.md b/docs/method/backlog/asap/PROCESS_cli-module-split.md deleted file mode 100644 index 3cb46cf..0000000 --- a/docs/method/backlog/asap/PROCESS_cli-module-split.md +++ /dev/null @@ -1,23 +0,0 @@ -# CLI Module Split - -`src/cli.ts` is carrying too many concerns at once: command parsing, -workspace logic, file discovery, markdown extraction, normalization, -and the drift algorithm all live together. Split the CLI into smaller -runtime-owned modules so the code shape matches the repo's own doctrine -about traceability and single responsibility. - -Session context: - -- The `method drift` PR shipped a useful feature, but review surfaced a - real structural problem: the CLI entry point is becoming a monolith. -- This is not cosmetic refactoring. It affects reviewability, - testability, and the ability to trace a feature back to a cycle - design without spelunking a giant file. - -What this surfaced: - -- `cli.ts` should become a thin entry point. -- Workspace/state logic, drift behavior, filesystem discovery, and - normalization helpers want separate homes. -- Future cycles will get harder to ship honestly if all command logic - keeps accumulating in one file. diff --git a/docs/method/backlog/inbox/PROCESS_retro-conversational-closeout.md b/docs/method/backlog/cool-ideas/PROCESS_retro-conversational-closeout.md similarity index 100% rename from docs/method/backlog/inbox/PROCESS_retro-conversational-closeout.md rename to docs/method/backlog/cool-ideas/PROCESS_retro-conversational-closeout.md diff --git a/docs/method/backlog/cool-ideas/SYNTH_cycle-witness-command.md b/docs/method/backlog/cool-ideas/SYNTH_cycle-witness-command.md new file mode 100644 index 0000000..b72bbba --- /dev/null +++ b/docs/method/backlog/cool-ideas/SYNTH_cycle-witness-command.md @@ -0,0 +1,23 @@ +# Cycle Witness Command + +Explore a `method witness` command that can package the rerunnable +proof for a cycle close: commands run, artifacts produced, playback +answers, and file references. + +Session context: + +- External repos using METHOD are starting to want a cleaner way to + package what counted as the witness for a completed cycle. +- The need is broader than any one repo's local tooling. If METHOD + thinks witness packaging is a real pattern, it belongs here instead + of being rediscovered downstream. + +What this surfaced: + +- A witness command could reduce ad hoc closeout work while staying + faithful to METHOD's requirement that done-claims be reproducible. +- The command should package evidence, not invent it. It would point to + rerunnable proof rather than becoming a substitute for that proof. +- This still feels like a cool idea, not core doctrine, until METHOD + has clearer boundaries around artifact history, closeout flow, and + any future API surface. diff --git a/docs/method/backlog/inbox/PROCESS_release-shaping-and-user-migration-docs.md b/docs/method/backlog/inbox/PROCESS_release-shaping-and-user-migration-docs.md new file mode 100644 index 0000000..faf5d22 --- /dev/null +++ b/docs/method/backlog/inbox/PROCESS_release-shaping-and-user-migration-docs.md @@ -0,0 +1,44 @@ +# Release shaping and user migration docs + +METHOD needs a clearer way to shape releases, not just cycles. Alongside +`CHANGELOG.md`, the repo should have a structured, user-facing release +surface that explains what is new in a release, why it matters, and how +to migrate from previous versions when migration is required. + +Session context: + +- The current release doctrine is intentionally light: `release.md` + says releases happen when externally meaningful behavior changes and + points at `CHANGELOG.md` plus `README.md`. +- That is enough for honesty, but it is not enough for user-facing + release communication. Changelogs are ledger-like. Users often need a + more guided document: what changed, what to look at first, what might + break, and what to do next. +- The gap is especially visible for infrastructure repositories where a + release may change doctrine, CLI behavior, file layout, or expected + workflow. A terse changelog entry is not always the right surface for + helping a user adopt that release. + +Questions this should answer: + +- What makes a release “shaped” rather than just tagged and logged? +- What artifact should accompany a real release besides + `CHANGELOG.md`? +- Should METHOD define a user-facing release note format such as: + summary, what changed, why it matters, breaking changes, + migration/upgrade steps, and links to deeper docs? +- When should a release include explicit migration guidance versus “no + migration required”? +- Where should these artifacts live: `docs/releases/`, a generated + release page, versioned markdown files, or something else? +- How should release shaping relate to cycle closeout and post-merge + ship sync? + +What this surfaced: + +- METHOD currently knows how to close cycles honestly, but not yet how + to present releases coherently to users. +- `CHANGELOG.md` is necessary, but not always sufficient as the primary + user-facing release document. +- A structured release note / migration surface could become part of the + release method without turning every cycle into release theater. diff --git a/docs/method/retro/0007-cli-module-split/cli-module-split.md b/docs/method/retro/0007-cli-module-split/cli-module-split.md new file mode 100644 index 0000000..fed2660 --- /dev/null +++ b/docs/method/retro/0007-cli-module-split/cli-module-split.md @@ -0,0 +1,51 @@ +# CLI Module Split Retro + +Design: `docs/design/0007-cli-module-split/cli-module-split.md` +Outcome: hill-met +Drift check: yes + +## Summary + +This cycle split the CLI monolith into behavior-owned modules without +changing the command surface. `src/cli.ts` is now a thin 97-line entry +point that wires command execution together, while `src/cli-args.ts`, +`src/workspace.ts`, `src/drift.ts`, and `src/errors.ts` carry the +owned runtime behavior. This first cut moved argument parsing and +drift-specific behavior into dedicated modules while leaving +workspace-local document scaffolding inside `src/workspace.ts`. The +existing CLI contract stayed intact: +`npm test`, `npm run build`, and `npm run method -- status` all still +pass after the split. + +## Playback Witness + +- [Witness Index](./witness/README.md) +- [Playback Witness](./witness/playback.md) +- [Verification Witness](./witness/verification.md) + +## Drift + +- None recorded. + +## New Debt + +- None recorded. + +## Cool Ideas + +- None recorded. + +## Backlog Maintenance + +- [x] Inbox processed during cycle closeout +- [x] Priorities reviewed +- [x] Dead work buried or merged +- Moved `PROCESS_retro-conversational-closeout` from `inbox/` to + `cool-ideas/`. +- Left `SYNTH_generated-signpost-provenance` as the sole `asap/` item. +- Left `PROCESS_git-branch-workflow-policy`, + `PROCESS_library-api-surface`, and + `PROCESS_system-style-javascript-adoption` in `up-next/` for later + cycle planning. +- Branch tip now also carries the later backlog capture + `PROCESS_release-shaping-and-user-migration-docs` in `inbox/`. diff --git a/docs/method/retro/0007-cli-module-split/witness/README.md b/docs/method/retro/0007-cli-module-split/witness/README.md new file mode 100644 index 0000000..11da924 --- /dev/null +++ b/docs/method/retro/0007-cli-module-split/witness/README.md @@ -0,0 +1,24 @@ +# Witness Index + +This cycle claims a structural refactor, not a new feature. The proof +rests on committed module boundaries plus rerunnable commands that show +the CLI contract still behaves the same after the split. + +## Artifacts + +- [playback.md](./playback.md) + Human and agent playback answers for the CLI-module-split cycle. +- [verification.md](./verification.md) + Reproducible commands showing the new `src/` layout, the thinner + entry point, and the unchanged build/test/status truth surfaces. + +## Review Notes + +- The done-claim is carried by committed source files and rerunnable + commands, not by review prose. +- This witness proves the split happened and that the current CLI + contract still holds. It does not claim a new public API or new CLI + behavior. +- The refactor stays aligned with the repo's system-style JavaScript + direction by keeping a real `Workspace` runtime object instead of + flattening everything into utility functions. diff --git a/docs/method/retro/0007-cli-module-split/witness/playback.md b/docs/method/retro/0007-cli-module-split/witness/playback.md new file mode 100644 index 0000000..101ac76 --- /dev/null +++ b/docs/method/retro/0007-cli-module-split/witness/playback.md @@ -0,0 +1,64 @@ +# Playback Witness + +Date: 2026-04-03 + +This was a structural refactor cycle. The deliverable is a thinner CLI +entry point and clearer behavior-owned modules, with the existing +command surface preserved. + +## Human Playback + +### Can I open the CLI code and identify where argument parsing ends and workspace behavior begins without reading the whole command stack top to bottom? + +Yes. + +The boundaries are now visible from committed file names and grep-able +exports: + +- `src/cli.ts` wires execution together +- `src/cli-args.ts` owns parse/help behavior +- `src/workspace.ts` owns workspace mutations and status rendering +- `src/drift.ts` owns playback-question extraction and matching + +The human no longer has to scan a 1,000-line single file to answer +basic ownership questions. + +### If I need to review or change only drift behavior, can I find that logic in one small module instead of tracing through unrelated `init`, `pull`, `close`, and `status` code? + +Yes. + +`src/drift.ts` now holds the drift-specific behavior directly, and +`Workspace.detectDrift()` delegates to it. The refactor did not hide +drift behind generic helpers or mixed command code. + +## Agent Playback + +### Does `src/cli.ts` stay a thin entry point that wires command parsing and execution together rather than owning most of the runtime behavior itself? + +Yes. + +`src/cli.ts` is now 97 lines and contains: + +- the exported `runCli` +- the exported `main` +- the entry-point bootstrap + +It no longer defines `Workspace`, drift extraction, or generic file +collection behavior. + +### Are the resulting modules named by owned behavior, with stable tests proving that the CLI still preserves its existing contract? + +Yes. + +The module names describe owned behavior directly, and the existing CLI +tests still pass after the split. Two added structural tests also prove +that: + +- the behavior-owned files exist +- `src/cli.ts` no longer contains the old monolith seams + +## Outcome + +The hill is met. The CLI is structurally clearer, the entry point is +thin again, and the command behavior stayed stable under rerunnable +test/build/status verification. diff --git a/docs/method/retro/0007-cli-module-split/witness/verification.md b/docs/method/retro/0007-cli-module-split/witness/verification.md new file mode 100644 index 0000000..2bf36e7 --- /dev/null +++ b/docs/method/retro/0007-cli-module-split/witness/verification.md @@ -0,0 +1,92 @@ +# Verification Witness + +Date: 2026-04-03 + +## Commands + +```text +$ find src -maxdepth 1 -type f | sort +src/cli-args.ts +src/cli.ts +src/drift.ts +src/errors.ts +src/workspace.ts + +$ wc -l src/cli.ts src/cli-args.ts src/workspace.ts src/drift.ts src/errors.ts + 97 src/cli.ts + 208 src/cli-args.ts + 512 src/workspace.ts + 289 src/drift.ts + 6 src/errors.ts + 1112 total + +$ rg -n "export async function runCli|class Workspace|detectWorkspaceDrift|parseCliArgs|usage\\(|renderStatus\\(|detectDrift\\(" src/cli.ts src/cli-args.ts src/workspace.ts src/drift.ts +src/cli-args.ts:14:export function parseCliArgs(argv: readonly string[]): ParsedCommand { +src/cli-args.ts:52:export function usage(topic?: string): string { +src/workspace.ts:13:import { detectWorkspaceDrift, type DriftReport } from './drift.js'; +src/workspace.ts:70:export class Workspace { +src/workspace.ts:185: detectDrift(cycleName?: string): DriftReport { +src/workspace.ts:187: return detectWorkspaceDrift(this.root, cycles); +src/workspace.ts:190: renderStatus(ctx: ReturnType): string { +src/cli.ts:7:import { parseCliArgs, usage } from './cli-args.js'; +src/cli.ts:20:export async function runCli( +src/cli.ts:32: const parsed = parseCliArgs(argv); +src/cli.ts:34: stdout.write(`${usage(parsed.topic)}\n`); +src/cli.ts:75: const report = workspace.detectDrift(parsed.cycle); +src/cli.ts:80: stdout.write(workspace.renderStatus(ctx)); +src/drift.ts:17:export function detectWorkspaceDrift(root: string, cycles: readonly Cycle[]): DriftReport { + +$ npm test + +> method@0.1.0 test +> vitest run --config vitest.config.ts + + RUN v4.1.2 + + Test Files 2 passed (2) + Tests 37 passed (37) + +$ npm run build + +> method@0.1.0 build +> tsc -p tsconfig.json + +$ npm run method -- status + +> method@0.1.0 method +> tsx src/cli.ts status + +METHOD Status + +--- Backlog --- +inbox 1 PROCESS_release-shaping-and-user-migration-docs +asap 1 SYNTH_generated-signpost-provenance +up-next 5 PROCESS_behavior-spike-convention, PROCESS_git-branch-workflow-policy, PROCESS_library-api-surface, PROCESS_system-style-javascript-adoption, SYNTH_executive-summary-protocol +cool-ideas 6 PROCESS_drift-near-miss-hints, PROCESS_legend-audit-and-assignment, PROCESS_retro-conversational-closeout, PROCESS_review-config-hardening, SYNTH_artifact-history-and-semantic-provenance, SYNTH_cycle-witness-command +bad-code 0 - +root 0 - + +--- Active Cycles --- +- + +--- Legend Health --- +PROCESS backlog=9 active=0 +SYNTH backlog=4 active=0 +``` + +## Interpretation + +- The CLI no longer lives in a single source file; the committed `src/` + layout exposes the new boundaries directly. +- `src/cli.ts` is now a thin entry point, while `Workspace` and the + drift matcher live in their own runtime-owned modules. +- `Workspace` still owns internal document scaffolding and markdown + traversal in this first cut; the split only moved parsing and + drift-specific behavior into dedicated modules. +- The build, tests, and workspace status commands still pass after the + split, so the refactor preserved the existing CLI contract. +- Backlog maintenance was completed during closeout, including moving + `PROCESS_retro-conversational-closeout` out of `inbox/`. +- The branch tip also includes the later backlog capture + `PROCESS_release-shaping-and-user-migration-docs`, which is why the + current status output shows `inbox 1`. diff --git a/src/cli-args.ts b/src/cli-args.ts new file mode 100644 index 0000000..35d3b47 --- /dev/null +++ b/src/cli-args.ts @@ -0,0 +1,208 @@ +import { MethodError } from './errors.js'; + +export type Outcome = 'hill-met' | 'partial' | 'not-met'; + +export type ParsedCommand = + | { command: 'help'; topic?: string } + | { command: 'init'; path: string } + | { command: 'inbox'; idea: string; legend?: string; title?: string } + | { command: 'pull'; item: string } + | { command: 'close'; cycle?: string; driftCheck?: 'yes' | 'no'; outcome?: Outcome } + | { command: 'drift'; cycle?: string } + | { command: 'status' }; + +export function parseCliArgs(argv: readonly string[]): ParsedCommand { + const [command, ...rest] = argv; + if (command === undefined || command === '--help' || command === '-h') { + return { command: 'help' }; + } + + if (command === 'help') { + if (rest.length > 1) { + throw new MethodError('Usage: method help [command]'); + } + return { command: 'help', topic: rest[0] }; + } + + if (rest.includes('--help') || rest.includes('-h')) { + return { command: 'help', topic: command }; + } + + switch (command) { + case 'init': + return parseInitArgs(rest); + case 'inbox': + return parseInboxArgs(rest); + case 'pull': + return parsePullArgs(rest); + case 'close': + return parseCloseArgs(rest); + case 'drift': + return parseDriftArgs(rest); + case 'status': + if (rest.length > 0) { + throw new MethodError('`status` does not take any arguments.'); + } + return { command: 'status' }; + default: + throw new MethodError(`Unknown command: ${command}`); + } +} + +export function usage(topic?: string): string { + if (topic === 'init') { + return 'Usage: method init [path]\n\nScaffold a METHOD workspace in the given directory.'; + } + if (topic === 'inbox') { + return 'Usage: method inbox [--legend CODE] [--title TITLE]\n\nCapture a raw idea in docs/method/backlog/inbox/.'; + } + if (topic === 'pull') { + return 'Usage: method pull \n\nPromote a backlog item into the next numbered design cycle.'; + } + if (topic === 'close') { + return 'Usage: method close [cycle] [--drift-check yes|no] [--outcome hill-met|partial|not-met]\n\nClose an active cycle into docs/method/retro/.'; + } + if (topic === 'status') { + return 'Usage: method status\n\nShow backlog lanes, active cycles, and legend health.'; + } + if (topic === 'drift') { + return [ + 'Usage: method drift [cycle]', + '', + 'Check active cycle playback questions against test descriptions in tests/.', + 'First cut scans tests/**/*.test.* and tests/**/*.spec.* only.', + ].join('\n'); + } + + return [ + 'Usage: method [options]', + '', + 'Commands:', + ' init [path] Scaffold a METHOD workspace.', + ' inbox Capture a raw idea in inbox/.', + ' pull 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.', + ' status Show backlog, active cycles, and legend health.', + '', + 'Run `method help ` for command-specific usage.', + ].join('\n'); +} + +function parseInitArgs(args: readonly string[]): ParsedCommand { + if (args.length > 1) { + throw new MethodError('Usage: method init [path]'); + } + return { command: 'init', path: args[0] ?? '.' }; +} + +function parseInboxArgs(args: readonly string[]): ParsedCommand { + let legend: string | undefined; + let title: string | undefined; + const positionals: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === '--legend') { + legend = requireOptionValue(args, index, '--legend'); + index += 1; + continue; + } + if (value.startsWith('--legend=')) { + legend = value.slice('--legend='.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.startsWith('-')) { + throw new MethodError(`Unknown option: ${value}`); + } + positionals.push(value); + } + + if (positionals.length !== 1) { + throw new MethodError('Usage: method inbox [--legend CODE] [--title TITLE]'); + } + + return { command: 'inbox', idea: positionals[0], legend, title }; +} + +function parsePullArgs(args: readonly string[]): ParsedCommand { + if (args.length !== 1) { + throw new MethodError('Usage: method pull '); + } + return { command: 'pull', item: args[0] }; +} + +function parseCloseArgs(args: readonly string[]): ParsedCommand { + let cycle: string | undefined; + let driftCheck: 'yes' | 'no' | undefined; + let outcome: Outcome | undefined; + + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === '--drift-check') { + driftCheck = parseDriftCheckValue(requireOptionValue(args, index, '--drift-check')); + index += 1; + continue; + } + if (value.startsWith('--drift-check=')) { + driftCheck = parseDriftCheckValue(value.slice('--drift-check='.length)); + continue; + } + if (value === '--outcome') { + outcome = parseOutcomeValue(requireOptionValue(args, index, '--outcome')); + index += 1; + continue; + } + if (value.startsWith('--outcome=')) { + outcome = parseOutcomeValue(value.slice('--outcome='.length)); + continue; + } + if (value.startsWith('-')) { + throw new MethodError(`Unknown option: ${value}`); + } + if (cycle !== undefined) { + throw new MethodError('Usage: method close [cycle] [--drift-check yes|no] [--outcome hill-met|partial|not-met]'); + } + cycle = value; + } + + return { command: 'close', cycle, driftCheck, outcome }; +} + +function parseDriftArgs(args: readonly string[]): ParsedCommand { + if (args.length > 1) { + throw new MethodError('Usage: method drift [cycle]'); + } + return { command: 'drift', cycle: args[0] }; +} + +function requireOptionValue(args: readonly string[], index: number, optionName: string): string { + const value = args[index + 1]; + if (value === undefined || value.startsWith('-')) { + throw new MethodError(`${optionName} requires a value.`); + } + return value; +} + +function parseDriftCheckValue(value: string): 'yes' | 'no' { + if (value === 'yes' || value === 'no') { + return value; + } + throw new MethodError('`--drift-check` must be `yes` or `no`.'); +} + +function parseOutcomeValue(value: string): Outcome { + if (value === 'hill-met' || value === 'partial' || value === 'not-met') { + return value; + } + throw new MethodError('`--outcome` must be `hill-met`, `partial`, or `not-met`.'); +} diff --git a/src/cli.ts b/src/cli.ts index 89f25ef..440bc4a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,38 +1,13 @@ #!/usr/bin/env node -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - unlinkSync, - writeFileSync, -} from 'node:fs'; import { relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { alert, confirm, headerBox, separator } from '@flyingrobots/bijou'; +import { alert, confirm } from '@flyingrobots/bijou'; import { createNodeContext } from '@flyingrobots/bijou-node'; - -const LANES = ['inbox', 'asap', 'up-next', 'cool-ideas', 'bad-code'] as const; -const BACKLOG_DIR = 'docs/method/backlog'; -const DESIGN_DIR = 'docs/design'; -const RETRO_DIR = 'docs/method/retro'; -const LEGEND_PATTERN = /^(?[A-Z][A-Z0-9]*)_(?.+)$/; -const CYCLE_PATTERN = /^(?\d{4})-(?[a-z0-9][a-z0-9-]*)$/; +import { parseCliArgs, usage } from './cli-args.js'; +import { initWorkspace, Workspace } from './workspace.js'; type Writer = Pick; - -type ParsedCommand = - | { command: 'help'; topic?: string } - | { command: 'init'; path: string } - | { command: 'inbox'; idea: string; legend?: string; title?: string } - | { command: 'pull'; item: string } - | { command: 'close'; cycle?: string; driftCheck?: 'yes' | 'no'; outcome?: Outcome } - | { command: 'drift'; cycle?: string } - | { command: 'status' }; - -type Outcome = 'hill-met' | 'partial' | 'not-met'; - type ConfirmPrompt = (options: { title: string; defaultValue: boolean }) => Promise; export interface RunCliOptions { @@ -42,28 +17,6 @@ export interface RunCliOptions { confirmPrompt?: ConfirmPrompt; } -interface Cycle { - name: string; - number: number; - slug: string; - designDoc: string; - retroDoc: string; -} - -interface PlaybackQuestion { - designDoc: string; - sponsor: 'Human' | 'Agent'; - text: string; - normalized: string; -} - -interface DriftReport { - exitCode: 0 | 2; - output: string; -} - -class MethodError extends Error {} - export async function runCli( argv: readonly string[], options: RunCliOptions = {}, @@ -137,918 +90,6 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro return runCli(argv); } -function parseCliArgs(argv: readonly string[]): ParsedCommand { - const [command, ...rest] = argv; - if (command === undefined || command === '--help' || command === '-h') { - return { command: 'help' }; - } - - if (command === 'help') { - if (rest.length > 1) { - throw new MethodError('Usage: method help [command]'); - } - return { command: 'help', topic: rest[0] }; - } - - if (rest.includes('--help') || rest.includes('-h')) { - return { command: 'help', topic: command }; - } - - switch (command) { - case 'init': - return parseInitArgs(rest); - case 'inbox': - return parseInboxArgs(rest); - case 'pull': - return parsePullArgs(rest); - case 'close': - return parseCloseArgs(rest); - case 'drift': - return parseDriftArgs(rest); - case 'status': - if (rest.length > 0) { - throw new MethodError('`status` does not take any arguments.'); - } - return { command: 'status' }; - default: - throw new MethodError(`Unknown command: ${command}`); - } -} - -function parseInitArgs(args: readonly string[]): ParsedCommand { - if (args.length > 1) { - throw new MethodError('Usage: method init [path]'); - } - return { command: 'init', path: args[0] ?? '.' }; -} - -function parseInboxArgs(args: readonly string[]): ParsedCommand { - let legend: string | undefined; - let title: string | undefined; - const positionals: string[] = []; - - for (let index = 0; index < args.length; index += 1) { - const value = args[index]; - if (value === '--legend') { - legend = requireOptionValue(args, index, '--legend'); - index += 1; - continue; - } - if (value.startsWith('--legend=')) { - legend = value.slice('--legend='.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.startsWith('-')) { - throw new MethodError(`Unknown option: ${value}`); - } - positionals.push(value); - } - - if (positionals.length !== 1) { - throw new MethodError('Usage: method inbox [--legend CODE] [--title TITLE]'); - } - - return { command: 'inbox', idea: positionals[0], legend, title }; -} - -function parsePullArgs(args: readonly string[]): ParsedCommand { - if (args.length !== 1) { - throw new MethodError('Usage: method pull '); - } - return { command: 'pull', item: args[0] }; -} - -function parseCloseArgs(args: readonly string[]): ParsedCommand { - let cycle: string | undefined; - let driftCheck: 'yes' | 'no' | undefined; - let outcome: Outcome | undefined; - - for (let index = 0; index < args.length; index += 1) { - const value = args[index]; - if (value === '--drift-check') { - driftCheck = parseDriftCheckValue(requireOptionValue(args, index, '--drift-check')); - index += 1; - continue; - } - if (value.startsWith('--drift-check=')) { - driftCheck = parseDriftCheckValue(value.slice('--drift-check='.length)); - continue; - } - if (value === '--outcome') { - outcome = parseOutcomeValue(requireOptionValue(args, index, '--outcome')); - index += 1; - continue; - } - if (value.startsWith('--outcome=')) { - outcome = parseOutcomeValue(value.slice('--outcome='.length)); - continue; - } - if (value.startsWith('-')) { - throw new MethodError(`Unknown option: ${value}`); - } - if (cycle !== undefined) { - throw new MethodError('Usage: method close [cycle] [--drift-check yes|no] [--outcome hill-met|partial|not-met]'); - } - cycle = value; - } - - return { command: 'close', cycle, driftCheck, outcome }; -} - -function requireOptionValue(args: readonly string[], index: number, optionName: string): string { - const value = args[index + 1]; - if (value === undefined || value.startsWith('-')) { - throw new MethodError(`${optionName} requires a value.`); - } - return value; -} - -function parseDriftArgs(args: readonly string[]): ParsedCommand { - if (args.length > 1) { - throw new MethodError('Usage: method drift [cycle]'); - } - return { command: 'drift', cycle: args[0] }; -} - -function parseDriftCheckValue(value: string): 'yes' | 'no' { - if (value === 'yes' || value === 'no') return value; - throw new MethodError('`--drift-check` must be `yes` or `no`.'); -} - -function parseOutcomeValue(value: string): Outcome { - if (value === 'hill-met' || value === 'partial' || value === 'not-met') return value; - throw new MethodError('`--outcome` must be `hill-met`, `partial`, or `not-met`.'); -} - -function usage(topic?: string): string { - if (topic === 'init') { - return 'Usage: method init [path]\n\nScaffold a METHOD workspace in the given directory.'; - } - if (topic === 'inbox') { - return 'Usage: method inbox [--legend CODE] [--title TITLE]\n\nCapture a raw idea in docs/method/backlog/inbox/.'; - } - if (topic === 'pull') { - return 'Usage: method pull \n\nPromote a backlog item into the next numbered design cycle.'; - } - if (topic === 'close') { - return 'Usage: method close [cycle] [--drift-check yes|no] [--outcome hill-met|partial|not-met]\n\nClose an active cycle into docs/method/retro/.'; - } - if (topic === 'status') { - return 'Usage: method status\n\nShow backlog lanes, active cycles, and legend health.'; - } - if (topic === 'drift') { - return [ - 'Usage: method drift [cycle]', - '', - 'Check active cycle playback questions against test descriptions in tests/.', - 'First cut scans tests/**/*.test.* and tests/**/*.spec.* only.', - ].join('\n'); - } - return [ - 'Usage: method [options]', - '', - 'Commands:', - ' init [path] Scaffold a METHOD workspace.', - ' inbox Capture a raw idea in inbox/.', - ' pull 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.', - ' status Show backlog, active cycles, and legend health.', - '', - 'Run `method help ` for command-specific usage.', - ].join('\n'); -} - -function initWorkspace(root: string): { created: string[] } { - const directories = [ - resolve(root, BACKLOG_DIR, 'inbox'), - resolve(root, BACKLOG_DIR, 'asap'), - resolve(root, BACKLOG_DIR, 'up-next'), - resolve(root, BACKLOG_DIR, 'cool-ideas'), - resolve(root, BACKLOG_DIR, 'bad-code'), - resolve(root, 'docs/method/legends'), - resolve(root, 'docs/method/graveyard'), - resolve(root, 'docs/method/retro'), - resolve(root, DESIGN_DIR), - ]; - const files = new Map([ - [resolve(root, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n- No externally meaningful changes recorded yet.\n'], - [resolve(root, 'docs/method/process.md'), '# Process\n\nDescribe how cycles run in this repository.\n'], - [resolve(root, 'docs/method/release.md'), '# Release\n\nDescribe when and how externally meaningful releases ship.\n'], - ]); - - const created: string[] = []; - for (const directory of directories) { - if (!existsSync(directory)) { - mkdirSync(directory, { recursive: true }); - created.push(relative(root, directory) || '.'); - } else { - mkdirSync(directory, { recursive: true }); - } - } - - for (const [file, content] of files) { - if (!existsSync(file)) { - mkdirSync(resolve(file, '..'), { recursive: true }); - writeFileSync(file, content, 'utf8'); - created.push(relative(root, file)); - } - } - - return { created }; -} - -class Workspace { - readonly root: string; - - constructor(root: string) { - this.root = root; - } - - ensureInitialized(): void { - const requiredPaths = [ - resolve(this.root, 'CHANGELOG.md'), - resolve(this.root, BACKLOG_DIR), - resolve(this.root, DESIGN_DIR), - resolve(this.root, RETRO_DIR), - resolve(this.root, 'docs/method/process.md'), - resolve(this.root, 'docs/method/release.md'), - ]; - if (requiredPaths.some((path) => !existsSync(path))) { - throw new MethodError(`${this.root} is not a METHOD workspace. Run \`method init\` first.`); - } - } - - captureIdea(idea: string, legend?: string, title?: string): string { - const cleanIdea = idea.trim(); - if (cleanIdea.length === 0) { - throw new MethodError('Idea cannot be empty.'); - } - - const slug = slugify(title ?? cleanIdea); - if (slug.length === 0) { - throw new MethodError('Could not derive a filename from the provided idea.'); - } - - let prefix = ''; - if (legend !== undefined) { - const normalized = legend.trim().toUpperCase(); - if (!/^[A-Z][A-Z0-9]*$/.test(normalized)) { - throw new MethodError('Legend codes must be uppercase letters and numbers.'); - } - prefix = `${normalized}_`; - } - - const path = resolve(this.root, BACKLOG_DIR, 'inbox', `${prefix}${slug}.md`); - if (existsSync(path)) { - throw new MethodError(`${relative(this.root, path)} already exists. Use --title to disambiguate.`); - } - - mkdirSync(resolve(path, '..'), { recursive: true }); - const heading = (title ?? cleanIdea).trim(); - writeFileSync(path, `# ${heading}\n\n${cleanIdea}\n`, 'utf8'); - return path; - } - - pullItem(item: string): Cycle { - const backlogItem = this.resolveBacklogItem(item); - const title = readHeading(backlogItem); - if (title.length === 0) { - throw new MethodError(`${relative(this.root, backlogItem)} is missing a heading.`); - } - - const { legend, slug } = splitLegend(fileStem(backlogItem)); - const cycleName = `${String(this.nextCycleNumber()).padStart(4, '0')}-${slug}`; - const designDir = resolve(this.root, DESIGN_DIR, cycleName); - const designDoc = resolve(designDir, `${slug}.md`); - if (existsSync(designDoc)) { - throw new MethodError(`${relative(this.root, designDoc)} already exists.`); - } - - mkdirSync(designDir, { recursive: false }); - writeFileSync( - designDoc, - renderDesignDoc({ - title, - legend, - source: relative(this.root, backlogItem), - backlogBody: readBody(backlogItem), - }), - 'utf8', - ); - unlinkSync(backlogItem); - - return { - name: cycleName, - number: Number.parseInt(cycleName.slice(0, 4), 10), - slug, - designDoc, - retroDoc: resolve(this.root, RETRO_DIR, cycleName, `${slug}.md`), - }; - } - - closeCycle(cycleName: string | undefined, completedDriftCheck: boolean, outcome?: Outcome): Cycle { - if (!completedDriftCheck) { - throw new MethodError('Cannot close a cycle without completing the drift check.'); - } - - const cycle = this.resolveCycle(cycleName); - const retroDir = resolve(this.root, RETRO_DIR, cycle.name); - const witnessDir = resolve(retroDir, 'witness'); - mkdirSync(witnessDir, { recursive: true }); - if (existsSync(cycle.retroDoc)) { - throw new MethodError(`${relative(this.root, cycle.retroDoc)} already exists.`); - } - - writeFileSync( - cycle.retroDoc, - renderRetroDoc({ - cycle, - root: this.root, - outcome, - witnessDir: relative(this.root, witnessDir), - }), - 'utf8', - ); - return cycle; - } - - detectDrift(cycleName?: string): DriftReport { - const cycles = cycleName === undefined ? this.openCycles() : [this.resolveCycle(cycleName)]; - if (cycles.length === 0) { - return { - exitCode: 0, - output: 'No active cycles found.\n', - }; - } - - const questions = cycles.flatMap((cycle) => extractPlaybackQuestions(cycle.designDoc)); - const testDescriptions = collectTestDescriptions(this.root); - const unmatched = questions.filter((question) => - !testDescriptions.some((description) => normalizeForMatch(description) === question.normalized)); - const summaryLine = `Scanned ${cycles.length} active cycle${plural(cycles.length)}, ${questions.length} playback question${plural(questions.length)}, ${testDescriptions.length} test description${plural(testDescriptions.length)}.`; - const searchBasis = 'Search basis: exact normalized match in tests/**/*.test.* and tests/**/*.spec.* descriptions.'; - - if (unmatched.length === 0) { - return { - exitCode: 0, - output: [ - 'No playback-question drift found.', - summaryLine, - searchBasis, - '', - ].join('\n'), - }; - } - - const grouped = new Map(); - for (const question of unmatched) { - const current = grouped.get(question.designDoc) ?? []; - current.push(question); - grouped.set(question.designDoc, current); - } - - const findingLines: string[] = []; - for (const designDoc of [...grouped.keys()].sort((left, right) => left.localeCompare(right))) { - findingLines.push(relative(this.root, designDoc)); - for (const question of grouped.get(designDoc) ?? []) { - findingLines.push(`- ${question.sponsor}: ${question.text}`); - findingLines.push(' No exact normalized test description match found.'); - } - findingLines.push(''); - } - - return { - exitCode: 2, - output: [ - 'Playback-question drift found.', - summaryLine, - searchBasis, - '', - ...findingLines, - ].join('\n'), - }; - } - - renderStatus(ctx: ReturnType): string { - const backlogLines = [ - ...LANES.map((lane) => { - const items = collectMarkdownFiles(resolve(this.root, BACKLOG_DIR, lane)); - return `${lane.padEnd(10, ' ')} ${String(items.length).padStart(2, ' ')} ${items.map((item) => fileStem(item)).join(', ') || '-'}`; - }), - (() => { - const rootItems = collectMarkdownFiles(resolve(this.root, BACKLOG_DIR)) - .filter((file) => resolve(file, '..') === resolve(this.root, BACKLOG_DIR)); - return `${'root'.padEnd(10, ' ')} ${String(rootItems.length).padStart(2, ' ')} ${rootItems.map((item) => fileStem(item)).join(', ') || '-'}`; - })(), - ]; - - const activeCycles = this.openCycles(); - const cycleLines = activeCycles.length > 0 - ? activeCycles.map((cycle) => `${cycle.name.padEnd(18, ' ')} ${readHeading(cycle.designDoc) || cycle.slug}`) - : ['-']; - - const legendCounts = this.legendHealth(activeCycles); - const legendLines = legendCounts.size > 0 - ? [...legendCounts.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([legend, counts]) => `${legend.padEnd(8, ' ')} backlog=${counts.backlog} active=${counts.active}`) - : ['-']; - - return [ - `${headerBox('METHOD Status', { detail: this.root, ctx })}`, - '', - `${separator({ label: 'Backlog', ctx })}`, - ...backlogLines, - '', - `${separator({ label: 'Active Cycles', ctx })}`, - ...cycleLines, - '', - `${separator({ label: 'Legend Health', ctx })}`, - ...legendLines, - '', - ].join('\n'); - } - - resolveBacklogItem(item: string): string { - const direct = resolve(this.root, item); - if (existsSync(direct)) { - return direct; - } - - const backlogFiles = collectMarkdownFiles(resolve(this.root, BACKLOG_DIR)); - const exactMatches = backlogFiles.filter((file) => file === direct || basename(file) === item || fileStem(file) === item); - if (exactMatches.length === 1) return exactMatches[0]; - if (exactMatches.length > 1) { - throw new MethodError(`Ambiguous backlog item ${JSON.stringify(item)}: ${exactMatches.map((file) => relative(this.root, file)).join(', ')}`); - } - - const lowerItem = item.toLowerCase(); - const fuzzyMatches = backlogFiles.filter((file) => fileStem(file).toLowerCase() === lowerItem); - if (fuzzyMatches.length === 1) return fuzzyMatches[0]; - throw new MethodError(`Could not find backlog item ${JSON.stringify(item)}.`); - } - - nextCycleNumber(): number { - const numbers = this.allCycles().map((cycle) => cycle.number); - return numbers.length === 0 ? 1 : Math.max(...numbers) + 1; - } - - allCycles(): Cycle[] { - const cycles = new Map(); - for (const base of [resolve(this.root, DESIGN_DIR), resolve(this.root, RETRO_DIR)]) { - if (!existsSync(base)) continue; - for (const entry of readdirSync(base, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const match = CYCLE_PATTERN.exec(entry.name); - if (match?.groups === undefined) 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.root, DESIGN_DIR, entry.name, `${slug}.md`), - retroDoc: resolve(this.root, RETRO_DIR, entry.name, `${slug}.md`), - }); - } - } - return [...cycles.values()].sort((left, right) => left.number - right.number); - } - - openCycles(): Cycle[] { - return this.allCycles().filter((cycle) => existsSync(cycle.designDoc) && !existsSync(cycle.retroDoc)); - } - - resolveCycle(cycleName?: string): Cycle { - const activeCycles = this.openCycles(); - if (activeCycles.length === 0) { - throw new MethodError('No active cycles found.'); - } - - if (cycleName === undefined) { - if (activeCycles.length > 1) { - throw new MethodError(`Multiple active cycles found: ${activeCycles.map((cycle) => cycle.name).join(', ')}`); - } - return activeCycles[0]; - } - - const matches = activeCycles.filter((cycle) => - cycle.name === cycleName || cycle.slug === cycleName || cycle.name.endsWith(cycleName)); - if (matches.length === 1) return matches[0]; - if (matches.length > 1) { - throw new MethodError(`Ambiguous cycle ${JSON.stringify(cycleName)}: ${matches.map((cycle) => cycle.name).join(', ')}`); - } - throw new MethodError(`Could not find active cycle ${JSON.stringify(cycleName)}.`); - } - - legendHealth(activeCycles: readonly Cycle[]): Map { - const counts = new Map(); - for (const file of collectMarkdownFiles(resolve(this.root, BACKLOG_DIR))) { - const { legend } = splitLegend(fileStem(file)); - const key = legend ?? 'untagged'; - const current = counts.get(key) ?? { backlog: 0, active: 0 }; - current.backlog += 1; - counts.set(key, current); - } - - for (const cycle of activeCycles) { - const key = readDesignLegend(cycle.designDoc) ?? 'untagged'; - const current = counts.get(key) ?? { backlog: 0, active: 0 }; - current.active += 1; - counts.set(key, current); - } - - return counts; - } -} - -function collectMarkdownFiles(root: string): string[] { - if (!existsSync(root)) return []; - const files: string[] = []; - for (const entry of readdirSync(root, { withFileTypes: true })) { - const path = resolve(root, entry.name); - if (entry.isDirectory()) { - files.push(...collectMarkdownFiles(path)); - } else if (entry.isFile() && entry.name.endsWith('.md')) { - files.push(path); - } - } - return files.sort((left, right) => left.localeCompare(right)); -} - -function collectTestFiles(root: string): string[] { - const testsRoot = resolve(root, 'tests'); - if (!existsSync(testsRoot)) return []; - - return collectFiles(testsRoot, (name) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(name)); -} - -function collectFiles(root: string, predicate: (name: string) => boolean): string[] { - if (!existsSync(root)) return []; - const files: string[] = []; - for (const entry of readdirSync(root, { withFileTypes: true })) { - const path = resolve(root, entry.name); - if (entry.isDirectory()) { - files.push(...collectFiles(path, predicate)); - } else if (entry.isFile() && predicate(entry.name)) { - files.push(path); - } - } - return files.sort((left, right) => left.localeCompare(right)); -} - -function renderDesignDoc(options: { - title: string; - legend?: string; - source: string; - backlogBody: string; -}): string { - const legendValue = options.legend ?? 'none'; - return [ - `# ${options.title}`, - '', - `Source backlog item: \`${options.source}\``, - `Legend: ${legendValue}`, - '', - '## Sponsors', - '', - '- Human: TBD', - '- Agent: TBD', - '', - '## 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', - '', - options.backlogBody, - '', - ].join('\n'); -} - -function renderRetroDoc(options: { - cycle: Cycle; - root: string; - outcome?: Outcome; - witnessDir: string; -}): string { - const title = readHeading(options.cycle.designDoc) || titleCase(options.cycle.slug); - return [ - `# ${title} Retro`, - '', - `Design: \`${relative(options.root, options.cycle.designDoc)}\``, - `Outcome: ${options.outcome ?? 'TBD'}`, - 'Drift check: yes', - '', - '## Summary', - '', - 'TBD', - '', - '## Playback Witness', - '', - `Add artifacts under \`${options.witnessDir}\` and link them here.`, - '', - '## Drift', - '', - '- None recorded.', - '', - '## New Debt', - '', - '- None recorded.', - '', - '## Cool Ideas', - '', - '- None recorded.', - '', - '## Backlog Maintenance', - '', - '- [ ] Inbox processed', - '- [ ] Priorities reviewed', - '- [ ] Dead work buried or merged', - '', - ].join('\n'); -} - -function splitLegend(stem: string): { legend?: string; slug: string } { - const match = LEGEND_PATTERN.exec(stem); - if (match?.groups === undefined) return { slug: stem }; - return { legend: match.groups.legend, slug: match.groups.slug }; -} - -function readHeading(path: string): string { - for (const line of readFileSync(path, 'utf8').split(/\r?\n/u)) { - if (line.startsWith('# ')) { - return line.slice(2).trim(); - } - } - return ''; -} - -function readBody(path: string): string { - const lines = readFileSync(path, 'utf8').split(/\r?\n/u); - const bodyLines = lines[0]?.startsWith('# ') ? lines.slice(1) : lines; - const body = bodyLines.join('\n').trim(); - return body.length > 0 ? body : 'TBD'; -} - -function readDesignLegend(path: string): string | undefined { - for (const line of readFileSync(path, 'utf8').split(/\r?\n/u)) { - if (!line.startsWith('Legend: ')) continue; - const value = line.slice('Legend: '.length).trim(); - return value === 'none' ? undefined : value; - } - return undefined; -} - -function collectTestDescriptions(root: string): string[] { - const descriptions: string[] = []; - for (const file of collectTestFiles(root)) { - const contents = stripComments(readFileSync(file, 'utf8')); - for (const match of contents.matchAll(/\b(?:it|test)\s*\(\s*(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/gu)) { - const description = decodeTestStringLiteral(match[2] ?? '', match[1] ?? '').trim(); - if (description !== undefined && description.length > 0) { - descriptions.push(description); - } - } - } - return descriptions; -} - -function extractPlaybackQuestions(path: string): PlaybackQuestion[] { - const lines = readFileSync(path, 'utf8').split(/\r?\n/u); - const questions: PlaybackQuestion[] = []; - let inPlaybackSection = false; - let sponsor: 'Human' | 'Agent' | undefined; - let pendingLines: string[] = []; - - const flushPending = (): void => { - if (sponsor === undefined || pendingLines.length === 0) return; - const text = pendingLines.join(' ').replace(/\s+/gu, ' ').trim(); - if (text.length === 0) return; - questions.push({ - designDoc: path, - sponsor, - text, - normalized: normalizeForMatch(text), - }); - pendingLines = []; - }; - - for (const line of [...lines, '## End']) { - if (!inPlaybackSection) { - if (line.trim() === '## Playback Questions') { - inPlaybackSection = true; - } - continue; - } - - if (/^## /u.test(line)) { - flushPending(); - break; - } - - const sponsorMatch = /^### (Human|Agent)\s*$/u.exec(line.trim()); - if (sponsorMatch !== null) { - flushPending(); - sponsor = sponsorMatch[1] as 'Human' | 'Agent'; - continue; - } - - const bulletMatch = /^- \[ \] (.+)$/u.exec(line.trim()); - if (bulletMatch !== null) { - flushPending(); - pendingLines = [bulletMatch[1].trim()]; - continue; - } - - if (pendingLines.length > 0 && line.trim().length > 0) { - pendingLines.push(line.trim()); - } - } - - return questions; -} - -function normalizeForMatch(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/\s+/gu, ' '); -} - -function plural(count: number): string { - return count === 1 ? '' : 's'; -} - -function decodeTestStringLiteral(value: string, quoteChar: string): string { - return value.replace(/\\([\\'"`nrt])/gu, (_match, escaped: string) => { - if (escaped === 'n') return '\n'; - if (escaped === 'r') return '\r'; - if (escaped === 't') return '\t'; - if (escaped === quoteChar) return quoteChar; - return escaped; - }); -} - -function stripComments(value: string): string { - let result = ''; - let index = 0; - let state: 'code' | 'single-quote' | 'double-quote' | 'template' | 'line-comment' | 'block-comment' = 'code'; - let escaped = false; - - while (index < value.length) { - const current = value[index]; - const next = value[index + 1]; - - if (state === 'line-comment') { - if (current === '\n' || current === '\r') { - result += current; - state = 'code'; - } else { - result += ' '; - } - index += 1; - continue; - } - - if (state === 'block-comment') { - if (current === '*' && next === '/') { - result += ' '; - index += 2; - state = 'code'; - continue; - } - - result += current === '\n' || current === '\r' ? current : ' '; - index += 1; - continue; - } - - if (state === 'single-quote' || state === 'double-quote' || state === 'template') { - result += current; - - if (escaped) { - escaped = false; - } else if (current === '\\') { - escaped = true; - } else if ( - (state === 'single-quote' && current === '\'') - || (state === 'double-quote' && current === '"') - || (state === 'template' && current === '`') - ) { - state = 'code'; - } - - index += 1; - continue; - } - - if (current === '/' && next === '/') { - result += ' '; - index += 2; - state = 'line-comment'; - continue; - } - - if (current === '/' && next === '*') { - result += ' '; - index += 2; - state = 'block-comment'; - continue; - } - - if (current === '\'') { - state = 'single-quote'; - result += current; - index += 1; - continue; - } - - if (current === '"') { - state = 'double-quote'; - result += current; - index += 1; - continue; - } - - if (current === '`') { - state = 'template'; - result += current; - index += 1; - continue; - } - - result += current; - index += 1; - } - - return result; -} - -function slugify(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/gu, '-') - .replace(/^-+|-+$/gu, ''); -} - -function titleCase(value: string): string { - return value - .split('-') - .filter((part) => part.length > 0) - .map((part) => `${part[0].toUpperCase()}${part.slice(1)}`) - .join(' '); -} - -function basename(path: string): string { - return path.split('/').at(-1) ?? path; -} - -function fileStem(path: string): string { - const name = basename(path); - return name.endsWith('.md') ? name.slice(0, -3) : name; -} - if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { main().then((code) => { process.exitCode = code; diff --git a/src/drift.ts b/src/drift.ts new file mode 100644 index 0000000..ef8573d --- /dev/null +++ b/src/drift.ts @@ -0,0 +1,289 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import type { Cycle } from './workspace.js'; + +interface PlaybackQuestion { + designDoc: string; + sponsor: 'Human' | 'Agent'; + text: string; + normalized: string; +} + +export interface DriftReport { + exitCode: 0 | 2; + output: string; +} + +export function detectWorkspaceDrift(root: string, cycles: readonly Cycle[]): DriftReport { + if (cycles.length === 0) { + return { + exitCode: 0, + output: 'No active cycles found.\n', + }; + } + + const questions = cycles.flatMap((cycle) => extractPlaybackQuestions(cycle.designDoc)); + const testDescriptions = collectTestDescriptions(root); + const unmatched = questions.filter((question) => + !testDescriptions.some((description) => normalizeForMatch(description) === question.normalized)); + const summaryLine = `Scanned ${cycles.length} active cycle${plural(cycles.length)}, ${questions.length} playback question${plural(questions.length)}, ${testDescriptions.length} test description${plural(testDescriptions.length)}.`; + const searchBasis = 'Search basis: exact normalized match in tests/**/*.test.* and tests/**/*.spec.* descriptions.'; + + if (unmatched.length === 0) { + return { + exitCode: 0, + output: [ + 'No playback-question drift found.', + summaryLine, + searchBasis, + '', + ].join('\n'), + }; + } + + const grouped = new Map(); + for (const question of unmatched) { + const current = grouped.get(question.designDoc) ?? []; + current.push(question); + grouped.set(question.designDoc, current); + } + + const findingLines: string[] = []; + for (const designDoc of [...grouped.keys()].sort((left, right) => left.localeCompare(right))) { + findingLines.push(relative(root, designDoc)); + for (const question of grouped.get(designDoc) ?? []) { + findingLines.push(`- ${question.sponsor}: ${question.text}`); + findingLines.push(' No exact normalized test description match found.'); + } + findingLines.push(''); + } + + return { + exitCode: 2, + output: [ + 'Playback-question drift found.', + summaryLine, + searchBasis, + '', + ...findingLines, + ].join('\n'), + }; +} + +function collectTestDescriptions(root: string): string[] { + const descriptions: string[] = []; + for (const file of collectTestFiles(root)) { + const contents = stripComments(readFileSync(file, 'utf8')); + for (const match of contents.matchAll(/\b(?:it|test)\s*\(\s*(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/gu)) { + const description = decodeTestStringLiteral(match[2] ?? '', match[1] ?? '').trim(); + if (description.length > 0) { + descriptions.push(description); + } + } + } + return descriptions; +} + +function collectTestFiles(root: string): string[] { + const testsRoot = resolve(root, 'tests'); + if (!existsSync(testsRoot)) { + return []; + } + + return collectFiles(testsRoot, (name) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(name)); +} + +function collectFiles(root: string, predicate: (name: string) => boolean): string[] { + if (!existsSync(root)) { + return []; + } + + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = resolve(root, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(path, predicate)); + } else if (entry.isFile() && predicate(entry.name)) { + files.push(path); + } + } + + return files.sort((left, right) => left.localeCompare(right)); +} + +function extractPlaybackQuestions(path: string): PlaybackQuestion[] { + const lines = readFileSync(path, 'utf8').split(/\r?\n/u); + const questions: PlaybackQuestion[] = []; + let inPlaybackSection = false; + let sponsor: 'Human' | 'Agent' | undefined; + let pendingLines: string[] = []; + + const flushPending = (): void => { + if (sponsor === undefined || pendingLines.length === 0) { + return; + } + + const text = pendingLines.join(' ').replace(/\s+/gu, ' ').trim(); + if (text.length === 0) { + return; + } + + questions.push({ + designDoc: path, + sponsor, + text, + normalized: normalizeForMatch(text), + }); + pendingLines = []; + }; + + for (const line of [...lines, '## End']) { + if (!inPlaybackSection) { + if (line.trim() === '## Playback Questions') { + inPlaybackSection = true; + } + continue; + } + + if (/^## /u.test(line)) { + flushPending(); + break; + } + + const sponsorMatch = /^### (Human|Agent)\s*$/u.exec(line.trim()); + if (sponsorMatch !== null) { + flushPending(); + sponsor = sponsorMatch[1] as 'Human' | 'Agent'; + continue; + } + + const bulletMatch = /^- \[ \] (.+)$/u.exec(line.trim()); + if (bulletMatch !== null) { + flushPending(); + pendingLines = [bulletMatch[1].trim()]; + continue; + } + + if (pendingLines.length > 0 && line.trim().length > 0) { + pendingLines.push(line.trim()); + } + } + + return questions; +} + +function normalizeForMatch(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/\s+/gu, ' '); +} + +function plural(count: number): string { + return count === 1 ? '' : 's'; +} + +function decodeTestStringLiteral(value: string, quoteChar: string): string { + return value.replace(/\\([\\'"`nrt])/gu, (_match, escaped: string) => { + if (escaped === 'n') return '\n'; + if (escaped === 'r') return '\r'; + if (escaped === 't') return '\t'; + if (escaped === quoteChar) return quoteChar; + return escaped; + }); +} + +function stripComments(value: string): string { + let result = ''; + let index = 0; + let state: 'code' | 'single-quote' | 'double-quote' | 'template' | 'line-comment' | 'block-comment' = 'code'; + let escaped = false; + + while (index < value.length) { + const current = value[index]; + const next = value[index + 1]; + + if (state === 'line-comment') { + if (current === '\n' || current === '\r') { + result += current; + state = 'code'; + } else { + result += ' '; + } + index += 1; + continue; + } + + if (state === 'block-comment') { + if (current === '*' && next === '/') { + result += ' '; + index += 2; + state = 'code'; + continue; + } + + result += current === '\n' || current === '\r' ? current : ' '; + index += 1; + continue; + } + + if (state === 'single-quote' || state === 'double-quote' || state === 'template') { + result += current; + + if (escaped) { + escaped = false; + } else if (current === '\\') { + escaped = true; + } else if ( + (state === 'single-quote' && current === '\'') + || (state === 'double-quote' && current === '"') + || (state === 'template' && current === '`') + ) { + state = 'code'; + } + + index += 1; + continue; + } + + if (current === '/' && next === '/') { + result += ' '; + index += 2; + state = 'line-comment'; + continue; + } + + if (current === '/' && next === '*') { + result += ' '; + index += 2; + state = 'block-comment'; + continue; + } + + if (current === '\'') { + state = 'single-quote'; + result += current; + index += 1; + continue; + } + + if (current === '"') { + state = 'double-quote'; + result += current; + index += 1; + continue; + } + + if (current === '`') { + state = 'template'; + result += current; + index += 1; + continue; + } + + result += current; + index += 1; + } + + return result; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..8b070f1 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,6 @@ +export class MethodError extends Error { + constructor(message: string) { + super(message); + this.name = 'MethodError'; + } +} diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..8c73168 --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,512 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { headerBox, separator } from '@flyingrobots/bijou'; +import { createNodeContext } from '@flyingrobots/bijou-node'; +import type { Outcome } from './cli-args.js'; +import { detectWorkspaceDrift, type DriftReport } from './drift.js'; +import { MethodError } from './errors.js'; + +const LANES = ['inbox', 'asap', 'up-next', 'cool-ideas', 'bad-code'] as const; +const BACKLOG_DIR = 'docs/method/backlog'; +const DESIGN_DIR = 'docs/design'; +const RETRO_DIR = 'docs/method/retro'; +const LEGEND_PATTERN = /^(?[A-Z][A-Z0-9]*)_(?.+)$/; +const CYCLE_PATTERN = /^(?\d{4})-(?[a-z0-9][a-z0-9-]*)$/; + +export interface Cycle { + name: string; + number: number; + slug: string; + designDoc: string; + retroDoc: string; +} + +export function initWorkspace(root: string): { created: string[] } { + const directories = [ + resolve(root, BACKLOG_DIR, 'inbox'), + resolve(root, BACKLOG_DIR, 'asap'), + resolve(root, BACKLOG_DIR, 'up-next'), + resolve(root, BACKLOG_DIR, 'cool-ideas'), + resolve(root, BACKLOG_DIR, 'bad-code'), + resolve(root, 'docs/method/legends'), + resolve(root, 'docs/method/graveyard'), + resolve(root, 'docs/method/retro'), + resolve(root, DESIGN_DIR), + ]; + const files = new Map([ + [resolve(root, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n- No externally meaningful changes recorded yet.\n'], + [resolve(root, 'docs/method/process.md'), '# Process\n\nDescribe how cycles run in this repository.\n'], + [resolve(root, 'docs/method/release.md'), '# Release\n\nDescribe when and how externally meaningful releases ship.\n'], + ]); + + const created: string[] = []; + for (const directory of directories) { + if (!existsSync(directory)) { + mkdirSync(directory, { recursive: true }); + created.push(relative(root, directory) || '.'); + } else { + mkdirSync(directory, { recursive: true }); + } + } + + for (const [file, content] of files) { + if (!existsSync(file)) { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content, 'utf8'); + created.push(relative(root, file)); + } + } + + return { created }; +} + +export class Workspace { + readonly root: string; + + constructor(root: string) { + this.root = root; + } + + ensureInitialized(): void { + const requiredPaths = [ + resolve(this.root, 'CHANGELOG.md'), + resolve(this.root, BACKLOG_DIR), + resolve(this.root, DESIGN_DIR), + resolve(this.root, RETRO_DIR), + resolve(this.root, 'docs/method/process.md'), + resolve(this.root, 'docs/method/release.md'), + ]; + if (requiredPaths.some((path) => !existsSync(path))) { + throw new MethodError(`${this.root} is not a METHOD workspace. Run \`method init\` first.`); + } + } + + captureIdea(idea: string, legend?: string, title?: string): string { + const cleanIdea = idea.trim(); + if (cleanIdea.length === 0) { + throw new MethodError('Idea cannot be empty.'); + } + + const slug = slugify(title ?? cleanIdea); + if (slug.length === 0) { + throw new MethodError('Could not derive a filename from the provided idea.'); + } + + let prefix = ''; + if (legend !== undefined) { + const normalized = legend.trim().toUpperCase(); + if (!/^[A-Z][A-Z0-9]*$/u.test(normalized)) { + throw new MethodError('Legend codes must be uppercase letters and numbers.'); + } + prefix = `${normalized}_`; + } + + const path = resolve(this.root, BACKLOG_DIR, 'inbox', `${prefix}${slug}.md`); + if (existsSync(path)) { + throw new MethodError(`${relative(this.root, path)} already exists. Use --title to disambiguate.`); + } + + mkdirSync(dirname(path), { recursive: true }); + const heading = (title ?? cleanIdea).trim(); + writeFileSync(path, `# ${heading}\n\n${cleanIdea}\n`, 'utf8'); + return path; + } + + pullItem(item: string): Cycle { + const backlogItem = this.resolveBacklogItem(item); + const title = readHeading(backlogItem); + if (title.length === 0) { + throw new MethodError(`${relative(this.root, backlogItem)} is missing a heading.`); + } + + const { legend, slug } = splitLegend(fileStem(backlogItem)); + const cycleName = `${String(this.nextCycleNumber()).padStart(4, '0')}-${slug}`; + const designDir = resolve(this.root, DESIGN_DIR, cycleName); + const designDoc = resolve(designDir, `${slug}.md`); + if (existsSync(designDoc)) { + throw new MethodError(`${relative(this.root, designDoc)} already exists.`); + } + + mkdirSync(designDir, { recursive: false }); + writeFileSync( + designDoc, + renderDesignDoc({ + title, + legend, + source: relative(this.root, backlogItem), + backlogBody: readBody(backlogItem), + }), + 'utf8', + ); + unlinkSync(backlogItem); + + return { + name: cycleName, + number: Number.parseInt(cycleName.slice(0, 4), 10), + slug, + designDoc, + retroDoc: resolve(this.root, RETRO_DIR, cycleName, `${slug}.md`), + }; + } + + closeCycle(cycleName: string | undefined, completedDriftCheck: boolean, outcome?: Outcome): Cycle { + if (!completedDriftCheck) { + throw new MethodError('Cannot close a cycle without completing the drift check.'); + } + + const cycle = this.resolveCycle(cycleName); + const retroDir = resolve(this.root, RETRO_DIR, cycle.name); + const witnessDir = resolve(retroDir, 'witness'); + mkdirSync(witnessDir, { recursive: true }); + if (existsSync(cycle.retroDoc)) { + throw new MethodError(`${relative(this.root, cycle.retroDoc)} already exists.`); + } + + writeFileSync( + cycle.retroDoc, + renderRetroDoc({ + cycle, + root: this.root, + outcome, + witnessDir: relative(this.root, witnessDir), + }), + 'utf8', + ); + return cycle; + } + + detectDrift(cycleName?: string): DriftReport { + const cycles = cycleName === undefined ? this.openCycles() : [this.resolveCycle(cycleName)]; + return detectWorkspaceDrift(this.root, cycles); + } + + renderStatus(ctx: ReturnType): string { + const backlogLines = [ + ...LANES.map((lane) => { + const items = collectMarkdownFiles(resolve(this.root, BACKLOG_DIR, lane)); + return `${lane.padEnd(10, ' ')} ${String(items.length).padStart(2, ' ')} ${items.map((item) => fileStem(item)).join(', ') || '-'}`; + }), + (() => { + const rootItems = collectMarkdownFiles(resolve(this.root, BACKLOG_DIR)) + .filter((file) => dirname(file) === resolve(this.root, BACKLOG_DIR)); + return `${'root'.padEnd(10, ' ')} ${String(rootItems.length).padStart(2, ' ')} ${rootItems.map((item) => fileStem(item)).join(', ') || '-'}`; + })(), + ]; + + const activeCycles = this.openCycles(); + const cycleLines = activeCycles.length > 0 + ? activeCycles.map((cycle) => `${cycle.name.padEnd(18, ' ')} ${readHeading(cycle.designDoc) || cycle.slug}`) + : ['-']; + + const legendCounts = this.legendHealth(activeCycles); + const legendLines = legendCounts.size > 0 + ? [...legendCounts.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([legend, counts]) => `${legend.padEnd(8, ' ')} backlog=${counts.backlog} active=${counts.active}`) + : ['-']; + + return [ + `${headerBox('METHOD Status', { detail: this.root, ctx })}`, + '', + `${separator({ label: 'Backlog', ctx })}`, + ...backlogLines, + '', + `${separator({ label: 'Active Cycles', ctx })}`, + ...cycleLines, + '', + `${separator({ label: 'Legend Health', ctx })}`, + ...legendLines, + '', + ].join('\n'); + } + + openCycles(): Cycle[] { + return this.allCycles().filter((cycle) => existsSync(cycle.designDoc) && !existsSync(cycle.retroDoc)); + } + + private resolveBacklogItem(item: string): string { + const direct = resolve(this.root, item); + if (existsSync(direct)) { + return direct; + } + + const backlogFiles = collectMarkdownFiles(resolve(this.root, BACKLOG_DIR)); + const exactMatches = backlogFiles.filter((file) => file === direct || basename(file) === item || fileStem(file) === item); + if (exactMatches.length === 1) { + return exactMatches[0]; + } + if (exactMatches.length > 1) { + throw new MethodError(`Ambiguous backlog item ${JSON.stringify(item)}: ${exactMatches.map((file) => relative(this.root, file)).join(', ')}`); + } + + const lowerItem = item.toLowerCase(); + const fuzzyMatches = backlogFiles.filter((file) => fileStem(file).toLowerCase() === 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(); + for (const base of [resolve(this.root, DESIGN_DIR), resolve(this.root, RETRO_DIR)]) { + if (!existsSync(base)) { + continue; + } + for (const entry of readdirSync(base, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const match = CYCLE_PATTERN.exec(entry.name); + if (match?.groups === undefined) { + 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.root, DESIGN_DIR, entry.name, `${slug}.md`), + retroDoc: resolve(this.root, RETRO_DIR, entry.name, `${slug}.md`), + }); + } + } + return [...cycles.values()].sort((left, right) => left.number - right.number); + } + + private resolveCycle(cycleName?: string): Cycle { + const activeCycles = this.openCycles(); + if (activeCycles.length === 0) { + throw new MethodError('No active cycles found.'); + } + + if (cycleName === undefined) { + if (activeCycles.length > 1) { + throw new MethodError(`Multiple active cycles found: ${activeCycles.map((cycle) => cycle.name).join(', ')}`); + } + return activeCycles[0]; + } + + const matches = activeCycles.filter((cycle) => + cycle.name === cycleName || cycle.slug === cycleName || cycle.name.endsWith(cycleName)); + if (matches.length === 1) { + return matches[0]; + } + if (matches.length > 1) { + throw new MethodError(`Ambiguous cycle ${JSON.stringify(cycleName)}: ${matches.map((cycle) => cycle.name).join(', ')}`); + } + throw new MethodError(`Could not find active cycle ${JSON.stringify(cycleName)}.`); + } + + private legendHealth(activeCycles: readonly Cycle[]): Map { + const counts = new Map(); + for (const file of collectMarkdownFiles(resolve(this.root, BACKLOG_DIR))) { + const { legend } = splitLegend(fileStem(file)); + const key = legend ?? 'untagged'; + const current = counts.get(key) ?? { backlog: 0, active: 0 }; + current.backlog += 1; + counts.set(key, current); + } + + for (const cycle of activeCycles) { + const key = readDesignLegend(cycle.designDoc) ?? 'untagged'; + const current = counts.get(key) ?? { backlog: 0, active: 0 }; + current.active += 1; + counts.set(key, current); + } + + return counts; + } +} + +function collectMarkdownFiles(root: string): string[] { + if (!existsSync(root)) { + return []; + } + + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = resolve(root, entry.name); + if (entry.isDirectory()) { + files.push(...collectMarkdownFiles(path)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(path); + } + } + + return files.sort((left, right) => left.localeCompare(right)); +} + +function renderDesignDoc(options: { + title: string; + legend?: string; + source: string; + backlogBody: string; +}): string { + const legendValue = options.legend ?? 'none'; + return [ + `# ${options.title}`, + '', + `Source backlog item: \`${options.source}\``, + `Legend: ${legendValue}`, + '', + '## Sponsors', + '', + '- Human: TBD', + '- Agent: TBD', + '', + '## 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', + '', + options.backlogBody, + '', + ].join('\n'); +} + +function renderRetroDoc(options: { + cycle: Cycle; + root: string; + outcome?: Outcome; + witnessDir: string; +}): string { + const title = readHeading(options.cycle.designDoc) || titleCase(options.cycle.slug); + return [ + `# ${title} Retro`, + '', + `Design: \`${relative(options.root, options.cycle.designDoc)}\``, + `Outcome: ${options.outcome ?? 'TBD'}`, + 'Drift check: yes', + '', + '## Summary', + '', + 'TBD', + '', + '## Playback Witness', + '', + `Add artifacts under \`${options.witnessDir}\` and link them here.`, + '', + '## Drift', + '', + '- None recorded.', + '', + '## New Debt', + '', + '- None recorded.', + '', + '## Cool Ideas', + '', + '- None recorded.', + '', + '## Backlog Maintenance', + '', + '- [ ] Inbox processed', + '- [ ] Priorities reviewed', + '- [ ] Dead work buried or merged', + '', + ].join('\n'); +} + +function splitLegend(stem: string): { legend?: string; slug: string } { + const match = LEGEND_PATTERN.exec(stem); + if (match?.groups === undefined) { + return { slug: stem }; + } + return { legend: match.groups.legend, slug: match.groups.slug }; +} + +function readHeading(path: string): string { + for (const line of readFileSync(path, 'utf8').split(/\r?\n/u)) { + if (line.startsWith('# ')) { + return line.slice(2).trim(); + } + } + return ''; +} + +function readBody(path: string): string { + const lines = readFileSync(path, 'utf8').split(/\r?\n/u); + const bodyLines = lines[0]?.startsWith('# ') ? lines.slice(1) : lines; + const body = bodyLines.join('\n').trim(); + return body.length > 0 ? body : 'TBD'; +} + +function readDesignLegend(path: string): string | undefined { + for (const line of readFileSync(path, 'utf8').split(/\r?\n/u)) { + if (!line.startsWith('Legend: ')) { + continue; + } + const value = line.slice('Legend: '.length).trim(); + return value === 'none' ? undefined : value; + } + return undefined; +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gu, '-') + .replace(/^-+|-+$/gu, ''); +} + +function titleCase(value: string): string { + return value + .split('-') + .filter((part) => part.length > 0) + .map((part) => `${part[0].toUpperCase()}${part.slice(1)}`) + .join(' '); +} + +function basename(path: string): string { + return path.split('/').at(-1) ?? path; +} + +function fileStem(path: string): string { + const name = basename(path); + return name.endsWith('.md') ? name.slice(0, -3) : name; +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 9b232f4..66c5fc6 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -437,6 +437,30 @@ describe('method CLI', () => { expect(exitCode).toBe(1); expect(stderr.output).toContain('Could not find active cycle'); }); + + it('keeps behavior-owned modules for arg parsing, workspace behavior, and drift logic', () => { + expect(existsSync(new URL('../src/cli-args.ts', import.meta.url))).toBe(true); + expect(existsSync(new URL('../src/workspace.ts', import.meta.url))).toBe(true); + expect(existsSync(new URL('../src/drift.ts', import.meta.url))).toBe(true); + }); + + it('keeps cli.ts as a thin entry point instead of a behavior monolith', () => { + const cliSource = readFileSync(new URL('../src/cli.ts', import.meta.url), 'utf8'); + + expect(cliSource).not.toContain('class Workspace'); + expect(cliSource).not.toContain('function collectTestFiles'); + expect(cliSource).not.toContain('function extractPlaybackQuestions'); + expect(cliSource).not.toContain('function normalizeForMatch'); + expect(cliSource.split(/\r?\n/u).length).toBeLessThan(260); + }); + + it('keeps workspace.ts focused on workspace behavior instead of reabsorbing drift helpers', () => { + const workspaceSource = readFileSync(new URL('../src/workspace.ts', import.meta.url), 'utf8'); + + expect(workspaceSource).not.toContain('function extractPlaybackQuestions'); + expect(workspaceSource).not.toContain('function collectTestFiles'); + expect(workspaceSource).not.toContain('function normalizeForMatch'); + }); }); function expectFile(root: string, relativePath: string): void {