Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"method": {
"command": "node",
"args": ["dist/cli.js", "mcp"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a runnable MCP entrypoint by default

The new MCP config points to dist/cli.js, but this repository does not include a built dist/ directory by default, so MCP clients that load .mcp.json in a fresh clone fail immediately with Cannot find module '/workspace/method/dist/cli.js'. That makes the shipped MCP integration unusable unless users discover and run a separate build step first; the config should target a runnable source entrypoint or explicitly enforce/build before launch.

Useful? React with 👍 / 👎.

}
}
}
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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`);
Expand Down
21 changes: 21 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof PathsSchema>;

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',
}),
Comment on lines +22 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

DRY violation — defaults are duplicated and will drift.

You've defined the same default values twice:

  1. Inner defaults (lines 6–11): via .default() on each field of PathsSchema
  2. Outer defaults (lines 22–29): via PathsSchema.default({...}) with explicit values

When the paths key is missing entirely, Zod uses the outer default. But that object then gets parsed through PathsSchema, where the inner defaults would apply anyway. The explicit outer defaults are redundant — pass an empty object and let the inner defaults do their job.

This duplication is a maintenance hazard. Someone will change one set and forget the other. Murphy's law.

♻️ Eliminate duplicate defaults
 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',
-  }),
+  paths: PathsSchema.default({}),
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
paths: PathsSchema.default({
backlog: 'docs/method/backlog',
design: 'docs/design',
retro: 'docs/method/retro',
tests: 'tests',
graveyard: 'docs/method/graveyard',
method_dir: 'docs/method',
}),
paths: PathsSchema.default({}),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.ts` around lines 22 - 29, The outer defaults duplicate the
per-field defaults inside PathsSchema; replace the explicit object passed to
PathsSchema.default(...) with an empty object so the inner .default() values on
each PathsSchema field are used when the paths key is missing — update the paths
assignment that currently uses PathsSchema.default({backlog:..., design:...,
...}) to use PathsSchema.default({}) (or remove the outer defaults entirely) so
only PathsSchema's internal defaults govern values.

});

export type Config = z.infer<typeof ConfigSchema>;
Expand Down
3 changes: 0 additions & 3 deletions src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof LaneSchema>;

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<typeof OutcomeSchema>;
Expand Down
15 changes: 7 additions & 8 deletions src/drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)}.`;
Expand Down Expand Up @@ -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();
Expand All @@ -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[] {
Expand Down
Loading
Loading