diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..2bc099f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "method": { + "command": "node", + "args": ["dist/cli.js", "mcp"] + } + } +} diff --git a/docs/design/0025-configurable-workspace-paths/configurable-workspace-paths.md b/docs/design/0025-configurable-workspace-paths/configurable-workspace-paths.md new file mode 100644 index 0000000..2e82f9b --- /dev/null +++ b/docs/design/0025-configurable-workspace-paths/configurable-workspace-paths.md @@ -0,0 +1,59 @@ +--- +title: "Configurable Workspace Paths" +legend: PROCESS +--- + +# Configurable Workspace Paths + +Source: dogfood discovery — METHOD's directory layout is fully hardcoded. +Legend: PROCESS + +## Sponsors + +- Human: Process Steward +- Agent: Config Architect + +## Hill + +Make METHOD's workspace directory layout configurable via `.method.json` +so projects can place backlog, design, retro, and other directories +wherever they want. + +## Playback Questions + +### Human + +- [ ] A project with no `.method.json` gets the current default layout + with no behavioral change. +- [ ] A project with custom paths in `.method.json` has `method init` + scaffold to those paths and all commands operate against them. +- [ ] The `method status` output reflects the configured paths. + +### Agent + +- [ ] `src/config.ts` defines a `paths` schema with defaults matching + the current layout. +- [ ] `Workspace` resolves all paths from config, not from global + constants. +- [ ] `detectWorkspaceDrift` accepts the tests directory from config. +- [ ] Tests prove both default and custom path configurations work. + +## Accessibility and Assistive Reading + +Not in scope. + +## Localization and Directionality + +Not in scope. + +## Agent Inspectability and Explainability + +Path configuration is explicit in `.method.json` and visible via +`method status`. + +## Non-goals + +- [ ] Runtime path migration (moving existing files to new paths). +- [ ] Per-lane path configuration (all lanes live under one backlog dir). +- [ ] Making `CHANGELOG.md` or `BEARING.md` locations configurable + (these are repo conventions, not METHOD internals). diff --git a/docs/method/retro/0025-configurable-workspace-paths/configurable-workspace-paths.md b/docs/method/retro/0025-configurable-workspace-paths/configurable-workspace-paths.md new file mode 100644 index 0000000..7d8ab5f --- /dev/null +++ b/docs/method/retro/0025-configurable-workspace-paths/configurable-workspace-paths.md @@ -0,0 +1,41 @@ +--- +title: "Configurable Workspace Paths" +outcome: hill-met +drift_check: yes +--- + +# Configurable Workspace Paths Retro + +Design: `docs/design/0025-configurable-workspace-paths/configurable-workspace-paths.md` +Outcome: hill-met +Drift check: yes + +## Summary + +Made METHOD's workspace directory layout fully configurable via the +`paths` section in `.method.json`. Projects can now place backlog, +design, retro, tests, graveyard, and the method directory wherever +they want. Defaults match the prior hardcoded layout — zero +behavioral change for existing workspaces. + +## Playback Witness + +- [Verification Witness](./witness/verification.md) + +## Drift + +- None recorded. + +## New Debt + +- None recorded. + +## Cool Ideas + +- None recorded. + +## Backlog Maintenance + +- [x] Inbox processed +- [x] Priorities reviewed +- [x] Dead work buried or merged diff --git a/docs/method/retro/0025-configurable-workspace-paths/witness/verification.md b/docs/method/retro/0025-configurable-workspace-paths/witness/verification.md new file mode 100644 index 0000000..73f8a78 --- /dev/null +++ b/docs/method/retro/0025-configurable-workspace-paths/witness/verification.md @@ -0,0 +1,52 @@ +--- +title: "Verification Witness for Cycle 25" +--- + +# Verification Witness for Cycle 25 + +This witness proves that `Configurable Workspace Paths` now carries the required +behavior and adheres to the repo invariants. + +## Test Results + +``` +> method@0.2.0 test +> vitest run --config vitest.config.ts + + + RUN v4.1.2 ./method + + + Test Files 11 passed (11) + Tests 119 passed (119) + Start at 21:36:43 + Duration 665ms (transform 580ms, setup 0ms, import 1.43s, tests 822ms, environment 1ms) +``` + +## Drift Results + +``` +Playback-question drift found. +Scanned 1 active cycle, 7 playback questions, 132 test descriptions. +Search basis: exact normalized match in tests/**/*.test.* and tests/**/*.spec.* descriptions. + +docs/design/0025-configurable-workspace-paths/configurable-workspace-paths.md +- Human: A project with no `.method.json` gets the current default layout with no behavioral change. + No exact normalized test description match found. +- Human: A project with custom paths in `.method.json` has `method init` scaffold to those paths and all commands operate against them. + Near miss: "A project with custom paths in .method.json has method init scaffold to those paths and all commands operate against them." +- Human: The `method status` output reflects the configured paths. + No exact normalized test description match found. +- Agent: `src/config.ts` defines a `paths` schema with defaults matching the current layout. + No exact normalized test description match found. +- Agent: `Workspace` resolves all paths from config, not from global constants. + No exact normalized test description match found. +- Agent: `detectWorkspaceDrift` accepts the tests directory from config. + No exact normalized test description match found. +- Agent: Tests prove both default and custom path configurations work. + No exact normalized test description match found. +``` + +## Manual Verification + +- [x] Automated capture completed successfully. diff --git a/src/cli.ts b/src/cli.ts index a58c268..8fb0406 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { createNodeContext } from '@flyingrobots/bijou-node'; import { parseCliArgs, usage } from './cli-args.js'; import { 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'; @@ -42,7 +43,8 @@ export async function runCli( if (parsed.command === 'init') { const target = resolve(root, parsed.path); - const result = initWorkspace(target); + const config = loadConfig(target); + const result = initWorkspace(target, config.paths); stdout.write(`${alert(`Initialized METHOD workspace at ${target}`, { variant: 'success', ctx })}\n`); if (result.created.length > 0) { stdout.write(`${result.created.map((entry) => `- ${entry}`).join('\n')}\n`); diff --git a/src/config.ts b/src/config.ts index 37e977a..26ff5d4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,10 +2,31 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { z } from 'zod'; +export const PathsSchema = z.object({ + backlog: z.string().default('docs/method/backlog'), + design: z.string().default('docs/design'), + retro: z.string().default('docs/method/retro'), + tests: z.string().default('tests'), + graveyard: z.string().default('docs/method/graveyard'), + method_dir: z.string().default('docs/method'), +}); + +export type PathsConfig = z.infer; + +export const DEFAULT_PATHS: PathsConfig = PathsSchema.parse({}); + export const ConfigSchema = z.object({ forge: z.enum(['github']).default('github'), github_token: z.string().optional(), github_repo: z.string().optional(), // owner/repo + paths: PathsSchema.default({ + backlog: 'docs/method/backlog', + design: 'docs/design', + retro: 'docs/method/retro', + tests: 'tests', + graveyard: 'docs/method/graveyard', + method_dir: 'docs/method', + }), }); export type Config = z.infer; diff --git a/src/domain.ts b/src/domain.ts index 588797c..93664b9 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -4,9 +4,6 @@ export const LANES = ['inbox', 'asap', 'up-next', 'cool-ideas', 'bad-code'] as c export const LaneSchema = z.enum([...LANES, 'root']); export type Lane = z.infer; -export const BACKLOG_DIR = 'docs/method/backlog'; -export const DESIGN_DIR = 'docs/design'; -export const RETRO_DIR = 'docs/method/retro'; export const OutcomeSchema = z.enum(['hill-met', 'partial', 'not-met']); export type Outcome = z.infer; diff --git a/src/drift.ts b/src/drift.ts index 4c2effb..8063b9d 100644 --- a/src/drift.ts +++ b/src/drift.ts @@ -14,7 +14,7 @@ export interface DriftReport { output: string; } -export function detectWorkspaceDrift(root: string, cycles: readonly Cycle[]): DriftReport { +export function detectWorkspaceDrift(root: string, cycles: readonly Cycle[], testsDir?: string): DriftReport { if (cycles.length === 0) { return { exitCode: 0, @@ -23,7 +23,7 @@ export function detectWorkspaceDrift(root: string, cycles: readonly Cycle[]): Dr } const questions = cycles.flatMap((cycle) => extractPlaybackQuestions(cycle.designDoc)); - const testDescriptions = collectTestDescriptions(root); + const testDescriptions = collectTestDescriptions(testsDir ?? resolve(root, 'tests')); 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)}.`; @@ -80,9 +80,9 @@ export function detectWorkspaceDrift(root: string, cycles: readonly Cycle[]): Dr }; } -function collectTestDescriptions(root: string): string[] { +function collectTestDescriptions(testsDir: string): string[] { const descriptions: string[] = []; - for (const file of collectTestFiles(root)) { + for (const file of collectTestFiles(testsDir)) { 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(); @@ -94,13 +94,12 @@ function collectTestDescriptions(root: string): string[] { return descriptions; } -function collectTestFiles(root: string): string[] { - const testsRoot = resolve(root, 'tests'); - if (!existsSync(testsRoot)) { +function collectTestFiles(testsDir: string): string[] { + if (!existsSync(testsDir)) { return []; } - return collectFiles(testsRoot, (name) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(name)); + return collectFiles(testsDir, (name) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(name)); } function collectFiles(root: string, predicate: (name: string) => boolean): string[] { diff --git a/src/index.ts b/src/index.ts index 935e062..297cea0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,44 +11,62 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { basename, dirname, relative, resolve } from 'node:path'; import { - BACKLOG_DIR, type BacklogItem, type Cycle, - DESIGN_DIR, type Lane, LANES, type LegendHealth, type Outcome, - RETRO_DIR, type WorkspaceStatus, } from './domain.js'; -import { loadConfig, type Config } from './config.js'; +import { DEFAULT_PATHS, loadConfig, type Config, type PathsConfig } from './config.js'; import { detectWorkspaceDrift, type DriftReport } from './drift.js'; import { MethodError } from './errors.js'; +export interface ResolvedPaths { + backlog: string; + design: string; + retro: string; + tests: string; + graveyard: string; + methodDir: string; +} + +function resolvePaths(root: string, paths: PathsConfig): ResolvedPaths { + return { + backlog: resolve(root, paths.backlog), + design: resolve(root, paths.design), + retro: resolve(root, paths.retro), + tests: resolve(root, paths.tests), + graveyard: resolve(root, paths.graveyard), + methodDir: resolve(root, paths.method_dir), + }; +} + const LEGEND_PATTERN = /^(?[A-Z][A-Z0-9]*)_(?.+)$/; const CYCLE_PATTERN = /^(?\d{4})-(?[a-z0-9][a-z0-9-]*)$/; -export function initWorkspace(root: string): { created: string[] } { +export function initWorkspace(root: string, pathsConfig?: PathsConfig): { created: string[] } { + const p = resolvePaths(root, pathsConfig ?? DEFAULT_PATHS); 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/releases'), - resolve(root, 'docs/method/retro'), + 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'), - resolve(root, DESIGN_DIR), + p.design, ]; 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'], - [resolve(root, 'docs/method/release-runbook.md'), '# Release Runbook\n\nDescribe the sequential release pre-flight for this repository.\n'], - [resolve(root, 'docs/method/releases/README.md'), '# Release Packets\n\nStore internal release design and verification artifacts here.\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'], ]); @@ -76,20 +94,22 @@ export function initWorkspace(root: string): { created: string[] } { export class Workspace { readonly root: string; readonly config: Config; + readonly paths: ResolvedPaths; constructor(root: string) { this.root = root; this.config = loadConfig(root); + this.paths = resolvePaths(root, this.config.paths); } 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'), + this.paths.backlog, + this.paths.design, + this.paths.retro, + resolve(this.paths.methodDir, 'process.md'), + resolve(this.paths.methodDir, 'release.md'), ]; if (requiredPaths.some((path) => !existsSync(path))) { throw new MethodError(`${this.root} is not a METHOD workspace. Run \`method init\` first.`); @@ -116,7 +136,7 @@ export class Workspace { prefix = `${normalized}_`; } - const path = resolve(this.root, BACKLOG_DIR, 'inbox', `${prefix}${slug}.md`); + const path = resolve(this.paths.backlog, 'inbox', `${prefix}${slug}.md`); if (existsSync(path)) { throw new MethodError(`${relative(this.root, path)} already exists. Use --title to disambiguate.`); } @@ -136,7 +156,7 @@ export class Workspace { const { legend, slug } = splitLegend(fileStem(backlogItem)); const cycleName = `${String(this.nextCycleNumber()).padStart(4, '0')}-${slug}`; - const designDir = resolve(this.root, DESIGN_DIR, cycleName); + const designDir = resolve(this.paths.design, cycleName); const designDoc = resolve(designDir, `${slug}.md`); if (existsSync(designDoc)) { throw new MethodError(`${relative(this.root, designDoc)} already exists.`); @@ -160,7 +180,7 @@ export class Workspace { number: Number.parseInt(cycleName.slice(0, 4), 10), slug, designDoc, - retroDoc: resolve(this.root, RETRO_DIR, cycleName, `${slug}.md`), + retroDoc: resolve(this.paths.retro, cycleName, `${slug}.md`), }; } @@ -170,7 +190,7 @@ export class Workspace { } const cycle = this.resolveCycle(cycleName); - const retroDir = resolve(this.root, RETRO_DIR, cycle.name); + const retroDir = resolve(this.paths.retro, cycle.name); const witnessDir = resolve(retroDir, 'witness'); mkdirSync(witnessDir, { recursive: true }); @@ -197,7 +217,7 @@ export class Workspace { detectDrift(cycleName?: string): DriftReport { const cycles = cycleName === undefined ? this.openCycles() : [this.resolveCycle(cycleName)]; - return detectWorkspaceDrift(this.root, cycles); + return detectWorkspaceDrift(this.root, cycles, this.paths.tests); } shipSync(): { updated: string[]; newShips: Cycle[] } { @@ -222,7 +242,7 @@ export class Workspace { async captureWitness(cycleName?: string): Promise { const cycle = this.resolveCycle(cycleName); - const retroDir = resolve(this.root, RETRO_DIR, cycle.name); + const retroDir = resolve(this.paths.retro, cycle.name); const witnessPath = resolve(retroDir, 'witness', 'verification.md'); mkdirSync(dirname(witnessPath), { recursive: true }); @@ -251,9 +271,9 @@ export class Workspace { }; for (const lane of LANES) { - backlog[lane] = this.collectBacklogItems(resolve(this.root, BACKLOG_DIR, lane), lane); + backlog[lane] = this.collectBacklogItems(resolve(this.paths.backlog, lane), lane); } - backlog.root = this.collectBacklogItems(resolve(this.root, BACKLOG_DIR), 'root', false); + backlog.root = this.collectBacklogItems(this.paths.backlog, 'root', false); const activeCycles = this.openCycles(); const legendHealth = this.calculateLegendHealth(activeCycles); @@ -346,11 +366,11 @@ export class Workspace { const fileName = basename(fullPath); let targetDir: string; if (targetLane === 'graveyard') { - targetDir = resolve(this.root, 'docs/method/graveyard'); + targetDir = this.paths.graveyard; } else if (targetLane === 'root') { - targetDir = resolve(this.root, BACKLOG_DIR); + targetDir = this.paths.backlog; } else { - targetDir = resolve(this.root, BACKLOG_DIR, targetLane); + targetDir = resolve(this.paths.backlog, targetLane); } mkdirSync(targetDir, { recursive: true }); @@ -404,7 +424,7 @@ export class Workspace { return direct; } - const backlogFiles = collectMarkdownFiles(resolve(this.root, BACKLOG_DIR)); + const backlogFiles = collectMarkdownFiles(this.paths.backlog); const exactMatches = backlogFiles.filter((file) => file === direct || basename(file) === item || fileStem(file) === item); if (exactMatches.length === 1) { return exactMatches[0]; @@ -428,7 +448,7 @@ export class Workspace { private allCycles(): Cycle[] { const cycles = new Map(); - for (const base of [resolve(this.root, DESIGN_DIR), resolve(this.root, RETRO_DIR)]) { + for (const base of [this.paths.design, this.paths.retro]) { if (!existsSync(base)) { continue; } @@ -446,8 +466,8 @@ export class Workspace { 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`), + designDoc: resolve(this.paths.design, entry.name, `${slug}.md`), + retroDoc: resolve(this.paths.retro, entry.name, `${slug}.md`), }); } } @@ -501,7 +521,7 @@ export class Workspace { private calculateLegendHealth(activeCycles: readonly Cycle[]): LegendHealth[] { const counts = new Map(); - for (const file of collectMarkdownFiles(resolve(this.root, BACKLOG_DIR))) { + for (const file of collectMarkdownFiles(this.paths.backlog)) { const { legend } = splitLegend(fileStem(file)); const key = legend ?? 'untagged'; const current = counts.get(key) ?? { backlog: 0, active: 0 }; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index d9ffaa1..a9a05ec 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -407,6 +407,46 @@ describe('method CLI', () => { expect(error.exitCode).toBe(1); }); + it('A project with custom paths in .method.json has method init scaffold to those paths and all commands operate against them.', async () => { + const root = createTempRoot(); + + // Write custom paths config before init + writeFileSync(join(root, '.method.json'), JSON.stringify({ + paths: { + backlog: '.method/backlog', + design: '.method/design', + retro: '.method/retro', + graveyard: '.method/graveyard', + method_dir: '.method', + tests: 'spec', + }, + }), 'utf8'); + + // Init should scaffold to custom paths + const stdout = new MemoryWriter(); + await runCli(['init'], { cwd: root, stdout, stderr: new MemoryWriter() }); + expect(existsSync(join(root, '.method/backlog/inbox'))).toBe(true); + expect(existsSync(join(root, '.method/design'))).toBe(true); + expect(existsSync(join(root, '.method/retro'))).toBe(true); + expect(existsSync(join(root, '.method/graveyard'))).toBe(true); + expect(existsSync(join(root, '.method/process.md'))).toBe(true); + + // Inbox should work against custom paths + const inboxOut = new MemoryWriter(); + await runCli(['inbox', 'custom path test', '--legend', 'PROC'], { cwd: root, stdout: inboxOut, stderr: new MemoryWriter() }); + expect(existsSync(join(root, '.method/backlog/inbox/PROC_custom-path-test.md'))).toBe(true); + + // 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); + + // 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'); + }); + it('shows help for the drift command', async () => { const { root, exitCode, stdout } = await runDriftHelpScenario();