@dotdotgod/cli 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Command-line tools for dotdotgod project memory.
6
6
 
7
7
  Use this package when you want to initialize a docs-first project scaffold, validate project-memory docs, build a local graph/cache, search shared documentation locally, expand project references, or ask what should be checked after a file changes.
8
8
 
9
- These commands implement dotdotgod's project-memory loop — init, load, plan, impact — for shells, scripts, CI, and any agent without a dedicated adapter.
9
+ These commands support dotdotgod's project-memory loop with initialization, documentation query, validation, and impact analysis for shells, scripts, CI, and agents without a dedicated adapter.
10
10
 
11
11
  ## Start Here
12
12
 
@@ -40,8 +40,6 @@ dotdotgod graph impact . --changed <path> --compact
40
40
  - `resolve` and `expand` map explicit or high-signal prompt references to project files.
41
41
  - `graph impact` ranks likely related specs, tests, docs, commands, and source files for one or more changed paths, with a combined ranking and per-file top five.
42
42
  - `traceability links` checks or repairs generated Markdown traceability-link sections.
43
- - `plan validate` validates active plan artifacts and simplified Plan Generator checkpoints before execution.
44
- - `plan stage create` creates an internal `.dotdotgod-plan/NN_STAGE_NAME.md` checkpoint for a simplified Plan Generator stage.
45
43
  - `trello sync` plans or writes Trello linked-doc metadata from configured docs paths.
46
44
 
47
45
  ## Commands
@@ -62,17 +60,12 @@ dotdotgod expand . "Update [[PLAN_MODE]] and [[HOOKS]]"
62
60
  dotdotgod expand . "PLAN_MODE 수정하자" --fuzzy
63
61
  dotdotgod traceability links . --check
64
62
  dotdotgod traceability links . --write
65
- dotdotgod plan validate docs/plan/<task-slug>/README.md
66
- dotdotgod plan validate docs/plan/<task-slug>/README.md --stage 04
67
- dotdotgod plan stage create 02 docs/plan/<task-slug>/README.md
68
63
  dotdotgod graph impact . --changed <path>
69
64
  dotdotgod graph impact . --changed <path> --changed <another-path> --compact
70
65
  dotdotgod graph impact . --changed <path> --changed <another-path> --yml
71
66
  dotdotgod graph communities .
72
67
  ```
73
68
 
74
- `--help`, `-h`, and `help` print usage to stdout. Command-specific help is available with `dotdotgod <command> --help`, including nested commands such as `dotdotgod graph impact --help`, `dotdotgod config init --help`, `dotdotgod plan stage create --help`, and `dotdotgod traceability links --help`.
75
-
76
69
  ## Which Command Should I Use?
77
70
 
78
71
  | Need | Command |
@@ -83,8 +76,6 @@ dotdotgod graph communities .
83
76
  | Search shared project documentation locally | `dotdotgod query . "project documentation"` |
84
77
  | Resolve `[[...]]` references from a prompt | `dotdotgod expand . "Update [[PLAN_MODE]]"` |
85
78
  | Repair generated traceability-link sections | `dotdotgod traceability links . --write` |
86
- | Validate an active plan before execution | `dotdotgod plan validate docs/plan/<task>/README.md` |
87
- | Create an internal Plan Generator stage checkpoint | `dotdotgod plan stage create 03 docs/plan/<task>/README.md` |
88
79
  | See what else to inspect after a change | `dotdotgod graph impact . --changed <path> --compact` |
89
80
  | Preview Trello linked-doc updates | `dotdotgod trello sync . --dry-run` |
90
81
 
@@ -115,7 +106,6 @@ The graph is built from maintained project files: Markdown links, README routes,
115
106
  | `dotdotgod validate <root>` | Checks dotdotgod docs/project-memory structure, local links, traceability blocks, generated traceability-link drift, config validity, and optional index freshness. |
116
107
  | `dotdotgod traceability links <root> --check` | Runs only the generated traceability-link and compact JSON drift check. |
117
108
  | `dotdotgod traceability links <root> --write` | Repairs generated traceability-link sections and rewrites canonical `json dotdotgod` blocks as compact JSON. |
118
- | `dotdotgod plan validate <plan-readme>` | Checks active-plan structure, required Markdown sections, decision/assumption blockers, atomic-task verification, and simplified Plan Generator checkpoints when present. |
119
109
  | `pnpm run verify` | Runs this repository's quality gate: generated-resource checks, package verify contracts, tests, typecheck, and docs validation where each package defines it. |
120
110
 
121
111
  Most users start with `validate`. Use `traceability links --write` only when generated traceability output needs repair, and use `verify` before release-style handoff in this repository.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "CLI for dotdotgod validation, local documentation search, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
package/src/cli/usage.mjs CHANGED
@@ -36,23 +36,6 @@ Create dotdotgod.config.json with the built-in default memory, traceability, val
36
36
  dotdotgod query <root> <query> [--limit n] [--json]
37
37
 
38
38
  Search shared project documentation with the local multilingual E5 vector index.`;
39
- case 'plan':
40
- return `Usage:
41
- dotdotgod plan validate docs/plan/<task-slug>/README.md [--stage stage] [--json]
42
- dotdotgod plan stage create <stage> [docs/plan/<task-slug>/README.md] [--json]`;
43
- case 'plan validate':
44
- return `Usage:
45
- dotdotgod plan validate docs/plan/<task-slug>/README.md [--stage stage] [--json]
46
-
47
- Validates an active plan artifact before execution. Supports simplified Plan Generator stages and optional .dotdotgod-plan/NN_STAGE_NAME.md workspaces. Use --stage with a stage name or numeric prefix such as 04 or 05 to validate only that stage.`;
48
- case 'plan stage':
49
- return `Usage:
50
- dotdotgod plan stage create <stage> [docs/plan/<task-slug>/README.md] [--json]`;
51
- case 'plan stage create':
52
- return `Usage:
53
- dotdotgod plan stage create <stage> [docs/plan/<task-slug>/README.md] [--json]
54
-
55
- Creates the matching .dotdotgod-plan/NN_STAGE_NAME.md internal stage checkpoint for a simplified Plan Generator stage. <stage> accepts a numeric prefix such as 02 or a canonical stage name such as 02-context-load. If the plan path is omitted, exactly one active docs/plan/<task-slug>/README.md candidate must exist.`;
56
39
  case 'resolve':
57
40
  return `Usage:
58
41
  dotdotgod resolve <root> <ref> [--max-results n] [--include-archive] [--json]`;
@@ -108,8 +91,6 @@ Ranks nodes related to one or more changed files. <root> is the project root; re
108
91
  dotdotgod resolve <root> <ref> [--max-results n] [--include-archive] [--json]
109
92
  dotdotgod expand <root> <prompt> [--max-results n] [--include-archive] [--with-impact] [--fuzzy] [--json]
110
93
  dotdotgod traceability links <root> [--check|--write] [--json]
111
- dotdotgod plan validate docs/plan/<task-slug>/README.md [--stage stage] [--json]
112
- dotdotgod plan stage create <stage> [docs/plan/<task-slug>/README.md] [--json]
113
94
  dotdotgod trello sync <root> [--dry-run]
114
95
  dotdotgod graph impact <root> --changed <path> [--changed <path> ...] [--compact|--json|--yml|--yaml]
115
96
  dotdotgod graph communities <root> [--json]`;
@@ -156,9 +137,6 @@ export function helpCommandFromArgs(args) {
156
137
  if (nonHelp[0] === 'graph' && nonHelp[1]) return `graph ${nonHelp[1]}`;
157
138
  if (nonHelp[0] === 'trello' && nonHelp[1]) return `trello ${nonHelp[1]}`;
158
139
  if (nonHelp[0] === 'traceability' && nonHelp[1]) return `traceability ${nonHelp[1]}`;
159
- if (nonHelp[0] === 'plan' && nonHelp[1] === 'stage' && nonHelp[2] === 'create') return 'plan stage create';
160
- if (nonHelp[0] === 'plan' && nonHelp[1] === 'stage') return 'plan stage';
161
- if (nonHelp[0] === 'plan' && nonHelp[1]) return `plan ${nonHelp[1]}`;
162
140
  if (nonHelp[0] === 'config' && nonHelp[1] === 'init') return 'config init';
163
141
  return nonHelp[0] ?? 'root';
164
142
  }
package/src/core.mjs CHANGED
@@ -7,7 +7,6 @@ import { runQuery } from './commands/query.mjs';
7
7
  import { runResolve, runExpand } from './reference/resolve.mjs';
8
8
  import { runTraceability } from './commands/traceability.mjs';
9
9
  import { runGraph } from './commands/graph.mjs';
10
- import { runPlan } from './commands/plan.mjs';
11
10
  import { runTrelloSync } from './trello/sync.mjs';
12
11
 
13
12
  export { CACHE_VERSION } from './index/constants.mjs';
@@ -35,7 +34,6 @@ export { extractBracketReferences, extractFuzzyReferences, normalizeReferenceAli
35
34
  export { resolveReferenceCandidates, runResolve, runExpand } from './reference/resolve.mjs';
36
35
  export { parseTraceabilityOptions, runTraceability } from './commands/traceability.mjs';
37
36
  export { parseGraphOptions, runGraph } from './commands/graph.mjs';
38
- export { buildPlanValidationRepairPrompt, createPlanStageCheckpoint, formatPlanValidationText, PLAN_REQUIRED_HEADERS, PLAN_STAGE_DIRECTORIES, resolvePlanValidationStage, runPlan, validatePlanArtifact } from './commands/plan.mjs';
39
37
  export { planTrelloDryRun, runTrelloSync } from './trello/sync.mjs';
40
38
  export { parseTrelloMetadata, extractTrelloShortLink } from './trello/metadata.mjs';
41
39
  export { normalizeGitHubRemote, resolveGitHubFileUrl, resolveGitHubRepositoryIdentity } from './trello/github-url.mjs';
@@ -60,7 +58,6 @@ export async function runCli(argv = process.argv.slice(2)) {
60
58
  else if (command === 'expand') runExpand(args);
61
59
  else if (command === 'traceability') runTraceability(args);
62
60
  else if (command === 'graph') await runGraph(args);
63
- else if (command === 'plan') runPlan(args);
64
61
  else if (command === 'trello') {
65
62
  const result = await Promise.resolve(runTrelloSync(args));
66
63
  if (result.stdout) process.stdout.write(result.stdout);
@@ -37,7 +37,6 @@ export function runValidate(argv) {
37
37
  if (path.includes('/node_modules') || path === 'node_modules') return true;
38
38
  if (path.includes('/.git') || path === '.git') return true;
39
39
  if (path === CACHE_DIR || path.startsWith(`${CACHE_DIR}/`)) return true;
40
- if (/^docs\/(?:plan|archive\/plan)\/[a-z0-9]+(?:-[a-z0-9]+)*\/\.dotdotgod-plan(?:\/|$)/.test(path)) return true;
41
40
  if (!options.includeLocalMemory && (path === 'docs/plan' || path.startsWith('docs/plan/') || path === 'docs/archive' || path.startsWith('docs/archive/'))) return true;
42
41
  return false;
43
42
  };
@@ -48,8 +47,7 @@ export function runValidate(argv) {
48
47
  if (entry.isDirectory()) {
49
48
  const docsRel = rel(docs, path);
50
49
  if (docsRel && !docsRel.startsWith('..')) {
51
- const isPlanInternalWorkspaceDir = /^(?:plan|archive\/plan)\/[a-z0-9]+(?:-[a-z0-9]+)*\/\.dotdotgod-plan(?:\/|$)/.test(docsRel);
52
- if (!isPlanInternalWorkspaceDir) for (const part of docsRel.split('/')) if (part && !isKebabCase(part)) addError(path, 'DIR_NAMING', `Directory must be kebab-case: ${part}`, null, 'rename this docs directory to kebab-case and update any links that reference it.');
50
+ for (const part of docsRel.split('/')) if (part && !isKebabCase(part)) addError(path, 'DIR_NAMING', `Directory must be kebab-case: ${part}`, null, 'rename this docs directory to kebab-case and update any links that reference it.');
53
51
  }
54
52
  walk(path);
55
53
  } else if (entry.isFile() && entry.name.endsWith('.md')) markdownFiles.push(path);
@@ -98,13 +96,11 @@ export function runValidate(argv) {
98
96
  const name = basename(file);
99
97
  const docsRel = rel(docs, file);
100
98
  const rootRel = rel(root, file);
101
- const isPlanInternalWorkspaceFile = /^docs\/(?:plan|archive\/plan)\/[a-z0-9]+(?:-[a-z0-9]+)*\/\.dotdotgod-plan\/(?:[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*|[0-9]{2}_[A-Z0-9]+(?:_[A-Z0-9]+)*)\.md$/.test(rootRel);
102
- if (docsRel && !docsRel.startsWith('..') && !isPlanInternalWorkspaceFile && !isUpperSnakeMarkdown(name)) addError(file, 'FILE_NAMING', `Markdown file must be UPPER_SNAKE_CASE.md or README.md: ${name}`, null, 'rename the markdown file to UPPER_SNAKE_CASE.md or README.md and update any links that reference it.');
99
+ if (docsRel && !docsRel.startsWith('..') && !isUpperSnakeMarkdown(name)) addError(file, 'FILE_NAMING', `Markdown file must be UPPER_SNAKE_CASE.md or README.md: ${name}`, null, 'rename the markdown file to UPPER_SNAKE_CASE.md or README.md and update any links that reference it.');
103
100
  const content = readFileSync(file, 'utf8');
104
101
  fileCache.set(file, content);
105
102
  const docArea = docsRel?.split('/')[0];
106
- if (name !== 'README.md' && !isPlanInternalWorkspaceFile
107
- && isUpperSnakeMarkdown(name) && FILENAME_QUALITY_AREAS.has(docArea)) {
103
+ if (name !== 'README.md' && isUpperSnakeMarkdown(name) && FILENAME_QUALITY_AREAS.has(docArea)) {
108
104
  const fp = validationPolicy.markdown?.filename ?? {};
109
105
  const allow = fp.allow ?? [];
110
106
  if (!allow.some((p) => matchPathPattern(rootRel, p)) && (fp.warnNumberedSeries ?? true)) {
@@ -144,8 +140,6 @@ export function runValidate(argv) {
144
140
  const byDir = new Map();
145
141
  for (const file of markdownFiles) byDir.set(dirname(file), [...(byDir.get(dirname(file)) ?? []), file]);
146
142
  for (const [dir, files] of byDir) {
147
- const dirRel = rel(root, dir);
148
- if (/^docs\/(?:plan|archive\/plan)\/[a-z0-9]+(?:-[a-z0-9]+)*\/\.dotdotgod-plan$/.test(dirRel)) continue;
149
143
  if (files.length > 1 && !files.some((file) => basename(file) === 'README.md')) addError(dir, 'MISSING_README', 'Directory with multiple markdown files must include README.md', null, 'add a README.md in this directory that indexes the important markdown files and their purpose.');
150
144
  }
151
145
  if (options.linkCheck) {
@@ -1,75 +0,0 @@
1
- export const PLAN_STAGE_DIRECTORIES = [
2
- '01-intake',
3
- '02-context-load',
4
- '03-discovery',
5
- '04-plan',
6
- ];
7
-
8
- export const PLAN_INTERNAL_WORKSPACE = '.dotdotgod-plan';
9
-
10
- export const PLAN_INTERNAL_STAGE_DIRECTORIES = [
11
- ...PLAN_STAGE_DIRECTORIES,
12
- '05-workstream-handoff',
13
- ];
14
-
15
- export const PLAN_REQUIRED_HEADERS = {
16
- '01-intake': ['Request Summary', 'Goal', 'Scope', 'Non-goals', 'Constraints', 'Assumptions'],
17
- '02-context-load': ['Memory Reads', 'Impact Candidates', 'Related Files'],
18
- '03-discovery': ['Findings', 'Risks', 'Open Questions'],
19
- '04-plan': ['Milestones', 'Atomic Tasks', 'Validation Plan', 'Resume Point'],
20
- '05-workstream-handoff': [
21
- 'Workstream Handoff',
22
- 'Workstream Map',
23
- 'Shared Context',
24
- 'Workstreams',
25
- 'Integration Sequence',
26
- 'Todo Contract',
27
- ],
28
- };
29
-
30
- export const PLAN_CONSTRUCTION_CHECKLISTS = {
31
- '01-intake': ['Requirements', 'Constraints', 'Failure paths', 'Data shape', 'Verification', 'Integration impact'],
32
- '02-context-load': ['Memory reads', 'Impact candidates', 'Related files', 'Boundary risk'],
33
- '03-discovery': ['Findings', 'Risks', 'Open questions', 'Extension points'],
34
- '04-plan': ['Milestones', 'Atomic tasks', 'Validation plan', 'Resume point'],
35
- '05-workstream-handoff': ['Handoffs', 'Do-not rules', 'Focused verification', 'Chat-independent context'],
36
- };
37
-
38
- export const PLAN_BLOCKER_LABELS = new Map([
39
- ['missing-plan', 'Plan file does not exist'],
40
- ['invalid-plan-path', 'Plan file must be docs/plan/<task-slug>/README.md'],
41
- ['invalid-task-slug', 'Plan task directory must be kebab-case'],
42
- ['empty-plan', 'Plan README is empty'],
43
- ['missing-stage', 'Missing required stage directory'],
44
- ['missing-stage-readme', 'Missing stage README.md'],
45
- ['missing-internal-stage', 'Missing internal stage checkpoint'],
46
- ['open-internal-stage', 'Internal stage checkpoint is not completed'],
47
- ['invalid-markdown-name', 'Invalid Markdown file name'],
48
- ['missing-section', 'Missing required section'],
49
- ['empty-section', 'Required section has no content'],
50
- ['placeholder-section', 'Required section only contains placeholder content'],
51
- ['pending-workstream', 'Required workstream is pending'],
52
- ['unresolved-discussion', 'Discussion Queue has unresolved items'],
53
- ['unresolved-assumption', 'Assumptions contain unresolved items'],
54
- ['missing-atomic-acceptance', 'Atomic Tasks must define acceptance criteria'],
55
- ['missing-atomic-verification', 'Atomic Tasks must define verification'],
56
- ['missing-construction-checklist', 'Missing construction checklist'],
57
- ['missing-checklist-item', 'Missing construction checklist item'],
58
- ['malformed-checklist-item', 'Malformed construction checklist item'],
59
- ['open-checklist-item', 'Construction checklist item is open'],
60
- ['placeholder-checklist-item', 'Construction checklist item only contains placeholder content'],
61
- ['missing-workstream-split-decision', 'Workstream Handoff must include an explicit split decision'],
62
- ['incomplete-workstream-split', 'Split workstream handoff must include actionable workstream contracts'],
63
- ['missing-workstream-no-split-rationale', 'No-split workstream handoff must include rationale'],
64
- ]);
65
-
66
- export function buildPlanValidationNextStagePrompt(planPath, stage, stagePath) {
67
- return [
68
- `Continue Plan Mode stage advancement for ${planPath}.`,
69
- `Current stage: ${stage}.`,
70
- `Update the durable plan for human readers, then make ${stagePath} the machine-readable checkpoint that satisfies the current stage validation contract. Do not create later checkpoint files yet.`,
71
- `In ${stagePath}, set Status: completed and include the current stage required sections plus \`## Stage ${stage.slice(0, 2)} Construction Checklist\` rows in the canonical \`- [x] Category: evidence\` form.`,
72
- stage === '05-workstream-handoff' ? 'For this stage checkpoint, record `Split decision: yes` or `Split decision: no` in `## Workstream Handoff`. If splitting, make `## Workstream Map`, `## Workstreams`, and `## Integration Sequence` describe multiple actionable workstreams with required context, allowed/forbidden edits, tasks, validation, dependencies, and handoff output. If not splitting, record the no-split rationale and keep `## Todo Contract` actionable so Pi can extract execution todos.' : undefined,
73
- `After completing this stage, stop and rerun \`dotdotgod plan validate ${planPath} --stage ${stage} --json\`.`,
74
- ].filter(Boolean).join('\n\n');
75
- }
@@ -1,669 +0,0 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
- import { commandUsage } from '../cli/usage.mjs';
4
- import { isKebabCase, isUpperSnakeMarkdown, removeCodeBlocks } from '../docs/markdown.mjs';
5
- import {
6
- PLAN_BLOCKER_LABELS as BLOCKER_LABELS,
7
- PLAN_CONSTRUCTION_CHECKLISTS,
8
- PLAN_INTERNAL_STAGE_DIRECTORIES,
9
- PLAN_INTERNAL_WORKSPACE,
10
- PLAN_REQUIRED_HEADERS,
11
- PLAN_STAGE_DIRECTORIES,
12
- buildPlanValidationNextStagePrompt,
13
- } from './plan-stage-contract.mjs';
14
-
15
- export {
16
- PLAN_CONSTRUCTION_CHECKLISTS,
17
- PLAN_INTERNAL_STAGE_DIRECTORIES,
18
- PLAN_INTERNAL_WORKSPACE,
19
- PLAN_REQUIRED_HEADERS,
20
- PLAN_STAGE_DIRECTORIES,
21
- };
22
-
23
- function normalizeHeading(value) {
24
- return String(value ?? '').trim().toLowerCase().replace(/`/g, '').replace(/[\s/_-]+/g, ' ');
25
- }
26
-
27
- function escapeRegExp(value) {
28
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
- }
30
-
31
- function projectRelative(root, file) {
32
- return relative(root, file).replace(/\\/g, '/');
33
- }
34
-
35
- function buildBlockerRepairPrompt(blocker) {
36
- const target = [blocker.path, blocker.stage, blocker.section ? `## ${blocker.section}` : undefined].filter(Boolean).join(' / ');
37
- const location = target ? ` in ${target}` : '';
38
- return `Update the active plan artifact${location} to resolve this blocker: ${blocker.message}`;
39
- }
40
-
41
- function addBlocker(blockers, code, message, details = {}) {
42
- const blocker = { code, message: message ?? BLOCKER_LABELS.get(code) ?? code, ...details };
43
- blockers.push({ ...blocker, prompt: buildBlockerRepairPrompt(blocker) });
44
- }
45
-
46
- function parseHeadedSections(content) {
47
- const sections = [];
48
- const lines = removeCodeBlocks(content).split(/\r?\n/);
49
- let current;
50
- for (let index = 0; index < lines.length; index += 1) {
51
- const match = /^(#{1,6})\s+(.+?)\s*$/.exec(lines[index]);
52
- if (match) {
53
- if (current) sections.push(current);
54
- current = { level: match[1].length, title: match[2].trim(), contentLines: [], line: index + 1 };
55
- } else if (current) {
56
- current.contentLines.push(lines[index]);
57
- }
58
- }
59
- if (current) sections.push(current);
60
- return sections;
61
- }
62
-
63
- function contentHasValue(content) {
64
- const stripped = content
65
- .split(/\r?\n/)
66
- .filter((line) => !/^\s*<!--/.test(line))
67
- .join('\n')
68
- .trim();
69
- return stripped.length > 0;
70
- }
71
-
72
- function readMarkdownFiles(stageDir) {
73
- if (!existsSync(stageDir)) return [];
74
- return readdirSync(stageDir, { withFileTypes: true })
75
- .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
76
- .map((entry) => join(stageDir, entry.name));
77
- }
78
-
79
- function internalWorkspaceDir(taskDir) {
80
- return join(taskDir, PLAN_INTERNAL_WORKSPACE);
81
- }
82
-
83
- export function internalStageFileName(stageName) {
84
- const match = /^(\d{2})-(.+)$/.exec(stageName);
85
- if (!match) return `${String(stageName).replace(/-/g, '_').toUpperCase()}.md`;
86
- return `${match[1]}_${match[2].replace(/-/g, '_').toUpperCase()}.md`;
87
- }
88
-
89
- function internalStageFile(taskDir, stageName) {
90
- return join(internalWorkspaceDir(taskDir), internalStageFileName(stageName));
91
- }
92
-
93
- function resolvePlanReadme(root, planPath) {
94
- const absolutePlanPath = isAbsolute(planPath) ? planPath : resolve(root, planPath);
95
- const taskDir = dirname(absolutePlanPath);
96
- const taskSlug = basename(taskDir);
97
- const relPlanPath = projectRelative(root, absolutePlanPath);
98
- const expectedPath = `docs/plan/${taskSlug}/README.md`;
99
- return { absolutePlanPath, taskDir, taskSlug, relPlanPath, expectedPath };
100
- }
101
-
102
- function discoverActivePlanReadmes(root) {
103
- const planRoot = join(root, 'docs/plan');
104
- if (!existsSync(planRoot)) return [];
105
- return readdirSync(planRoot, { withFileTypes: true })
106
- .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
107
- .map((entry) => join(planRoot, entry.name, 'README.md'))
108
- .filter((file) => existsSync(file))
109
- .sort((left, right) => left.localeCompare(right));
110
- }
111
-
112
- function resolveCheckpointPlanPath(root, planPath) {
113
- if (planPath) return resolvePlanReadme(root, planPath);
114
- const candidates = discoverActivePlanReadmes(root);
115
- if (candidates.length !== 1) {
116
- const suffix = candidates.length > 1
117
- ? ` Found ${candidates.length} candidates; pass docs/plan/<task-slug>/README.md explicitly.`
118
- : ' Pass docs/plan/<task-slug>/README.md explicitly.';
119
- throw new Error(`Could not infer an active plan.${suffix}`);
120
- }
121
- return resolvePlanReadme(root, candidates[0]);
122
- }
123
-
124
- function buildStageCheckpointContent(stageName, now = new Date()) {
125
- return `Stage: ${stageName}\nStatus: created\nUpdated: ${now.toISOString()}\n`;
126
- }
127
-
128
- export function createPlanStageCheckpoint(stageValue, options = {}) {
129
- const root = resolve(options.root ?? process.cwd());
130
- const stageName = resolvePlanValidationStage(stageValue);
131
- if (!stageName) throw new Error(`Invalid plan stage: ${stageValue}`);
132
-
133
- const plan = resolveCheckpointPlanPath(root, options.planPath);
134
- if (plan.relPlanPath !== plan.expectedPath) {
135
- throw new Error(`Plan file must be docs/plan/<task-slug>/README.md: ${plan.relPlanPath}`);
136
- }
137
- if (!isKebabCase(plan.taskSlug)) {
138
- throw new Error(`Plan task directory must be kebab-case: ${plan.taskSlug}`);
139
- }
140
- if (!existsSync(plan.absolutePlanPath)) {
141
- throw new Error(`Plan file does not exist: ${plan.relPlanPath}`);
142
- }
143
-
144
- const workspace = internalWorkspaceDir(plan.taskDir);
145
- const checkpointPath = internalStageFile(plan.taskDir, stageName);
146
- if (existsSync(checkpointPath)) {
147
- throw new Error(`Plan stage checkpoint already exists: ${projectRelative(root, checkpointPath)}`);
148
- }
149
-
150
- mkdirSync(workspace, { recursive: true });
151
- writeFileSync(checkpointPath, buildStageCheckpointContent(stageName, options.now));
152
- return {
153
- ok: true,
154
- planPath: plan.relPlanPath,
155
- stage: stageName,
156
- path: projectRelative(root, checkpointPath),
157
- };
158
- }
159
-
160
- function hasInternalWorkspace(taskDir) {
161
- const workspace = internalWorkspaceDir(taskDir);
162
- return existsSync(workspace) && statSync(workspace).isDirectory();
163
- }
164
-
165
- function discoverDurablePlanMarkdownFiles(taskDir) {
166
- const results = [];
167
- function walk(directory) {
168
- if (!existsSync(directory)) return;
169
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
170
- if (entry.name === PLAN_INTERNAL_WORKSPACE || entry.isSymbolicLink()) continue;
171
- const entryPath = join(directory, entry.name);
172
- if (entry.isDirectory()) {
173
- walk(entryPath);
174
- continue;
175
- }
176
- if (entry.isFile() && entry.name.endsWith('.md')) results.push(entryPath);
177
- }
178
- }
179
- walk(taskDir);
180
- return results.sort((left, right) => {
181
- if (basename(left) === 'README.md' && dirname(left) === taskDir) return -1;
182
- if (basename(right) === 'README.md' && dirname(right) === taskDir) return 1;
183
- return left.localeCompare(right);
184
- });
185
- }
186
-
187
- function readInternalStageState(taskDir, stageName) {
188
- const file = internalStageFile(taskDir, stageName);
189
- if (!existsSync(file)) return undefined;
190
- const content = readFileSync(file, 'utf8');
191
- return { file, content };
192
- }
193
-
194
- function validateInternalStageState(root, taskDir, stageName, blockers) {
195
- const state = readInternalStageState(taskDir, stageName);
196
- const expectedPath = internalStageFile(taskDir, stageName);
197
- const rel = projectRelative(root, state?.file ?? expectedPath);
198
- if (!state) {
199
- addBlocker(blockers, 'missing-internal-stage', `Missing internal stage checkpoint: ${rel}`, { path: rel, stage: stageName });
200
- return { state: undefined, valid: false };
201
- }
202
-
203
- let valid = true;
204
- const stage = contentMatch(state.content, /^Stage:\s*(\S+)\s*$/m);
205
- const status = contentMatch(state.content, /^Status:\s*(created|blocked|completed)\s*$/m);
206
- const updated = contentMatch(state.content, /^Updated:\s*(\S+)\s*$/m);
207
- if (stage && stage !== stageName) {
208
- valid = false;
209
- addBlocker(blockers, 'missing-internal-stage', `Internal stage file has wrong Stage field: ${rel}`, { path: rel, stage: stageName });
210
- }
211
- if (!stage) {
212
- valid = false;
213
- addBlocker(blockers, 'missing-internal-stage', `Internal stage file is missing Stage: ${rel}`, { path: rel, stage: stageName });
214
- }
215
- if (!status) {
216
- valid = false;
217
- addBlocker(blockers, 'missing-internal-stage', `Internal stage file is missing valid Status: ${rel}`, { path: rel, stage: stageName });
218
- } else if (status !== 'completed') {
219
- valid = false;
220
- addBlocker(blockers, 'open-internal-stage', `Internal stage checkpoint is not completed: ${rel}`, { path: rel, stage: stageName });
221
- }
222
- if (!updated) {
223
- valid = false;
224
- addBlocker(blockers, 'missing-internal-stage', `Internal stage file is missing Updated: ${rel}`, { path: rel, stage: stageName });
225
- }
226
- return { state, valid };
227
- }
228
-
229
- function contentMatch(content, pattern) {
230
- return pattern.exec(content)?.[1];
231
- }
232
-
233
- function findSection(files, header) {
234
- const wanted = normalizeHeading(header);
235
- let fallback;
236
- for (const file of files) {
237
- const content = readFileSync(file, 'utf8');
238
- for (const section of parseHeadedSections(content)) {
239
- if (section.level < 2 || normalizeHeading(section.title) !== wanted) continue;
240
- const found = { file, section, content: section.contentLines.join('\n') };
241
- const isTaskReadme = basename(file) === 'README.md';
242
- if (isTaskReadme || contentHasValue(found.content)) return found;
243
- fallback ??= found;
244
- }
245
- }
246
- return fallback;
247
- }
248
-
249
- function validateMarkdownNames(root, stageName, stageDir, blockers) {
250
- for (const file of readMarkdownFiles(stageDir)) {
251
- const name = basename(file);
252
- const valid = isUpperSnakeMarkdown(name);
253
- if (!valid) {
254
- addBlocker(blockers, 'invalid-markdown-name', `Invalid Markdown file name: ${projectRelative(root, file)}`, { path: projectRelative(root, file) });
255
- }
256
- }
257
- }
258
-
259
- function sectionContainsField(content, labels) {
260
- const text = removeCodeBlocks(content);
261
- return labels.some((label) => new RegExp(`(?:^|\\b)${escapeRegExp(label)}\\s*:\\s*\\S`, 'im').test(text));
262
- }
263
-
264
- function hasUncheckedChecklistItem(content) {
265
- return content.split(/\r?\n/).some((line) => /^\s*[-*+]\s*\[\s\]/.test(line));
266
- }
267
-
268
- function hasOpenStructuredStatus(content) {
269
- return content.split(/\r?\n/).some((line) => /^\s*(?:[-*+]\s*)?(?:Status|DecisionState|Decision State)\s*:\s*(?:open|blocked|unresolved|pending|research[-_ ]requested|plan[-_ ]revision[-_ ]requested)\s*$/i.test(line));
270
- }
271
-
272
- function hasUnresolvedStructuredItem(content) {
273
- return hasUncheckedChecklistItem(content) || hasOpenStructuredStatus(content);
274
- }
275
-
276
- function workstreamSplitDecision(content) {
277
- const match = /\bsplit\s+decision\s*:\s*(yes|no)\b/i.exec(content);
278
- return match?.[1].toLowerCase();
279
- }
280
-
281
- function fieldPattern(label, flags = 'im') {
282
- return new RegExp(`^\\s*(?:[-*+]\\s*)?${escapeRegExp(label)}\\s*:\\s*(\\S.*)$`, flags);
283
- }
284
-
285
- function fieldHasValue(content, label) {
286
- return Boolean(fieldPattern(label).exec(removeCodeBlocks(content))?.[1]?.trim());
287
- }
288
-
289
- function fieldOccurrences(content, label) {
290
- return [...removeCodeBlocks(content).matchAll(fieldPattern(label, 'img'))].length;
291
- }
292
-
293
- function hasNoSplitRationale(content) {
294
- return fieldHasValue(content, 'No-split rationale');
295
- }
296
-
297
- function hasMultipleWorkstreams(content) {
298
- return fieldOccurrences(content, 'Workstream ID') >= 2;
299
- }
300
-
301
- function hasActionableWorkstreamFields(content) {
302
- return ['Workstream ID', 'Purpose', 'Required context', 'Allowed edits', 'Forbidden edits', 'Tasks', 'Validation', 'Handoff output', 'Dependencies'].every((label) => fieldHasValue(content, label));
303
- }
304
-
305
- function hasActionableIntegrationFields(content) {
306
- return ['Step', 'Validation', 'Handoff'].every((label) => fieldHasValue(content, label));
307
- }
308
-
309
- function validateWorkstreamHandoffContract(root, files, stageName, blockers) {
310
- if (stageName !== '05-workstream-handoff') return true;
311
- let valid = true;
312
- const handoff = findSection(files, 'Workstream Handoff');
313
- const todo = findSection(files, 'Todo Contract');
314
- const relHandoff = projectRelative(root, handoff?.file ?? files[0]);
315
- const decision = workstreamSplitDecision(handoff?.content ?? '');
316
- if (!decision) {
317
- addBlocker(blockers, 'missing-workstream-split-decision', 'Workstream Handoff must include `Split decision: yes` or `Split decision: no`.', { path: relHandoff, stage: stageName, section: 'Workstream Handoff' });
318
- return false;
319
- }
320
- if (decision === 'no') {
321
- if (!hasNoSplitRationale(handoff.content)) {
322
- valid = false;
323
- addBlocker(blockers, 'missing-workstream-no-split-rationale', 'Workstream Handoff with `Split decision: no` must include `No-split rationale:`.', { path: relHandoff, stage: stageName, section: 'Workstream Handoff' });
324
- }
325
- if (!todo || !contentHasValue(todo.content)) valid = false;
326
- return valid;
327
- }
328
-
329
- const map = findSection(files, 'Workstream Map');
330
- const workstreams = findSection(files, 'Workstreams');
331
- const integration = findSection(files, 'Integration Sequence');
332
- const splitContent = [map?.content, workstreams?.content, integration?.content].filter(Boolean).join('\n');
333
- if (!hasMultipleWorkstreams(splitContent)) {
334
- valid = false;
335
- addBlocker(blockers, 'incomplete-workstream-split', 'Split Stage 05 handoff must include at least two `Workstream ID:` fields in Workstream Map, Workstreams, or Integration Sequence.', { path: projectRelative(root, map?.file ?? workstreams?.file ?? integration?.file ?? handoff.file), stage: stageName, section: 'Workstream Map' });
336
- }
337
- if (!workstreams || !hasActionableWorkstreamFields(workstreams.content)) {
338
- valid = false;
339
- addBlocker(blockers, 'incomplete-workstream-split', 'Workstreams must include non-empty structured fields: Workstream ID, Purpose, Required context, Allowed edits, Forbidden edits, Tasks, Validation, Handoff output, and Dependencies.', { path: projectRelative(root, workstreams?.file ?? handoff.file), stage: stageName, section: 'Workstreams' });
340
- }
341
- if (!integration || !hasActionableIntegrationFields(integration.content)) {
342
- valid = false;
343
- addBlocker(blockers, 'incomplete-workstream-split', 'Integration Sequence must include non-empty structured fields: Step, Validation, and Handoff.', { path: projectRelative(root, integration?.file ?? handoff.file), stage: stageName, section: 'Integration Sequence' });
344
- }
345
- return valid;
346
- }
347
-
348
- function constructionChecklistHeader(stageName) {
349
- const match = /^(\d{2})-/.exec(stageName);
350
- return `Stage ${match?.[1] ?? stageName} Construction Checklist`;
351
- }
352
-
353
- function normalizeChecklistCategory(value) {
354
- return normalizeHeading(value).replace(/\s+/g, ' ');
355
- }
356
-
357
- function checklistStatusChecked(value) {
358
- return /\[[xX]\]/.test(value) || /\bcompleted\b/i.test(value);
359
- }
360
-
361
- function parseChecklistLine(line, lineNumber) {
362
- const trimmed = line.trim();
363
- if (!trimmed) return undefined;
364
- if (/^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/.test(trimmed)) return undefined;
365
-
366
- const canonical = /^\s*[-*+]\s*\[([^\]])\]\s*([^:]+):\s*(.*?)\s*$/.exec(line);
367
- if (canonical) return { line, lineNumber, checked: canonical[1].trim().toLowerCase() === 'x', category: canonical[2].trim(), evidence: canonical[3].trim(), malformed: false };
368
-
369
- const checkedDash = /^\s*[-*+]\s*\[([^\]])\]\s*(.+?)\s*[—–-]\s*(.*?)\s*$/.exec(line);
370
- if (checkedDash) return { line, lineNumber, checked: checkedDash[1].trim().toLowerCase() === 'x', category: checkedDash[2].trim(), evidence: checkedDash[3].trim(), malformed: false };
371
-
372
- const completedPrefix = /^\s*[-*+]\s*completed\s*[—–-]\s*\[([xX])\]\s*(.+?)(?::\s*(.*?))?\s*$/.exec(line);
373
- if (completedPrefix) return { line, lineNumber, checked: true, category: completedPrefix[2].trim(), evidence: (completedPrefix[3] ?? 'completed').trim(), malformed: false };
374
-
375
- const table = /^\s*\|(.+)\|\s*$/.exec(line);
376
- if (table) {
377
- const cells = table[1].split('|').map((cell) => cell.trim()).filter(Boolean);
378
- if (cells.length >= 2 && !/^item$/i.test(cells[0]) && !/^category$/i.test(cells[0])) return { line, lineNumber, checked: checklistStatusChecked(cells.slice(1).join(' | ')), category: cells[0], evidence: cells.slice(1).join(' | '), malformed: false };
379
- return undefined;
380
- }
381
-
382
- const colonStatus = /^\s*([^:|]+):\s*(completed\s*[—–-]\s*\[[xX]\]|\[[xX]\]|completed)(?:\s*[—–:\-]\s*(.*?))?\s*$/.exec(line);
383
- if (colonStatus) return { line, lineNumber, checked: checklistStatusChecked(colonStatus[2]), category: colonStatus[1].trim(), evidence: (colonStatus[3] ?? colonStatus[2]).trim(), malformed: false };
384
-
385
- return /^\s*[-*+]\s*/.test(line) ? { line, lineNumber, malformed: true } : undefined;
386
- }
387
-
388
- function parseChecklistRows(content) {
389
- return content
390
- .split(/\r?\n/)
391
- .map((line, index) => parseChecklistLine(line, index + 1))
392
- .filter((row) => row?.malformed || row?.category);
393
- }
394
-
395
- function validateConstructionChecklist(root, files, stageName, blockers) {
396
- const requiredCategories = PLAN_CONSTRUCTION_CHECKLISTS[stageName] ?? [];
397
- if (requiredCategories.length === 0) return true;
398
- const header = constructionChecklistHeader(stageName);
399
- const found = findSection(files, header);
400
- if (!found) {
401
- addBlocker(blockers, 'missing-construction-checklist', `Missing construction checklist: ## ${header}`, { path: projectRelative(root, files[0]), stage: stageName, section: header });
402
- return false;
403
- }
404
- let valid = true;
405
- const relFound = projectRelative(root, found.file);
406
- const rows = parseChecklistRows(found.content);
407
- const byCategory = new Map(rows.filter((row) => row.category).map((row) => [normalizeChecklistCategory(row.category), row]));
408
- for (const row of rows) {
409
- if (row.malformed) {
410
- valid = false;
411
- addBlocker(blockers, 'malformed-checklist-item', `Malformed construction checklist item at line ${row.lineNumber}: expected canonical "- [x] Category: evidence" or a completed table/status row with non-empty evidence.`, { path: relFound, stage: stageName, section: header });
412
- continue;
413
- }
414
- if (!row.checked) {
415
- valid = false;
416
- addBlocker(blockers, 'open-checklist-item', `Construction checklist item is unchecked: ${row.category}`, { path: relFound, stage: stageName, section: header });
417
- }
418
- if (!contentHasValue(row.evidence)) {
419
- valid = false;
420
- addBlocker(blockers, 'empty-checklist-item', `Construction checklist item has no evidence: ${row.category}`, { path: relFound, stage: stageName, section: header });
421
- }
422
- }
423
- for (const category of requiredCategories) {
424
- if (!byCategory.has(normalizeChecklistCategory(category))) {
425
- valid = false;
426
- addBlocker(blockers, 'missing-checklist-item', `Missing construction checklist item: ${category}. Preferred format: "- [x] ${category}: evidence".`, { path: relFound, stage: stageName, section: header });
427
- }
428
- }
429
- return valid;
430
- }
431
-
432
- export function buildPlanValidationRepairPrompt(result) {
433
- if (result.ok) return undefined;
434
- const lines = [
435
- `Refine the active plan artifact so \`dotdotgod plan validate ${result.planPath} --json\` passes.`,
436
- 'Address every blocker below by editing the active plan files, preserving user decisions and marking unresolved questions in the Discussion Queue instead of guessing.',
437
- '',
438
- 'Blockers:',
439
- ];
440
- for (const blocker of result.blockers) {
441
- const location = [blocker.path, blocker.stage, blocker.section ? `## ${blocker.section}` : undefined].filter(Boolean).join(' / ');
442
- lines.push(`- ${blocker.code}${location ? ` (${location})` : ''}: ${blocker.message}`);
443
- }
444
- lines.push('', 'After edits, rerun the plan validation command and summarize remaining blockers if any.');
445
- return lines.join('\n');
446
- }
447
-
448
- function withPlanValidationRepairPrompt(result) {
449
- if (result.ok) return result;
450
- return { ...result, repairPrompt: buildPlanValidationRepairPrompt(result) };
451
- }
452
-
453
- function buildNextStageDetails(root, taskDir, relPlanPath, stageName) {
454
- const stagePath = projectRelative(root, internalStageFile(taskDir, stageName));
455
- return {
456
- stage: stageName,
457
- path: stagePath,
458
- prompt: buildPlanValidationNextStagePrompt(relPlanPath, stageName, stagePath),
459
- };
460
- }
461
-
462
- export function resolvePlanValidationStage(stageValue) {
463
- const value = String(stageValue ?? '').trim();
464
- if (!value) return undefined;
465
- const matches = PLAN_INTERNAL_STAGE_DIRECTORIES.filter((stage) => stage === value || stage.startsWith(value));
466
- return matches.length === 1 ? matches[0] : undefined;
467
- }
468
-
469
- function buildPlanValidationSummary(selectedStage, stagesForFullValidation = PLAN_STAGE_DIRECTORIES) {
470
- const stages = selectedStage ? [selectedStage] : stagesForFullValidation;
471
- return {
472
- stages: { total: stages.length, present: 0, valid: 0, ...(selectedStage ? { selected: selectedStage } : {}) },
473
- requiredSections: { total: stages.flatMap((stage) => PLAN_REQUIRED_HEADERS[stage] ?? []).length, present: 0, valid: 0 },
474
- splitFiles: { detected: false },
475
- };
476
- }
477
-
478
- export function validatePlanArtifact(planPath, options = {}) {
479
- const root = resolve(options.root ?? process.cwd());
480
- const plan = resolvePlanReadme(root, planPath);
481
- const { absolutePlanPath, taskDir, taskSlug, relPlanPath } = plan;
482
- const selectedStage = resolvePlanValidationStage(options.stage);
483
- const blockers = [];
484
- const warnings = [];
485
-
486
- if (!existsSync(absolutePlanPath)) {
487
- addBlocker(blockers, 'missing-plan', `Plan file does not exist: ${projectRelative(root, absolutePlanPath)}`, { path: projectRelative(root, absolutePlanPath) });
488
- return withPlanValidationRepairPrompt({ ok: false, planPath: projectRelative(root, absolutePlanPath), blockers, warnings, summary: buildPlanValidationSummary(selectedStage) });
489
- }
490
-
491
- const expectedPrefix = plan.expectedPath;
492
- if (relPlanPath !== expectedPrefix) {
493
- addBlocker(blockers, 'invalid-plan-path', `Plan file must be docs/plan/<task-slug>/README.md: ${relPlanPath}`, { path: relPlanPath });
494
- }
495
- if (!isKebabCase(taskSlug)) {
496
- addBlocker(blockers, 'invalid-task-slug', `Plan task directory must be kebab-case: ${taskSlug}`, { path: projectRelative(root, taskDir) });
497
- }
498
- if (!contentHasValue(readFileSync(absolutePlanPath, 'utf8'))) {
499
- addBlocker(blockers, 'empty-plan', `Plan README is empty: ${relPlanPath}`, { path: relPlanPath });
500
- }
501
-
502
- const stagesForFullValidation = PLAN_INTERNAL_STAGE_DIRECTORIES;
503
- const summary = buildPlanValidationSummary(selectedStage, stagesForFullValidation);
504
- const stagesToValidate = selectedStage ? [selectedStage] : stagesForFullValidation;
505
- const durableFiles = discoverDurablePlanMarkdownFiles(taskDir);
506
- if (durableFiles.length > 1) summary.splitFiles.detected = true;
507
- for (const stageName of stagesToValidate) {
508
- const internal = validateInternalStageState(root, taskDir, stageName, blockers);
509
- summary.stages.present += internal.state ? 1 : 0;
510
- let stageValid = internal.valid;
511
- const files = internal.state ? [internal.state.file] : [];
512
- const fallbackPath = projectRelative(root, internalStageFile(taskDir, stageName));
513
- for (const header of PLAN_REQUIRED_HEADERS[stageName]) {
514
- const found = files.length > 0 ? findSection(files, header) : undefined;
515
- if (!found) {
516
- stageValid = false;
517
- addBlocker(blockers, 'missing-section', `Missing required section in internal checkpoint: ## ${header}`, { path: fallbackPath, stage: stageName, section: header });
518
- continue;
519
- }
520
- summary.requiredSections.present += 1;
521
- const relFound = projectRelative(root, found.file);
522
- if (!contentHasValue(found.content)) {
523
- stageValid = false;
524
- addBlocker(blockers, 'empty-section', `Required checkpoint section has no content: ## ${header}`, { path: relFound, stage: stageName, section: header });
525
- continue;
526
- }
527
- summary.requiredSections.valid += 1;
528
-
529
- if (header === 'Atomic Tasks') {
530
- if (!sectionContainsField(found.content, ['Acceptance', 'Acceptance criteria'])) {
531
- stageValid = false;
532
- addBlocker(blockers, 'missing-atomic-acceptance', 'Atomic Tasks must define acceptance criteria.', { path: relFound, stage: stageName, section: header });
533
- }
534
- if (!sectionContainsField(found.content, ['Verification'])) {
535
- stageValid = false;
536
- addBlocker(blockers, 'missing-atomic-verification', 'Atomic Tasks must define verification.', { path: relFound, stage: stageName, section: header });
537
- }
538
- }
539
- if (header === 'Discussion Queue' && hasUnresolvedStructuredItem(found.content)) {
540
- stageValid = false;
541
- addBlocker(blockers, 'unresolved-discussion', 'Discussion Queue has unresolved items.', { path: relFound, stage: stageName, section: header });
542
- }
543
- if (header === 'Assumptions' && hasUnresolvedStructuredItem(found.content)) {
544
- stageValid = false;
545
- addBlocker(blockers, 'unresolved-assumption', 'Assumptions contain unresolved items.', { path: relFound, stage: stageName, section: header });
546
- }
547
- }
548
- if (files.length > 0 && !validateWorkstreamHandoffContract(root, files, stageName, blockers)) stageValid = false;
549
- if (files.length > 0 && !validateConstructionChecklist(root, files, stageName, blockers)) stageValid = false;
550
- if (stageValid) summary.stages.valid += 1;
551
- }
552
-
553
- const result = { ok: blockers.length === 0, planPath: relPlanPath, blockers, warnings, summary, ...(selectedStage ? { stage: selectedStage } : {}) };
554
- if (result.ok && selectedStage) {
555
- const stageIndex = PLAN_INTERNAL_STAGE_DIRECTORIES.indexOf(selectedStage);
556
- const nextStageName = stageIndex >= 0 ? PLAN_INTERNAL_STAGE_DIRECTORIES[stageIndex + 1] : undefined;
557
- if (nextStageName) result.nextStage = buildNextStageDetails(root, taskDir, relPlanPath, nextStageName);
558
- }
559
- return withPlanValidationRepairPrompt(result);
560
- }
561
-
562
- export function formatPlanValidationText(result) {
563
- const stageSuffix = result.stage ? ` (${result.stage})` : '';
564
- if (result.ok) {
565
- const lines = [`✅ Plan validation passed: ${result.planPath}${stageSuffix}`, `Stages: ${result.summary.stages.valid}/${result.summary.stages.total}`, `Required sections: ${result.summary.requiredSections.valid}/${result.summary.requiredSections.total}`];
566
- if (result.nextStage?.prompt) lines.push('', 'Next stage:', result.nextStage.prompt);
567
- return lines.join('\n');
568
- }
569
- const lines = [`❌ Plan validation failed: ${result.planPath}${stageSuffix}`, '', 'Blockers:'];
570
- for (const blocker of result.blockers) {
571
- lines.push(`- ${blocker.message}`);
572
- }
573
- if (result.repairPrompt) {
574
- lines.push('', 'Agent prompt:', result.repairPrompt);
575
- }
576
- lines.push('', 'Next:', '- refine the plan before execution');
577
- return lines.join('\n');
578
- }
579
-
580
- function runPlanValidate(args) {
581
- const [planPath, ...rest] = args;
582
- if (!planPath || planPath.startsWith('-')) {
583
- console.error('Missing plan path.');
584
- console.error(commandUsage('plan validate'));
585
- process.exit(2);
586
- }
587
- const json = rest.includes('--json');
588
- let stage;
589
- let stageProvided = false;
590
- for (let index = 0; index < rest.length; index += 1) {
591
- const arg = rest[index];
592
- if (arg === '--json') continue;
593
- if (arg === '--stage') {
594
- stageProvided = true;
595
- stage = rest[index + 1];
596
- if (!stage || stage.startsWith('-')) {
597
- console.error('Missing plan validation stage.');
598
- console.error(commandUsage('plan validate'));
599
- process.exit(2);
600
- }
601
- index += 1;
602
- continue;
603
- }
604
- console.error(`Unknown plan validate option: ${arg}`);
605
- console.error(commandUsage('plan validate'));
606
- process.exit(2);
607
- }
608
- if (stageProvided && !resolvePlanValidationStage(stage)) {
609
- console.error(`Invalid plan validation stage: ${stage}`);
610
- console.error(commandUsage('plan validate'));
611
- process.exit(2);
612
- }
613
- const result = validatePlanArtifact(planPath, { stage });
614
- if (json) console.log(JSON.stringify(result, null, 2));
615
- else console.log(formatPlanValidationText(result));
616
- process.exit(result.ok ? 0 : 1);
617
- }
618
-
619
- function runPlanStage(args) {
620
- const [subcommand, stage, ...rest] = args;
621
- if (subcommand !== 'create') {
622
- console.error(`Unknown plan stage command: ${subcommand ?? ''}`.trim());
623
- console.error(commandUsage('plan stage'));
624
- process.exit(2);
625
- }
626
- if (!stage || stage.startsWith('-')) {
627
- console.error('Missing plan stage.');
628
- console.error(commandUsage('plan stage create'));
629
- process.exit(2);
630
- }
631
- let json = false;
632
- let planPath;
633
- for (const arg of rest) {
634
- if (arg === '--json') {
635
- json = true;
636
- continue;
637
- }
638
- if (arg.startsWith('-')) {
639
- console.error(`Unknown plan stage create option: ${arg}`);
640
- console.error(commandUsage('plan stage create'));
641
- process.exit(2);
642
- }
643
- if (planPath) {
644
- console.error(`Unexpected plan stage create argument: ${arg}`);
645
- console.error(commandUsage('plan stage create'));
646
- process.exit(2);
647
- }
648
- planPath = arg;
649
- }
650
- try {
651
- const result = createPlanStageCheckpoint(stage, { planPath });
652
- if (json) console.log(JSON.stringify(result, null, 2));
653
- else console.log(`Created plan stage checkpoint: ${result.path}`);
654
- process.exit(0);
655
- } catch (error) {
656
- console.error(error instanceof Error ? error.message : String(error));
657
- console.error(commandUsage('plan stage create'));
658
- process.exit(2);
659
- }
660
- }
661
-
662
- export function runPlan(args) {
663
- const [subcommand, ...rest] = args;
664
- if (subcommand === 'validate') runPlanValidate(rest);
665
- if (subcommand === 'stage') runPlanStage(rest);
666
- console.error(`Unknown plan command: ${subcommand ?? ''}`.trim());
667
- console.error(commandUsage('plan'));
668
- process.exit(2);
669
- }