@ktpartners/dgs-platform 2.6.2
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/LICENSE +38 -0
- package/README.md +851 -0
- package/agents/dgs-codebase-cross-analyzer.md +183 -0
- package/agents/dgs-codebase-mapper.md +782 -0
- package/agents/dgs-codebase-synthesizer.md +156 -0
- package/agents/dgs-debugger.md +1256 -0
- package/agents/dgs-executor.md +550 -0
- package/agents/dgs-integration-checker.md +481 -0
- package/agents/dgs-nyquist-auditor.md +178 -0
- package/agents/dgs-phase-researcher.md +563 -0
- package/agents/dgs-phase-verifier.md +450 -0
- package/agents/dgs-plan-checker.md +708 -0
- package/agents/dgs-planner.md +1324 -0
- package/agents/dgs-project-researcher.md +631 -0
- package/agents/dgs-research-synthesizer.md +249 -0
- package/agents/dgs-roadmapper.md +652 -0
- package/agents/dgs-verifier.md +607 -0
- package/bin/install.js +2073 -0
- package/commands/dgs/add-doc.md +45 -0
- package/commands/dgs/add-idea.md +38 -0
- package/commands/dgs/add-phase.md +43 -0
- package/commands/dgs/add-repo.md +54 -0
- package/commands/dgs/add-tests.md +41 -0
- package/commands/dgs/add-todo.md +47 -0
- package/commands/dgs/approve-spec.md +38 -0
- package/commands/dgs/audit-milestone.md +36 -0
- package/commands/dgs/audit-phase.md +37 -0
- package/commands/dgs/cancel-job.md +23 -0
- package/commands/dgs/capture-principle.md +143 -0
- package/commands/dgs/check-todos.md +45 -0
- package/commands/dgs/cleanup.md +18 -0
- package/commands/dgs/complete-milestone.md +136 -0
- package/commands/dgs/complete-project.md +70 -0
- package/commands/dgs/consolidate-ideas.md +50 -0
- package/commands/dgs/create-milestone-job.md +37 -0
- package/commands/dgs/debug.md +164 -0
- package/commands/dgs/develop-idea.md +53 -0
- package/commands/dgs/discuss-idea.md +41 -0
- package/commands/dgs/discuss-phase.md +83 -0
- package/commands/dgs/execute-phase.md +41 -0
- package/commands/dgs/fast.md +38 -0
- package/commands/dgs/find-related-ideas.md +43 -0
- package/commands/dgs/health.md +28 -0
- package/commands/dgs/help.md +22 -0
- package/commands/dgs/import-spec.md +36 -0
- package/commands/dgs/init-product.md +28 -0
- package/commands/dgs/insert-phase.md +32 -0
- package/commands/dgs/join-discord.md +18 -0
- package/commands/dgs/list-docs.md +40 -0
- package/commands/dgs/list-ideas.md +42 -0
- package/commands/dgs/list-jobs.md +22 -0
- package/commands/dgs/list-phase-assumptions.md +46 -0
- package/commands/dgs/list-projects.md +57 -0
- package/commands/dgs/list-specs.md +40 -0
- package/commands/dgs/map-codebase.md +92 -0
- package/commands/dgs/new-milestone.md +44 -0
- package/commands/dgs/new-project.md +42 -0
- package/commands/dgs/node-repair.md +26 -0
- package/commands/dgs/overlap-check.md +20 -0
- package/commands/dgs/pause-work.md +38 -0
- package/commands/dgs/plan-milestone-gaps.md +34 -0
- package/commands/dgs/plan-phase.md +44 -0
- package/commands/dgs/progress.md +24 -0
- package/commands/dgs/quick.md +41 -0
- package/commands/dgs/reactivate-project.md +70 -0
- package/commands/dgs/reapply-patches.md +110 -0
- package/commands/dgs/refine-spec.md +38 -0
- package/commands/dgs/reject-idea.md +43 -0
- package/commands/dgs/remove-doc.md +44 -0
- package/commands/dgs/remove-phase.md +31 -0
- package/commands/dgs/remove-repo.md +69 -0
- package/commands/dgs/research-idea.md +43 -0
- package/commands/dgs/research-phase.md +189 -0
- package/commands/dgs/restore-idea.md +45 -0
- package/commands/dgs/resume-work.md +40 -0
- package/commands/dgs/rollback-job.md +24 -0
- package/commands/dgs/run-job.md +35 -0
- package/commands/dgs/search.md +40 -0
- package/commands/dgs/set-profile.md +34 -0
- package/commands/dgs/settings.md +38 -0
- package/commands/dgs/switch-project.md +58 -0
- package/commands/dgs/undo-consolidation.md +42 -0
- package/commands/dgs/update-idea.md +44 -0
- package/commands/dgs/update.md +37 -0
- package/commands/dgs/validate-phase.md +35 -0
- package/commands/dgs/verify-work.md +39 -0
- package/commands/dgs/write-spec.md +49 -0
- package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-01-SUMMARY.md +84 -0
- package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-02-SUMMARY.md +86 -0
- package/deliver-great-systems/.planning/phases/10-v1-to-v2-migration-flow/10-01-SUMMARY.md +85 -0
- package/deliver-great-systems/bin/dgs-tools.cjs +1444 -0
- package/deliver-great-systems/bin/lib/auto-test.cjs +1365 -0
- package/deliver-great-systems/bin/lib/commands.cjs +570 -0
- package/deliver-great-systems/bin/lib/config.cjs +417 -0
- package/deliver-great-systems/bin/lib/conflict-agent.cjs +1063 -0
- package/deliver-great-systems/bin/lib/conflict-agent.test.cjs +554 -0
- package/deliver-great-systems/bin/lib/context.cjs +929 -0
- package/deliver-great-systems/bin/lib/context.test.cjs +693 -0
- package/deliver-great-systems/bin/lib/core.cjs +744 -0
- package/deliver-great-systems/bin/lib/core.test.cjs +822 -0
- package/deliver-great-systems/bin/lib/docs.cjs +919 -0
- package/deliver-great-systems/bin/lib/docs.test.cjs +211 -0
- package/deliver-great-systems/bin/lib/execution.cjs +705 -0
- package/deliver-great-systems/bin/lib/execution.test.cjs +1472 -0
- package/deliver-great-systems/bin/lib/frontmatter.cjs +324 -0
- package/deliver-great-systems/bin/lib/ideas.cjs +1406 -0
- package/deliver-great-systems/bin/lib/ideas.test.cjs +1417 -0
- package/deliver-great-systems/bin/lib/identity.cjs +125 -0
- package/deliver-great-systems/bin/lib/init.cjs +1114 -0
- package/deliver-great-systems/bin/lib/init.test.cjs +1271 -0
- package/deliver-great-systems/bin/lib/jobs.cjs +2015 -0
- package/deliver-great-systems/bin/lib/jobs.test.cjs +2619 -0
- package/deliver-great-systems/bin/lib/merge-conflicts.cjs +654 -0
- package/deliver-great-systems/bin/lib/merge-conflicts.test.cjs +370 -0
- package/deliver-great-systems/bin/lib/migration.cjs +352 -0
- package/deliver-great-systems/bin/lib/migration.test.cjs +582 -0
- package/deliver-great-systems/bin/lib/milestone.cjs +243 -0
- package/deliver-great-systems/bin/lib/overlap.cjs +437 -0
- package/deliver-great-systems/bin/lib/overlap.test.cjs +747 -0
- package/deliver-great-systems/bin/lib/path-audit.test.cjs +384 -0
- package/deliver-great-systems/bin/lib/paths.cjs +144 -0
- package/deliver-great-systems/bin/lib/paths.test.cjs +486 -0
- package/deliver-great-systems/bin/lib/phase.cjs +910 -0
- package/deliver-great-systems/bin/lib/projects.cjs +691 -0
- package/deliver-great-systems/bin/lib/projects.test.cjs +871 -0
- package/deliver-great-systems/bin/lib/repos.cjs +1432 -0
- package/deliver-great-systems/bin/lib/repos.test.cjs +1882 -0
- package/deliver-great-systems/bin/lib/roadmap.cjs +305 -0
- package/deliver-great-systems/bin/lib/search.cjs +570 -0
- package/deliver-great-systems/bin/lib/specs.cjs +1303 -0
- package/deliver-great-systems/bin/lib/state.cjs +893 -0
- package/deliver-great-systems/bin/lib/template.cjs +228 -0
- package/deliver-great-systems/bin/lib/test-helpers.cjs +291 -0
- package/deliver-great-systems/bin/lib/verify.cjs +796 -0
- package/deliver-great-systems/references/checkpoints.md +776 -0
- package/deliver-great-systems/references/conflict-resolution.md +66 -0
- package/deliver-great-systems/references/context-tiers.md +166 -0
- package/deliver-great-systems/references/continuation-format.md +249 -0
- package/deliver-great-systems/references/decimal-phase-calculation.md +67 -0
- package/deliver-great-systems/references/git-integration.md +250 -0
- package/deliver-great-systems/references/git-planning-commit.md +40 -0
- package/deliver-great-systems/references/model-profile-resolution.md +36 -0
- package/deliver-great-systems/references/model-profiles.md +95 -0
- package/deliver-great-systems/references/phase-argument-parsing.md +61 -0
- package/deliver-great-systems/references/planning-config.md +224 -0
- package/deliver-great-systems/references/questioning.md +162 -0
- package/deliver-great-systems/references/spec-review-loop.md +177 -0
- package/deliver-great-systems/references/tdd.md +265 -0
- package/deliver-great-systems/references/ui-brand.md +160 -0
- package/deliver-great-systems/references/verification-patterns.md +612 -0
- package/deliver-great-systems/templates/DEBUG.md +166 -0
- package/deliver-great-systems/templates/UAT.md +251 -0
- package/deliver-great-systems/templates/VALIDATION.md +95 -0
- package/deliver-great-systems/templates/claude-md.md +74 -0
- package/deliver-great-systems/templates/codebase/architecture.md +257 -0
- package/deliver-great-systems/templates/codebase/concerns.md +312 -0
- package/deliver-great-systems/templates/codebase/conventions.md +309 -0
- package/deliver-great-systems/templates/codebase/integrations.md +282 -0
- package/deliver-great-systems/templates/codebase/stack.md +188 -0
- package/deliver-great-systems/templates/codebase/structure.md +287 -0
- package/deliver-great-systems/templates/codebase/testing.md +482 -0
- package/deliver-great-systems/templates/config.json +38 -0
- package/deliver-great-systems/templates/context.md +354 -0
- package/deliver-great-systems/templates/continue-here.md +80 -0
- package/deliver-great-systems/templates/debug-subagent-prompt.md +93 -0
- package/deliver-great-systems/templates/discovery.md +148 -0
- package/deliver-great-systems/templates/milestone-archive.md +125 -0
- package/deliver-great-systems/templates/milestone.md +117 -0
- package/deliver-great-systems/templates/phase-prompt.md +615 -0
- package/deliver-great-systems/templates/planner-subagent-prompt.md +119 -0
- package/deliver-great-systems/templates/project.md +186 -0
- package/deliver-great-systems/templates/requirements.md +233 -0
- package/deliver-great-systems/templates/research-project/ARCHITECTURE.md +206 -0
- package/deliver-great-systems/templates/research-project/FEATURES.md +149 -0
- package/deliver-great-systems/templates/research-project/PITFALLS.md +202 -0
- package/deliver-great-systems/templates/research-project/STACK.md +122 -0
- package/deliver-great-systems/templates/research-project/SUMMARY.md +172 -0
- package/deliver-great-systems/templates/research.md +554 -0
- package/deliver-great-systems/templates/retrospective.md +54 -0
- package/deliver-great-systems/templates/roadmap.md +204 -0
- package/deliver-great-systems/templates/state.md +178 -0
- package/deliver-great-systems/templates/summary-complex.md +59 -0
- package/deliver-great-systems/templates/summary-minimal.md +41 -0
- package/deliver-great-systems/templates/summary-standard.md +48 -0
- package/deliver-great-systems/templates/summary.md +253 -0
- package/deliver-great-systems/templates/user-setup.md +313 -0
- package/deliver-great-systems/templates/verification-report.md +324 -0
- package/deliver-great-systems/workflows/add-doc.md +151 -0
- package/deliver-great-systems/workflows/add-idea.md +96 -0
- package/deliver-great-systems/workflows/add-phase.md +120 -0
- package/deliver-great-systems/workflows/add-tests.md +359 -0
- package/deliver-great-systems/workflows/add-todo.md +162 -0
- package/deliver-great-systems/workflows/approve-spec.md +194 -0
- package/deliver-great-systems/workflows/audit-milestone.md +364 -0
- package/deliver-great-systems/workflows/audit-phase.md +462 -0
- package/deliver-great-systems/workflows/cancel-job.md +108 -0
- package/deliver-great-systems/workflows/check-todos.md +181 -0
- package/deliver-great-systems/workflows/cleanup.md +247 -0
- package/deliver-great-systems/workflows/codereview.md +526 -0
- package/deliver-great-systems/workflows/complete-milestone.md +1298 -0
- package/deliver-great-systems/workflows/consolidate-ideas.md +365 -0
- package/deliver-great-systems/workflows/create-milestone-job.md +177 -0
- package/deliver-great-systems/workflows/develop-idea.md +544 -0
- package/deliver-great-systems/workflows/diagnose-issues.md +231 -0
- package/deliver-great-systems/workflows/discovery-phase.md +301 -0
- package/deliver-great-systems/workflows/discuss-idea.md +263 -0
- package/deliver-great-systems/workflows/discuss-phase.md +733 -0
- package/deliver-great-systems/workflows/execute-phase.md +571 -0
- package/deliver-great-systems/workflows/execute-plan.md +592 -0
- package/deliver-great-systems/workflows/find-related-ideas.md +271 -0
- package/deliver-great-systems/workflows/health.md +173 -0
- package/deliver-great-systems/workflows/help.md +997 -0
- package/deliver-great-systems/workflows/import-spec.md +381 -0
- package/deliver-great-systems/workflows/init-product.md +767 -0
- package/deliver-great-systems/workflows/insert-phase.md +138 -0
- package/deliver-great-systems/workflows/list-docs.md +119 -0
- package/deliver-great-systems/workflows/list-ideas.md +154 -0
- package/deliver-great-systems/workflows/list-jobs.md +89 -0
- package/deliver-great-systems/workflows/list-phase-assumptions.md +192 -0
- package/deliver-great-systems/workflows/list-specs.md +101 -0
- package/deliver-great-systems/workflows/map-codebase.md +621 -0
- package/deliver-great-systems/workflows/new-milestone.md +591 -0
- package/deliver-great-systems/workflows/new-project.md +1113 -0
- package/deliver-great-systems/workflows/node-repair.md +94 -0
- package/deliver-great-systems/workflows/overlap-check.md +86 -0
- package/deliver-great-systems/workflows/pause-work.md +134 -0
- package/deliver-great-systems/workflows/plan-milestone-gaps.md +306 -0
- package/deliver-great-systems/workflows/plan-phase.md +698 -0
- package/deliver-great-systems/workflows/progress.md +386 -0
- package/deliver-great-systems/workflows/quick.md +845 -0
- package/deliver-great-systems/workflows/refine-spec.md +275 -0
- package/deliver-great-systems/workflows/reject-idea.md +109 -0
- package/deliver-great-systems/workflows/remove-doc.md +117 -0
- package/deliver-great-systems/workflows/remove-phase.md +163 -0
- package/deliver-great-systems/workflows/research-idea.md +325 -0
- package/deliver-great-systems/workflows/research-phase.md +81 -0
- package/deliver-great-systems/workflows/restore-idea.md +101 -0
- package/deliver-great-systems/workflows/resume-project.md +311 -0
- package/deliver-great-systems/workflows/rollback-job.md +130 -0
- package/deliver-great-systems/workflows/run-job.md +498 -0
- package/deliver-great-systems/workflows/search.md +130 -0
- package/deliver-great-systems/workflows/set-profile.md +83 -0
- package/deliver-great-systems/workflows/settings.md +470 -0
- package/deliver-great-systems/workflows/transition.md +563 -0
- package/deliver-great-systems/workflows/undo-consolidation.md +155 -0
- package/deliver-great-systems/workflows/update-idea.md +157 -0
- package/deliver-great-systems/workflows/update.md +242 -0
- package/deliver-great-systems/workflows/validate-phase.md +177 -0
- package/deliver-great-systems/workflows/verify-phase.md +253 -0
- package/deliver-great-systems/workflows/verify-work.md +671 -0
- package/deliver-great-systems/workflows/write-spec.md +450 -0
- package/hooks/dist/dgs-check-update.js +62 -0
- package/hooks/dist/dgs-context-monitor.js +141 -0
- package/hooks/dist/dgs-statusline.js +115 -0
- package/package.json +60 -0
- package/scripts/build-hooks.js +43 -0
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Specs — Structured PRD spec CRUD operations and lifecycle management
|
|
3
|
+
*
|
|
4
|
+
* Provides the data model and operations for the spec lifecycle:
|
|
5
|
+
* create, list, update-status, append-review-history, and finalize.
|
|
6
|
+
* Specs are stored as markdown files with YAML frontmatter in
|
|
7
|
+
* .planning/specs/ (flat directory, no subdirectories).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { safeReadFile, execGit, generateSlugInternal, output, error } = require('./core.cjs');
|
|
13
|
+
const { getPlanningRoot } = require('./paths.cjs');
|
|
14
|
+
const { extractNameFromAuthor } = require('./identity.cjs');
|
|
15
|
+
|
|
16
|
+
// ─── Directory Helpers ────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const SPEC_STATUSES = ['draft', 'review', 'final'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize spec status to two-state machine (draft/final).
|
|
22
|
+
* Maps deprecated 'review' and 'in-review' to 'draft'.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} status - Raw status value
|
|
25
|
+
* @returns {string} Normalized status ('draft' or 'final')
|
|
26
|
+
*/
|
|
27
|
+
function normalizeStatus(status) {
|
|
28
|
+
if (status === 'final') return 'final';
|
|
29
|
+
if (status === 'draft') return 'draft';
|
|
30
|
+
if (status === 'review' || status === 'in-review') return 'draft';
|
|
31
|
+
return 'draft';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ensure .planning/specs/ directory exists.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} cwd - Working directory
|
|
38
|
+
*/
|
|
39
|
+
function ensureSpecsDir(cwd) {
|
|
40
|
+
fs.mkdirSync(path.join(getPlanningRoot(cwd), 'specs'), { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Milestone State ─────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the set of shipped milestone versions from MILESTONES.md.
|
|
47
|
+
* Parses headings of the form: ## vX.Y Name (Shipped: DATE)
|
|
48
|
+
*
|
|
49
|
+
* @param {string} cwd - Working directory
|
|
50
|
+
* @returns {Set<string>} Set of shipped milestone version strings (e.g., "v12.0")
|
|
51
|
+
*/
|
|
52
|
+
function getShippedMilestones(cwd) {
|
|
53
|
+
const milestonesPath = path.join(getPlanningRoot(cwd), 'MILESTONES.md');
|
|
54
|
+
const content = safeReadFile(milestonesPath);
|
|
55
|
+
if (!content) return new Set();
|
|
56
|
+
|
|
57
|
+
const shipped = new Set();
|
|
58
|
+
const pattern = /^## (v\d+\.\d+)\s+.*\(Shipped:/gm;
|
|
59
|
+
let match;
|
|
60
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
61
|
+
shipped.add(match[1]);
|
|
62
|
+
}
|
|
63
|
+
return shipped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Frontmatter Parsing ──────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse YAML frontmatter from spec file content.
|
|
70
|
+
* Uses simple regex parsing (no YAML library -- zero-dependency project).
|
|
71
|
+
*
|
|
72
|
+
* @param {string} content - File content
|
|
73
|
+
* @returns {{ frontmatter: object, body: string }}
|
|
74
|
+
*/
|
|
75
|
+
function parseSpecFrontmatter(content) {
|
|
76
|
+
const match = content.match(/^---\n([\s\S]+?)\n---\n?([\s\S]*)/);
|
|
77
|
+
if (!match) return { frontmatter: {}, body: content };
|
|
78
|
+
|
|
79
|
+
const yaml = match[1];
|
|
80
|
+
const body = match[2] || '';
|
|
81
|
+
const frontmatter = {};
|
|
82
|
+
|
|
83
|
+
const lines = yaml.split('\n');
|
|
84
|
+
let currentArrayKey = null;
|
|
85
|
+
let currentArray = [];
|
|
86
|
+
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
if (line.trim() === '') continue;
|
|
89
|
+
|
|
90
|
+
// Handle multi-line array items (indented with -)
|
|
91
|
+
if (currentArrayKey && /^\s+-\s+/.test(line)) {
|
|
92
|
+
const value = line.replace(/^\s+-\s+/, '').replace(/^["']|["']$/g, '');
|
|
93
|
+
currentArray.push(value);
|
|
94
|
+
continue;
|
|
95
|
+
} else if (currentArrayKey) {
|
|
96
|
+
// End of multi-line array
|
|
97
|
+
frontmatter[currentArrayKey] = currentArray;
|
|
98
|
+
currentArrayKey = null;
|
|
99
|
+
currentArray = [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Handle inline array values (e.g., source_ideas: ["a", "b"])
|
|
103
|
+
const arrayMatch = line.match(/^([\w_]+):\s*\[([^\]]*)\]/);
|
|
104
|
+
if (arrayMatch) {
|
|
105
|
+
const key = arrayMatch[1];
|
|
106
|
+
const values = arrayMatch[2]
|
|
107
|
+
.split(',')
|
|
108
|
+
.map(v => v.trim().replace(/^["']|["']$/g, ''))
|
|
109
|
+
.filter(v => v !== '');
|
|
110
|
+
frontmatter[key] = values;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Handle array start (key followed by nothing or empty)
|
|
115
|
+
const arrayStartMatch = line.match(/^([\w_]+):\s*$/);
|
|
116
|
+
if (arrayStartMatch) {
|
|
117
|
+
currentArrayKey = arrayStartMatch[1];
|
|
118
|
+
currentArray = [];
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Handle simple key: value
|
|
123
|
+
const kvMatch = line.match(/^([\w_]+):\s*(.*)/);
|
|
124
|
+
if (kvMatch) {
|
|
125
|
+
const key = kvMatch[1];
|
|
126
|
+
let value = kvMatch[2].trim();
|
|
127
|
+
// Remove quotes
|
|
128
|
+
value = value.replace(/^["']|["']$/g, '');
|
|
129
|
+
frontmatter[key] = value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Flush any remaining array
|
|
134
|
+
if (currentArrayKey) {
|
|
135
|
+
frontmatter[currentArrayKey] = currentArray;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { frontmatter, body };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Dual-Format Support ─────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Detect the frontmatter format of spec file content.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} content - File content
|
|
147
|
+
* @returns {'yaml' | 'bold-key' | 'none'}
|
|
148
|
+
*/
|
|
149
|
+
function detectSpecFormat(content) {
|
|
150
|
+
if (!content) return 'none';
|
|
151
|
+
|
|
152
|
+
// YAML format: starts with ---\n
|
|
153
|
+
if (/^---\n/.test(content)) return 'yaml';
|
|
154
|
+
|
|
155
|
+
// Bold-key format: has **Key:** pattern before any --- delimiter or ## section heading
|
|
156
|
+
const lines = content.split('\n');
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
// Stop at --- horizontal rule
|
|
159
|
+
if (/^---\s*$/.test(line)) break;
|
|
160
|
+
// Stop at ## section heading
|
|
161
|
+
if (/^## /.test(line)) break;
|
|
162
|
+
// Found a bold-key line
|
|
163
|
+
if (/^\*\*[^*]+:\*\*/.test(line)) return 'bold-key';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return 'none';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Parse bold-key frontmatter from spec file content.
|
|
171
|
+
* Extracts **Key:** Value pairs from the top of the file.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} content - File content
|
|
174
|
+
* @returns {{ frontmatter: object, body: string }}
|
|
175
|
+
*/
|
|
176
|
+
function parseBoldKeyFrontmatter(content) {
|
|
177
|
+
if (!content) return { frontmatter: {}, body: content || '' };
|
|
178
|
+
|
|
179
|
+
const lines = content.split('\n');
|
|
180
|
+
const frontmatter = {};
|
|
181
|
+
let bodyStartIndex = 0;
|
|
182
|
+
|
|
183
|
+
// Bold-key to frontmatter key mapping
|
|
184
|
+
const keyMap = {
|
|
185
|
+
'Status': 'status',
|
|
186
|
+
'Author': 'author',
|
|
187
|
+
'Date': 'created',
|
|
188
|
+
'Updated': 'updated',
|
|
189
|
+
'Version': 'version',
|
|
190
|
+
'Approved Date': 'approved_date',
|
|
191
|
+
'Milestones': 'milestones',
|
|
192
|
+
'Source Ideas': 'source_ideas',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
196
|
+
const line = lines[i];
|
|
197
|
+
|
|
198
|
+
// Extract title from # Title line
|
|
199
|
+
const titleMatch = line.match(/^# (.+)$/);
|
|
200
|
+
if (titleMatch && !frontmatter.title) {
|
|
201
|
+
frontmatter.title = titleMatch[1].trim();
|
|
202
|
+
bodyStartIndex = i + 1;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Stop at --- horizontal rule (body starts after it)
|
|
207
|
+
if (/^---\s*$/.test(line)) {
|
|
208
|
+
bodyStartIndex = i + 1;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Stop at ## section heading (body starts here)
|
|
213
|
+
if (/^## /.test(line)) {
|
|
214
|
+
bodyStartIndex = i;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Extract bold-key values
|
|
219
|
+
const boldKeyMatch = line.match(/^\*\*([^*]+):\*\*\s*(.*)/);
|
|
220
|
+
if (boldKeyMatch) {
|
|
221
|
+
const rawKey = boldKeyMatch[1].trim();
|
|
222
|
+
const rawValue = boldKeyMatch[2].trim();
|
|
223
|
+
const mappedKey = keyMap[rawKey];
|
|
224
|
+
|
|
225
|
+
if (mappedKey) {
|
|
226
|
+
if (mappedKey === 'status') {
|
|
227
|
+
// Lowercase the value: Draft -> draft, Final -> final
|
|
228
|
+
frontmatter[mappedKey] = rawValue.toLowerCase();
|
|
229
|
+
} else if (mappedKey === 'milestones' || mappedKey === 'source_ideas') {
|
|
230
|
+
// Split comma-separated values into array
|
|
231
|
+
frontmatter[mappedKey] = rawValue
|
|
232
|
+
.split(',')
|
|
233
|
+
.map(v => v.trim())
|
|
234
|
+
.filter(v => v !== '');
|
|
235
|
+
} else {
|
|
236
|
+
frontmatter[mappedKey] = rawValue;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
bodyStartIndex = i + 1;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Skip empty lines in the frontmatter area
|
|
245
|
+
if (line.trim() === '') {
|
|
246
|
+
bodyStartIndex = i + 1;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Non-matching non-empty line that isn't a title or bold-key -- stop
|
|
251
|
+
bodyStartIndex = i;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Generate id from title if not present
|
|
256
|
+
if (frontmatter.title && !frontmatter.id) {
|
|
257
|
+
const slug = generateSlugInternal(frontmatter.title);
|
|
258
|
+
frontmatter.id = `spec-${slug}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const body = lines.slice(bodyStartIndex).join('\n');
|
|
262
|
+
return { frontmatter, body };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Build spec file content in bold-key format from frontmatter object and body.
|
|
267
|
+
*
|
|
268
|
+
* @param {object} frontmatter - Frontmatter fields
|
|
269
|
+
* @param {string} body - Body content
|
|
270
|
+
* @returns {string} Full file content
|
|
271
|
+
*/
|
|
272
|
+
function buildBoldKeyContent(frontmatter, body) {
|
|
273
|
+
let content = '';
|
|
274
|
+
|
|
275
|
+
// Title first
|
|
276
|
+
if (frontmatter.title) {
|
|
277
|
+
content += `# ${frontmatter.title}\n\n`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Status (title-case: draft -> Draft, final -> Final)
|
|
281
|
+
if (frontmatter.status) {
|
|
282
|
+
const titleCased = frontmatter.status.charAt(0).toUpperCase() + frontmatter.status.slice(1);
|
|
283
|
+
content += `**Status:** ${titleCased}\n`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Author
|
|
287
|
+
if (frontmatter.author) {
|
|
288
|
+
content += `**Author:** ${frontmatter.author}\n`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Date (maps from 'created')
|
|
292
|
+
if (frontmatter.created) {
|
|
293
|
+
content += `**Date:** ${frontmatter.created}\n`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Updated
|
|
297
|
+
if (frontmatter.updated) {
|
|
298
|
+
content += `**Updated:** ${frontmatter.updated}\n`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Version
|
|
302
|
+
if (frontmatter.version) {
|
|
303
|
+
content += `**Version:** ${frontmatter.version}\n`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Approved Date
|
|
307
|
+
if (frontmatter.approved_date) {
|
|
308
|
+
content += `**Approved Date:** ${frontmatter.approved_date}\n`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Milestones (comma-separated)
|
|
312
|
+
if (frontmatter.milestones && frontmatter.milestones.length > 0) {
|
|
313
|
+
content += `**Milestones:** ${frontmatter.milestones.join(', ')}\n`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Source Ideas (comma-separated)
|
|
317
|
+
if (frontmatter.source_ideas && frontmatter.source_ideas.length > 0) {
|
|
318
|
+
content += `**Source Ideas:** ${frontmatter.source_ideas.join(', ')}\n`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Separator and body
|
|
322
|
+
content += '\n---\n';
|
|
323
|
+
|
|
324
|
+
if (body) {
|
|
325
|
+
const trimmedBody = body.replace(/^\n+/, '');
|
|
326
|
+
content += '\n' + trimmedBody;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return content;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Build spec file content from frontmatter object and body string.
|
|
334
|
+
*
|
|
335
|
+
* @param {object} frontmatter - Frontmatter fields
|
|
336
|
+
* @param {string} body - Body content
|
|
337
|
+
* @returns {string} Full file content
|
|
338
|
+
*/
|
|
339
|
+
function buildSpecContent(frontmatter, body) {
|
|
340
|
+
let yaml = '---\n';
|
|
341
|
+
yaml += `id: ${frontmatter.id}\n`;
|
|
342
|
+
yaml += `title: "${frontmatter.title}"\n`;
|
|
343
|
+
yaml += `status: ${frontmatter.status}\n`;
|
|
344
|
+
|
|
345
|
+
if (frontmatter.source_ideas && frontmatter.source_ideas.length > 0) {
|
|
346
|
+
yaml += 'source_ideas:\n';
|
|
347
|
+
for (const idea of frontmatter.source_ideas) {
|
|
348
|
+
yaml += ` - "${idea}"\n`;
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
yaml += 'source_ideas: []\n';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (frontmatter.source_document) {
|
|
355
|
+
yaml += `source_document: "${frontmatter.source_document}"\n`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
yaml += `created: ${frontmatter.created}\n`;
|
|
359
|
+
yaml += `updated: ${frontmatter.updated}\n`;
|
|
360
|
+
if (frontmatter.author) {
|
|
361
|
+
yaml += `author: "${frontmatter.author}"\n`;
|
|
362
|
+
}
|
|
363
|
+
if (frontmatter.updated_by) {
|
|
364
|
+
yaml += `updated_by: "${frontmatter.updated_by}"\n`;
|
|
365
|
+
}
|
|
366
|
+
if (frontmatter.version) {
|
|
367
|
+
yaml += `version: ${frontmatter.version}\n`;
|
|
368
|
+
}
|
|
369
|
+
if (frontmatter.approved_date) {
|
|
370
|
+
yaml += `approved_date: ${frontmatter.approved_date}\n`;
|
|
371
|
+
}
|
|
372
|
+
if (frontmatter.milestones && frontmatter.milestones.length > 0) {
|
|
373
|
+
yaml += 'milestones:\n';
|
|
374
|
+
for (const m of frontmatter.milestones) {
|
|
375
|
+
yaml += ` - "${m}"\n`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
yaml += '---\n';
|
|
379
|
+
|
|
380
|
+
if (body) {
|
|
381
|
+
// Ensure body starts with newline for clean separation
|
|
382
|
+
const trimmedBody = body.replace(/^\n+/, '');
|
|
383
|
+
yaml += '\n' + trimmedBody;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return yaml;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Find Spec ────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Find a spec file by id or filename.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} cwd - Working directory
|
|
395
|
+
* @param {string} idOrFilename - Spec id (e.g., "spec-review-config") or filename
|
|
396
|
+
* @returns {{ filename: string, path: string, frontmatter: object, body: string } | null}
|
|
397
|
+
*/
|
|
398
|
+
function findSpecFile(cwd, idOrFilename) {
|
|
399
|
+
if (!idOrFilename) return null;
|
|
400
|
+
|
|
401
|
+
const specsDir = path.join(getPlanningRoot(cwd), 'specs');
|
|
402
|
+
let files;
|
|
403
|
+
try {
|
|
404
|
+
files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Helper to normalize a parsed result before returning
|
|
410
|
+
function normalizeResult(file, frontmatter, body) {
|
|
411
|
+
frontmatter.status = normalizeStatus(frontmatter.status || 'draft');
|
|
412
|
+
if (!frontmatter.version) {
|
|
413
|
+
frontmatter.version = '0.1';
|
|
414
|
+
}
|
|
415
|
+
return { filename: file, path: path.join(specsDir, file), frontmatter, body };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// First pass: exact matches
|
|
419
|
+
const parsed = [];
|
|
420
|
+
for (const file of files) {
|
|
421
|
+
const content = safeReadFile(path.join(specsDir, file));
|
|
422
|
+
if (!content) continue;
|
|
423
|
+
|
|
424
|
+
// Dual-format support: detect format and use appropriate parser
|
|
425
|
+
const format = detectSpecFormat(content);
|
|
426
|
+
const { frontmatter, body } = format === 'bold-key'
|
|
427
|
+
? parseBoldKeyFrontmatter(content)
|
|
428
|
+
: parseSpecFrontmatter(content);
|
|
429
|
+
|
|
430
|
+
// Match by id
|
|
431
|
+
if (frontmatter.id === idOrFilename) {
|
|
432
|
+
return normalizeResult(file, frontmatter, body);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Match by exact filename
|
|
436
|
+
if (file === idOrFilename) {
|
|
437
|
+
return normalizeResult(file, frontmatter, body);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Match by filename without .md extension
|
|
441
|
+
if (file === idOrFilename + '.md') {
|
|
442
|
+
return normalizeResult(file, frontmatter, body);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
parsed.push({ file, frontmatter, body });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Second pass: substring matching on id and title (case-insensitive)
|
|
449
|
+
const searchLower = idOrFilename.toLowerCase();
|
|
450
|
+
for (const entry of parsed) {
|
|
451
|
+
const idLower = (entry.frontmatter.id || '').toLowerCase();
|
|
452
|
+
const titleLower = (entry.frontmatter.title || '').toLowerCase();
|
|
453
|
+
|
|
454
|
+
if (idLower.includes(searchLower) || titleLower.includes(searchLower)) {
|
|
455
|
+
return normalizeResult(entry.file, entry.frontmatter, entry.body);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── CRUD Operations ──────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Create a new spec file.
|
|
466
|
+
*
|
|
467
|
+
* @param {string} cwd - Working directory
|
|
468
|
+
* @param {object} options - { title, source_ideas: ["001-slug.md", ...], body }
|
|
469
|
+
* @param {boolean} raw - Raw output mode
|
|
470
|
+
*/
|
|
471
|
+
function cmdSpecsCreate(cwd, options, raw) {
|
|
472
|
+
if (!options || !options.title) {
|
|
473
|
+
error('title required for specs create');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
ensureSpecsDir(cwd);
|
|
477
|
+
|
|
478
|
+
const slug = generateSlugInternal(options.title);
|
|
479
|
+
const id = `spec-${slug}`;
|
|
480
|
+
const filename = `${id}.md`;
|
|
481
|
+
const planRoot = getPlanningRoot(cwd);
|
|
482
|
+
const planRootRel = path.relative(cwd, planRoot) || '.';
|
|
483
|
+
const filePath = path.join(planRoot, 'specs', filename);
|
|
484
|
+
const now = new Date().toISOString();
|
|
485
|
+
|
|
486
|
+
const sourceIdeas = options.source_ideas || [];
|
|
487
|
+
const sourceDocument = options.source_document || null;
|
|
488
|
+
|
|
489
|
+
const frontmatter = {
|
|
490
|
+
id,
|
|
491
|
+
title: options.title,
|
|
492
|
+
status: 'draft',
|
|
493
|
+
source_ideas: sourceIdeas,
|
|
494
|
+
created: now,
|
|
495
|
+
updated: now,
|
|
496
|
+
version: '1.0',
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
if (sourceDocument) {
|
|
500
|
+
frontmatter.source_document = sourceDocument;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (options.author) {
|
|
504
|
+
frontmatter.author = options.author;
|
|
505
|
+
frontmatter.updated_by = options.author;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const body = options.body || '';
|
|
509
|
+
const content = buildSpecContent(frontmatter, body);
|
|
510
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
511
|
+
|
|
512
|
+
const result = {
|
|
513
|
+
id,
|
|
514
|
+
filename,
|
|
515
|
+
path: path.join(planRootRel, 'specs', filename),
|
|
516
|
+
title: options.title,
|
|
517
|
+
status: 'draft',
|
|
518
|
+
source_ideas: sourceIdeas,
|
|
519
|
+
source_document: sourceDocument,
|
|
520
|
+
};
|
|
521
|
+
output(result, raw);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Derive aggregate implementation status from linked milestones.
|
|
526
|
+
* @param {string[]|undefined} milestones - Array of milestone version strings
|
|
527
|
+
* @param {Set<string>} shippedMilestones - Set of shipped milestone versions
|
|
528
|
+
* @returns {'none'|'in-progress'|'completed'}
|
|
529
|
+
*/
|
|
530
|
+
function deriveImplementationStatus(milestones, shippedMilestones) {
|
|
531
|
+
if (!milestones || milestones.length === 0) return 'none';
|
|
532
|
+
const allShipped = milestones.every(m => shippedMilestones.has(m));
|
|
533
|
+
return allShipped ? 'completed' : 'in-progress';
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Derive per-milestone state details.
|
|
538
|
+
* @param {string[]|undefined} milestones - Array of milestone version strings
|
|
539
|
+
* @param {Set<string>} shippedMilestones - Set of shipped milestone versions
|
|
540
|
+
* @returns {Array<{version: string, state: string}>}
|
|
541
|
+
*/
|
|
542
|
+
function deriveMilestoneDetails(milestones, shippedMilestones) {
|
|
543
|
+
if (!milestones || milestones.length === 0) return [];
|
|
544
|
+
return milestones.map(m => ({
|
|
545
|
+
version: m,
|
|
546
|
+
state: shippedMilestones.has(m) ? 'completed' : 'in-progress',
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* List all specs with optional status filter.
|
|
552
|
+
*
|
|
553
|
+
* @param {string} cwd - Working directory
|
|
554
|
+
* @param {object} options - { status: null|"draft"|"review"|"final" }
|
|
555
|
+
* @param {boolean} raw - Raw output mode
|
|
556
|
+
*/
|
|
557
|
+
function cmdSpecsList(cwd, options, raw) {
|
|
558
|
+
const specs = [];
|
|
559
|
+
const counts = { draft: 0, final: 0, total: 0 };
|
|
560
|
+
|
|
561
|
+
const specsDir = path.join(getPlanningRoot(cwd), 'specs');
|
|
562
|
+
let files;
|
|
563
|
+
try {
|
|
564
|
+
files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
|
|
565
|
+
} catch {
|
|
566
|
+
// No specs directory
|
|
567
|
+
output({ specs, counts }, raw);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const shippedMilestones = getShippedMilestones(cwd);
|
|
572
|
+
|
|
573
|
+
for (const file of files) {
|
|
574
|
+
const content = safeReadFile(path.join(specsDir, file));
|
|
575
|
+
if (!content) continue;
|
|
576
|
+
|
|
577
|
+
const format = detectSpecFormat(content);
|
|
578
|
+
const { frontmatter } = format === 'bold-key'
|
|
579
|
+
? parseBoldKeyFrontmatter(content)
|
|
580
|
+
: parseSpecFrontmatter(content);
|
|
581
|
+
const status = normalizeStatus(frontmatter.status || 'draft');
|
|
582
|
+
|
|
583
|
+
// Status filter
|
|
584
|
+
if (options && options.status && status !== options.status) continue;
|
|
585
|
+
|
|
586
|
+
// Author filter (case-insensitive substring match on name-only)
|
|
587
|
+
const authorName = extractNameFromAuthor(frontmatter.author || '');
|
|
588
|
+
if (options && options.author) {
|
|
589
|
+
const filterAuthor = options.author.toLowerCase();
|
|
590
|
+
if (!authorName.toLowerCase().includes(filterAuthor)) continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
specs.push({
|
|
594
|
+
id: frontmatter.id || '',
|
|
595
|
+
title: frontmatter.title || '',
|
|
596
|
+
status,
|
|
597
|
+
version: frontmatter.version || '',
|
|
598
|
+
milestones: frontmatter.milestones || [],
|
|
599
|
+
implementation_status: deriveImplementationStatus(frontmatter.milestones, shippedMilestones),
|
|
600
|
+
milestone_details: deriveMilestoneDetails(frontmatter.milestones, shippedMilestones),
|
|
601
|
+
source_ideas: frontmatter.source_ideas || [],
|
|
602
|
+
source_document: frontmatter.source_document || '',
|
|
603
|
+
created: frontmatter.created || '',
|
|
604
|
+
updated: frontmatter.updated || '',
|
|
605
|
+
author: authorName,
|
|
606
|
+
filename: file,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
if (counts[status] !== undefined) {
|
|
610
|
+
counts[status]++;
|
|
611
|
+
}
|
|
612
|
+
counts.total++;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
output({ specs, counts }, raw);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Update spec status field.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} cwd - Working directory
|
|
622
|
+
* @param {string} idOrFilename - Spec id or filename
|
|
623
|
+
* @param {string} newStatus - New status (draft, review, final)
|
|
624
|
+
* @param {boolean} raw - Raw output mode
|
|
625
|
+
*/
|
|
626
|
+
function cmdSpecsUpdateStatus(cwd, idOrFilename, newStatus, raw, author) {
|
|
627
|
+
if (!idOrFilename) {
|
|
628
|
+
error('id required for specs update-status');
|
|
629
|
+
}
|
|
630
|
+
if (!newStatus) {
|
|
631
|
+
error('status required for specs update-status');
|
|
632
|
+
}
|
|
633
|
+
if (!SPEC_STATUSES.includes(newStatus)) {
|
|
634
|
+
error(`invalid status: ${newStatus}. Use draft, review, or final`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const spec = findSpecFile(cwd, idOrFilename);
|
|
638
|
+
if (!spec) {
|
|
639
|
+
error(`spec not found: ${idOrFilename}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const previousStatus = spec.frontmatter.status;
|
|
643
|
+
spec.frontmatter.status = newStatus;
|
|
644
|
+
spec.frontmatter.updated = new Date().toISOString();
|
|
645
|
+
if (author) {
|
|
646
|
+
spec.frontmatter.updated_by = author;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const content = buildSpecContent(spec.frontmatter, spec.body);
|
|
650
|
+
fs.writeFileSync(spec.path, content, 'utf-8');
|
|
651
|
+
|
|
652
|
+
const result = {
|
|
653
|
+
id: spec.frontmatter.id,
|
|
654
|
+
filename: spec.filename,
|
|
655
|
+
status: newStatus,
|
|
656
|
+
previous_status: previousStatus,
|
|
657
|
+
};
|
|
658
|
+
output(result, raw);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Append a review round to the spec's Review History section.
|
|
663
|
+
*
|
|
664
|
+
* @param {string} cwd - Working directory
|
|
665
|
+
* @param {string} idOrFilename - Spec id or filename
|
|
666
|
+
* @param {string} historyEntry - Formatted markdown for one review round
|
|
667
|
+
* @param {boolean} raw - Raw output mode
|
|
668
|
+
*/
|
|
669
|
+
function cmdSpecsAppendReviewHistory(cwd, idOrFilename, historyEntry, raw, author) {
|
|
670
|
+
if (!idOrFilename) {
|
|
671
|
+
error('id required for specs append-review');
|
|
672
|
+
}
|
|
673
|
+
if (!historyEntry) {
|
|
674
|
+
error('entry required for specs append-review');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const spec = findSpecFile(cwd, idOrFilename);
|
|
678
|
+
if (!spec) {
|
|
679
|
+
error(`spec not found: ${idOrFilename}`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
spec.frontmatter.updated = new Date().toISOString();
|
|
683
|
+
if (author) {
|
|
684
|
+
spec.frontmatter.updated_by = author;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Normalize literal \n sequences to real newlines (CLI passes escaped newlines)
|
|
688
|
+
historyEntry = historyEntry.replace(/\\n/g, '\n');
|
|
689
|
+
|
|
690
|
+
// Inject "Applied by" attribution into the review history entry
|
|
691
|
+
if (author) {
|
|
692
|
+
const appliedByName = extractNameFromAuthor(author);
|
|
693
|
+
// Insert "**Applied by:** Name" after the first line (### Round N heading)
|
|
694
|
+
const firstNewline = historyEntry.indexOf('\n');
|
|
695
|
+
if (firstNewline !== -1) {
|
|
696
|
+
historyEntry = historyEntry.slice(0, firstNewline + 1) +
|
|
697
|
+
'**Applied by:** ' + appliedByName + '\n' +
|
|
698
|
+
historyEntry.slice(firstNewline + 1);
|
|
699
|
+
} else {
|
|
700
|
+
// Entry is a single line — append attribution after it
|
|
701
|
+
historyEntry = historyEntry + '\n**Applied by:** ' + appliedByName;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
let body = spec.body;
|
|
706
|
+
|
|
707
|
+
// Find the ## Review History section
|
|
708
|
+
const reviewHistoryPattern = /## Review History\n/;
|
|
709
|
+
const reviewMatch = body.match(reviewHistoryPattern);
|
|
710
|
+
|
|
711
|
+
if (reviewMatch) {
|
|
712
|
+
// Find the position after "## Review History\n"
|
|
713
|
+
const insertPos = reviewMatch.index + reviewMatch[0].length;
|
|
714
|
+
// Find existing content after the header (before next ## section or end)
|
|
715
|
+
const afterHeader = body.slice(insertPos);
|
|
716
|
+
const nextSectionMatch = afterHeader.match(/\n## [^#]/);
|
|
717
|
+
const existingContent = nextSectionMatch
|
|
718
|
+
? afterHeader.slice(0, nextSectionMatch.index)
|
|
719
|
+
: afterHeader;
|
|
720
|
+
|
|
721
|
+
// Append the new entry after existing review history content
|
|
722
|
+
const newContent = existingContent.trimEnd() + '\n\n' + historyEntry + '\n';
|
|
723
|
+
|
|
724
|
+
if (nextSectionMatch) {
|
|
725
|
+
body = body.slice(0, insertPos) + newContent + afterHeader.slice(nextSectionMatch.index);
|
|
726
|
+
} else {
|
|
727
|
+
body = body.slice(0, insertPos) + newContent;
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
// No Review History section exists -- append it
|
|
731
|
+
body = body.trimEnd() + '\n\n## Review History\n\n' + historyEntry + '\n';
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const content = buildSpecContent(spec.frontmatter, body);
|
|
735
|
+
fs.writeFileSync(spec.path, content, 'utf-8');
|
|
736
|
+
|
|
737
|
+
const result = {
|
|
738
|
+
id: spec.frontmatter.id,
|
|
739
|
+
filename: spec.filename,
|
|
740
|
+
appended: true,
|
|
741
|
+
};
|
|
742
|
+
output(result, raw);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Finalize a spec: set status to final, move source ideas to done.
|
|
747
|
+
*
|
|
748
|
+
* @param {string} cwd - Working directory
|
|
749
|
+
* @param {string} idOrFilename - Spec id or filename
|
|
750
|
+
* @param {boolean} raw - Raw output mode
|
|
751
|
+
*/
|
|
752
|
+
function cmdSpecsFinalize(cwd, idOrFilename, raw, author) {
|
|
753
|
+
if (!idOrFilename) {
|
|
754
|
+
error('id required for specs finalize');
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const spec = findSpecFile(cwd, idOrFilename);
|
|
758
|
+
if (!spec) {
|
|
759
|
+
error(`spec not found: ${idOrFilename}`);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Guard: already finalized
|
|
763
|
+
if (spec.frontmatter.status === 'final') {
|
|
764
|
+
error(`spec already final: ${spec.frontmatter.id}. Cannot re-finalize.`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Set status to final
|
|
768
|
+
spec.frontmatter.status = 'final';
|
|
769
|
+
spec.frontmatter.updated = new Date().toISOString();
|
|
770
|
+
if (author) {
|
|
771
|
+
spec.frontmatter.updated_by = author;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const content = buildSpecContent(spec.frontmatter, spec.body);
|
|
775
|
+
fs.writeFileSync(spec.path, content, 'utf-8');
|
|
776
|
+
|
|
777
|
+
// Move source ideas to done
|
|
778
|
+
const ideasMoved = [];
|
|
779
|
+
const ideasFailed = [];
|
|
780
|
+
const sourceIdeas = spec.frontmatter.source_ideas || [];
|
|
781
|
+
|
|
782
|
+
for (const ideaFilename of sourceIdeas) {
|
|
783
|
+
// Extract idea ID from filename (e.g., "001-slug.md" -> "001")
|
|
784
|
+
const idMatch = ideaFilename.match(/^(\d+)/);
|
|
785
|
+
if (!idMatch) {
|
|
786
|
+
ideasFailed.push({ idea: ideaFilename, reason: 'could not parse ID' });
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const ideaId = idMatch[1];
|
|
790
|
+
|
|
791
|
+
try {
|
|
792
|
+
// Move ideas to done manually (ideas.cjs cmd functions call process.exit,
|
|
793
|
+
// so we use the internal helpers directly instead)
|
|
794
|
+
// Instead, do the move manually following the same pattern
|
|
795
|
+
const { findIdeaFile, ensureIdeasDirs } = require('./ideas.cjs');
|
|
796
|
+
const idea = findIdeaFile(cwd, ideaId);
|
|
797
|
+
|
|
798
|
+
if (!idea) {
|
|
799
|
+
ideasFailed.push({ idea: ideaFilename, reason: 'idea not found' });
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (idea.state === 'done') {
|
|
804
|
+
// Already done, skip
|
|
805
|
+
ideasMoved.push(ideaFilename);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (idea.state === 'consolidated') {
|
|
810
|
+
// Consolidated ideas cannot be used as spec sources
|
|
811
|
+
ideasFailed.push({ idea: ideaFilename, reason: 'idea is consolidated' });
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
ensureIdeasDirs(cwd);
|
|
816
|
+
|
|
817
|
+
const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
|
|
818
|
+
const fromRel = path.join(planRootRel, 'ideas', idea.state, idea.filename);
|
|
819
|
+
const toRel = path.join(planRootRel, 'ideas', 'done', idea.filename);
|
|
820
|
+
const mvResult = execGit(cwd, ['mv', fromRel, toRel]);
|
|
821
|
+
|
|
822
|
+
if (mvResult.exitCode !== 0) {
|
|
823
|
+
ideasFailed.push({ idea: ideaFilename, reason: mvResult.stderr || 'git mv failed' });
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
ideasMoved.push(ideaFilename);
|
|
828
|
+
} catch (err) {
|
|
829
|
+
ideasFailed.push({ idea: ideaFilename, reason: err.message || 'unknown error' });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Move associated research documents and update links in idea files
|
|
834
|
+
const researchDocsMoved = [];
|
|
835
|
+
|
|
836
|
+
for (const ideaFilename of ideasMoved) {
|
|
837
|
+
try {
|
|
838
|
+
// Extract slug from idea filename: "001-my-idea.md" -> "my-idea"
|
|
839
|
+
const slug = ideaFilename.replace(/^\d+-/, '').replace(/\.md$/, '');
|
|
840
|
+
const researchDocName = `${slug}-research.md`;
|
|
841
|
+
const specPlanRoot = getPlanningRoot(cwd);
|
|
842
|
+
const specPlanRootRel = path.relative(cwd, specPlanRoot) || '.';
|
|
843
|
+
const sourcePath = path.join(specPlanRoot, 'docs', 'ideas', 'pending', researchDocName);
|
|
844
|
+
|
|
845
|
+
// Skip if no research doc exists (graceful handling)
|
|
846
|
+
if (!fs.existsSync(sourcePath)) continue;
|
|
847
|
+
|
|
848
|
+
// Ensure done directory exists
|
|
849
|
+
fs.mkdirSync(path.join(specPlanRoot, 'docs', 'ideas', 'done'), { recursive: true });
|
|
850
|
+
|
|
851
|
+
const sourceRel = path.join(specPlanRootRel, 'docs', 'ideas', 'pending', researchDocName);
|
|
852
|
+
const targetRel = path.join(specPlanRootRel, 'docs', 'ideas', 'done', researchDocName);
|
|
853
|
+
const mvResult = execGit(cwd, ['mv', sourceRel, targetRel]);
|
|
854
|
+
|
|
855
|
+
if (mvResult.exitCode !== 0) continue;
|
|
856
|
+
|
|
857
|
+
researchDocsMoved.push(researchDocName);
|
|
858
|
+
|
|
859
|
+
// Update documentLink paths in the idea file (now in done/)
|
|
860
|
+
const ideaDonePath = path.join(getPlanningRoot(cwd), 'ideas', 'done', ideaFilename);
|
|
861
|
+
if (fs.existsSync(ideaDonePath)) {
|
|
862
|
+
let ideaContent = fs.readFileSync(ideaDonePath, 'utf-8');
|
|
863
|
+
// Replace pending/ paths with done/ for this research doc
|
|
864
|
+
const pendingPattern = `docs/ideas/pending/${researchDocName}`;
|
|
865
|
+
const doneReplacement = `docs/ideas/done/${researchDocName}`;
|
|
866
|
+
if (ideaContent.includes(pendingPattern)) {
|
|
867
|
+
ideaContent = ideaContent.split(pendingPattern).join(doneReplacement);
|
|
868
|
+
fs.writeFileSync(ideaDonePath, ideaContent, 'utf-8');
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
} catch {
|
|
872
|
+
// Graceful handling: skip on any error
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Auto-commit the spec status change and idea moves
|
|
877
|
+
execGit(cwd, ['add', '-A']);
|
|
878
|
+
const title = spec.frontmatter.title || spec.frontmatter.id;
|
|
879
|
+
execGit(cwd, ['commit', '-m', `specs: finalize ${spec.frontmatter.id} - ${title}`]);
|
|
880
|
+
|
|
881
|
+
const result = {
|
|
882
|
+
id: spec.frontmatter.id,
|
|
883
|
+
filename: spec.filename,
|
|
884
|
+
status: 'final',
|
|
885
|
+
ideas_moved: ideasMoved,
|
|
886
|
+
ideas_failed: ideasFailed,
|
|
887
|
+
research_docs_moved: researchDocsMoved,
|
|
888
|
+
};
|
|
889
|
+
output(result, raw);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ─── Set Status (two-state machine) ──────────────────────────────────────────
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Set spec status, enforcing the two-state machine (draft/final only).
|
|
896
|
+
* Rejects deprecated 'review' and 'in-review' with clear message.
|
|
897
|
+
*
|
|
898
|
+
* @param {string} cwd - Working directory
|
|
899
|
+
* @param {string} idOrFilename - Spec id or filename
|
|
900
|
+
* @param {string} newStatus - New status (draft or final)
|
|
901
|
+
* @param {boolean} raw - Raw output mode
|
|
902
|
+
* @param {string} [author] - Author string for updated_by
|
|
903
|
+
*/
|
|
904
|
+
function cmdSpecsSetStatus(cwd, idOrFilename, newStatus, raw, author) {
|
|
905
|
+
if (!idOrFilename) {
|
|
906
|
+
error('id required for specs set-status');
|
|
907
|
+
}
|
|
908
|
+
if (!newStatus) {
|
|
909
|
+
error('status required for specs set-status');
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Reject deprecated statuses with specific message
|
|
913
|
+
if (newStatus === 'review' || newStatus === 'in-review') {
|
|
914
|
+
error(`Status '${newStatus}' is deprecated. Valid statuses: draft, final`);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Validate against two-state machine (hardcoded, NOT using SPEC_STATUSES)
|
|
918
|
+
const validStatuses = ['draft', 'final'];
|
|
919
|
+
if (!validStatuses.includes(newStatus)) {
|
|
920
|
+
error(`Invalid status '${newStatus}'. Valid statuses: draft, final`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const spec = findSpecFile(cwd, idOrFilename);
|
|
924
|
+
if (!spec) {
|
|
925
|
+
// Build fuzzy suggestions
|
|
926
|
+
const specsDir = path.join(getPlanningRoot(cwd), 'specs');
|
|
927
|
+
const suggestions = [];
|
|
928
|
+
try {
|
|
929
|
+
const files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
|
|
930
|
+
const searchLower = idOrFilename.toLowerCase();
|
|
931
|
+
for (const file of files) {
|
|
932
|
+
const content = safeReadFile(path.join(specsDir, file));
|
|
933
|
+
if (!content) continue;
|
|
934
|
+
const format = detectSpecFormat(content);
|
|
935
|
+
const { frontmatter } = format === 'bold-key'
|
|
936
|
+
? parseBoldKeyFrontmatter(content)
|
|
937
|
+
: parseSpecFrontmatter(content);
|
|
938
|
+
const idLower = (frontmatter.id || '').toLowerCase();
|
|
939
|
+
const titleLower = (frontmatter.title || '').toLowerCase();
|
|
940
|
+
if (idLower.includes(searchLower) || titleLower.includes(searchLower)) {
|
|
941
|
+
suggestions.push(frontmatter.id || file);
|
|
942
|
+
}
|
|
943
|
+
if (suggestions.length >= 3) break;
|
|
944
|
+
}
|
|
945
|
+
} catch { /* ignore */ }
|
|
946
|
+
const suggestionText = suggestions.length > 0
|
|
947
|
+
? `. Did you mean: ${suggestions.join(', ')}?`
|
|
948
|
+
: '';
|
|
949
|
+
error(`spec not found: ${idOrFilename}${suggestionText}`);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Read the raw file content to detect format
|
|
953
|
+
const fileContent = fs.readFileSync(spec.path, 'utf-8');
|
|
954
|
+
const format = detectSpecFormat(fileContent);
|
|
955
|
+
|
|
956
|
+
// Track the previous status (normalized from what was stored)
|
|
957
|
+
const previousStatus = spec.frontmatter.status;
|
|
958
|
+
|
|
959
|
+
// Update frontmatter
|
|
960
|
+
spec.frontmatter.status = newStatus;
|
|
961
|
+
spec.frontmatter.updated = new Date().toISOString();
|
|
962
|
+
if (newStatus === 'final') {
|
|
963
|
+
spec.frontmatter.approved_date = new Date().toISOString().split('T')[0];
|
|
964
|
+
}
|
|
965
|
+
if (author) {
|
|
966
|
+
spec.frontmatter.updated_by = author;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Write back using the correct builder
|
|
970
|
+
const content = format === 'bold-key'
|
|
971
|
+
? buildBoldKeyContent(spec.frontmatter, spec.body)
|
|
972
|
+
: buildSpecContent(spec.frontmatter, spec.body);
|
|
973
|
+
fs.writeFileSync(spec.path, content, 'utf-8');
|
|
974
|
+
|
|
975
|
+
const result = {
|
|
976
|
+
id: spec.frontmatter.id,
|
|
977
|
+
filename: spec.filename,
|
|
978
|
+
previous_status: previousStatus,
|
|
979
|
+
status: newStatus,
|
|
980
|
+
message: `\u2713 ${spec.filename}: ${previousStatus} \u2192 ${newStatus}`,
|
|
981
|
+
};
|
|
982
|
+
output(result, raw);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ─── Validate ────────────────────────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Validate a spec for completeness, producing tiered error/warning/info output.
|
|
989
|
+
*
|
|
990
|
+
* @param {string} cwd - Working directory
|
|
991
|
+
* @param {string} idOrFilename - Spec id or filename
|
|
992
|
+
* @param {boolean} raw - Raw output mode
|
|
993
|
+
*/
|
|
994
|
+
function cmdSpecsValidate(cwd, idOrFilename, raw) {
|
|
995
|
+
if (!idOrFilename) {
|
|
996
|
+
error('id required for specs validate');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const spec = findSpecFile(cwd, idOrFilename);
|
|
1000
|
+
if (!spec) {
|
|
1001
|
+
// Build fuzzy suggestions
|
|
1002
|
+
const specsDir = path.join(getPlanningRoot(cwd), 'specs');
|
|
1003
|
+
const suggestions = [];
|
|
1004
|
+
try {
|
|
1005
|
+
const files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
|
|
1006
|
+
const searchLower = idOrFilename.toLowerCase();
|
|
1007
|
+
for (const file of files) {
|
|
1008
|
+
const content = safeReadFile(path.join(specsDir, file));
|
|
1009
|
+
if (!content) continue;
|
|
1010
|
+
const fmt = detectSpecFormat(content);
|
|
1011
|
+
const { frontmatter } = fmt === 'bold-key'
|
|
1012
|
+
? parseBoldKeyFrontmatter(content)
|
|
1013
|
+
: parseSpecFrontmatter(content);
|
|
1014
|
+
const idLower = (frontmatter.id || '').toLowerCase();
|
|
1015
|
+
const titleLower = (frontmatter.title || '').toLowerCase();
|
|
1016
|
+
if (idLower.includes(searchLower) || titleLower.includes(searchLower)) {
|
|
1017
|
+
suggestions.push(frontmatter.id || file);
|
|
1018
|
+
}
|
|
1019
|
+
if (suggestions.length >= 3) break;
|
|
1020
|
+
}
|
|
1021
|
+
} catch { /* ignore */ }
|
|
1022
|
+
const suggestionText = suggestions.length > 0
|
|
1023
|
+
? `. Did you mean: ${suggestions.join(', ')}?`
|
|
1024
|
+
: '';
|
|
1025
|
+
error(`spec not found: ${idOrFilename}${suggestionText}`);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Read the full body content (use the spec.body from findSpecFile)
|
|
1029
|
+
const fullContent = fs.readFileSync(spec.path, 'utf-8');
|
|
1030
|
+
|
|
1031
|
+
// Parse section headings from the full content
|
|
1032
|
+
const lines = fullContent.split('\n');
|
|
1033
|
+
const sectionsFound = [];
|
|
1034
|
+
for (const line of lines) {
|
|
1035
|
+
const sectionMatch = line.match(/^##\s+(?:\d+\.\s+)?(.*)/);
|
|
1036
|
+
if (sectionMatch) {
|
|
1037
|
+
sectionsFound.push(sectionMatch[1].trim());
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const errors = [];
|
|
1042
|
+
const warnings = [];
|
|
1043
|
+
const info = [];
|
|
1044
|
+
|
|
1045
|
+
// P0 (errors): Problem/Problem Statement, Requirements, Non-Goals/Scope
|
|
1046
|
+
const problemPattern = /^##\s+(?:\d+\.\s+)?(?:Problem(?:\s+Statement)?)/im;
|
|
1047
|
+
const requirementsPattern = /^##\s+(?:\d+\.\s+)?Requirements/im;
|
|
1048
|
+
const nonGoalsPattern = /^##\s+(?:\d+\.\s+)?(?:Non[- ]Goals|Scope)/im;
|
|
1049
|
+
|
|
1050
|
+
if (!problemPattern.test(fullContent)) {
|
|
1051
|
+
errors.push({ code: 'MISSING_SECTION', section: 'Problem', tier: 'P0', message: 'Missing required section: Problem' });
|
|
1052
|
+
}
|
|
1053
|
+
if (!requirementsPattern.test(fullContent)) {
|
|
1054
|
+
errors.push({ code: 'MISSING_SECTION', section: 'Requirements', tier: 'P0', message: 'Missing required section: Requirements' });
|
|
1055
|
+
}
|
|
1056
|
+
if (!nonGoalsPattern.test(fullContent)) {
|
|
1057
|
+
errors.push({ code: 'MISSING_SECTION', section: 'Non-Goals', tier: 'P0', message: 'Missing required section: Non-Goals' });
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Check for P0 requirement subsection under Requirements
|
|
1061
|
+
const p0Pattern = /^###\s+P0/im;
|
|
1062
|
+
if (requirementsPattern.test(fullContent) && !p0Pattern.test(fullContent)) {
|
|
1063
|
+
errors.push({ code: 'NO_P0_REQUIREMENT', message: 'No P0 requirement found in Requirements section' });
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// P1 (warnings): User Stories, Success Metrics
|
|
1067
|
+
const userStoriesPattern = /^##\s+(?:\d+\.\s+)?User\s+Stories/im;
|
|
1068
|
+
const successMetricsPattern = /^##\s+(?:\d+\.\s+)?Success\s+Metrics/im;
|
|
1069
|
+
|
|
1070
|
+
if (!userStoriesPattern.test(fullContent)) {
|
|
1071
|
+
warnings.push({ code: 'MISSING_SECTION', section: 'User Stories', tier: 'P1', message: 'Missing recommended section: User Stories' });
|
|
1072
|
+
}
|
|
1073
|
+
if (!successMetricsPattern.test(fullContent)) {
|
|
1074
|
+
warnings.push({ code: 'MISSING_SECTION', section: 'Success Metrics', tier: 'P1', message: 'Missing recommended section: Success Metrics' });
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const implNotesPattern = /^##\s+(?:\d+\.\s+)?Implementation\s+Notes/im;
|
|
1078
|
+
if (!implNotesPattern.test(fullContent)) {
|
|
1079
|
+
warnings.push({ code: 'MISSING_SECTION', section: 'Implementation Notes', tier: 'P1', message: 'Missing recommended section: Implementation Notes' });
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// P2 (info): other sections that are present (metadata sections excluded)
|
|
1083
|
+
const metadataSections = ['Refinement Log', 'Review History'];
|
|
1084
|
+
for (const section of sectionsFound) {
|
|
1085
|
+
// Skip P0 and P1 sections (already classified)
|
|
1086
|
+
if (/^(?:Problem(?:\s+Statement)?|Requirements|Non[- ]Goals|Scope|User\s+Stories|Success\s+Metrics|Implementation\s+Notes)$/i.test(section)) continue;
|
|
1087
|
+
// Skip metadata sections
|
|
1088
|
+
if (metadataSections.some(m => section.toLowerCase() === m.toLowerCase())) continue;
|
|
1089
|
+
info.push({ section, tier: 'P2', message: `Additional section found: ${section}` });
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const result = {
|
|
1093
|
+
id: spec.frontmatter.id,
|
|
1094
|
+
valid: errors.length === 0,
|
|
1095
|
+
errors,
|
|
1096
|
+
warnings,
|
|
1097
|
+
info,
|
|
1098
|
+
sections_found: sectionsFound,
|
|
1099
|
+
error_count: errors.length,
|
|
1100
|
+
warning_count: warnings.length,
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
if (raw) {
|
|
1104
|
+
// Format as grouped list for raw mode
|
|
1105
|
+
let text = '';
|
|
1106
|
+
if (errors.length > 0) {
|
|
1107
|
+
text += 'Errors:\n';
|
|
1108
|
+
for (const e of errors) text += ` - [${e.code}] ${e.message}\n`;
|
|
1109
|
+
}
|
|
1110
|
+
if (warnings.length > 0) {
|
|
1111
|
+
text += 'Warnings:\n';
|
|
1112
|
+
for (const w of warnings) text += ` - [${w.code}] ${w.message}\n`;
|
|
1113
|
+
}
|
|
1114
|
+
text += `\nErrors: ${errors.length}, Warnings: ${warnings.length}\n`;
|
|
1115
|
+
text += errors.length === 0 ? 'PASS' : 'FAIL';
|
|
1116
|
+
process.stdout.write(text + '\n');
|
|
1117
|
+
} else {
|
|
1118
|
+
output(result);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ─── Add Log Entry ───────────────────────────────────────────────────────────
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Add a log entry to the spec's Refinement Log table.
|
|
1126
|
+
* Creates the section if it does not exist.
|
|
1127
|
+
*
|
|
1128
|
+
* @param {string} cwd - Working directory
|
|
1129
|
+
* @param {string} idOrFilename - Spec id or filename
|
|
1130
|
+
* @param {string} date - Entry date
|
|
1131
|
+
* @param {string} version - Version string
|
|
1132
|
+
* @param {string} action - Action type (Created, Refined, Approved, Imported)
|
|
1133
|
+
* @param {string} summary - Entry summary
|
|
1134
|
+
* @param {boolean} raw - Raw output mode
|
|
1135
|
+
* @param {string} [author] - Author string for attribution
|
|
1136
|
+
*/
|
|
1137
|
+
function cmdSpecsAddLogEntry(cwd, idOrFilename, date, version, action, summary, raw, author) {
|
|
1138
|
+
if (!idOrFilename) {
|
|
1139
|
+
error('id required for specs add-log-entry');
|
|
1140
|
+
}
|
|
1141
|
+
if (!date) {
|
|
1142
|
+
error('date required for specs add-log-entry');
|
|
1143
|
+
}
|
|
1144
|
+
if (!version) {
|
|
1145
|
+
error('version required for specs add-log-entry');
|
|
1146
|
+
}
|
|
1147
|
+
if (!action) {
|
|
1148
|
+
error('action required for specs add-log-entry');
|
|
1149
|
+
}
|
|
1150
|
+
if (!summary) {
|
|
1151
|
+
error('summary required for specs add-log-entry');
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Validate action
|
|
1155
|
+
const validActions = ['Created', 'Refined', 'Approved', 'Imported'];
|
|
1156
|
+
if (!validActions.includes(action)) {
|
|
1157
|
+
error(`Invalid action '${action}'. Valid actions: ${validActions.join(', ')}`);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const spec = findSpecFile(cwd, idOrFilename);
|
|
1161
|
+
if (!spec) {
|
|
1162
|
+
error(`spec not found: ${idOrFilename}`);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Read the full file content from disk to preserve exact formatting
|
|
1166
|
+
let fileContent = fs.readFileSync(spec.path, 'utf-8');
|
|
1167
|
+
|
|
1168
|
+
// Look for existing Refinement Log section
|
|
1169
|
+
const logSectionPattern = /^## Refinement Log/m;
|
|
1170
|
+
const logMatch = fileContent.match(logSectionPattern);
|
|
1171
|
+
|
|
1172
|
+
if (logMatch) {
|
|
1173
|
+
// Find the last table row after the section heading
|
|
1174
|
+
const afterSection = fileContent.slice(logMatch.index);
|
|
1175
|
+
const afterLines = afterSection.split('\n');
|
|
1176
|
+
let lastTableRowIndex = -1;
|
|
1177
|
+
|
|
1178
|
+
for (let i = 0; i < afterLines.length; i++) {
|
|
1179
|
+
if (/^\|/.test(afterLines[i])) {
|
|
1180
|
+
lastTableRowIndex = i;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (lastTableRowIndex !== -1) {
|
|
1185
|
+
// Insert new row after the last table row
|
|
1186
|
+
afterLines.splice(lastTableRowIndex + 1, 0, `| ${date} | ${version} | ${action} | ${summary} |`);
|
|
1187
|
+
fileContent = fileContent.slice(0, logMatch.index) + afterLines.join('\n');
|
|
1188
|
+
} else {
|
|
1189
|
+
// Section exists but no table — add table after heading
|
|
1190
|
+
const insertPos = logMatch.index + logMatch[0].length;
|
|
1191
|
+
const tableContent = `\n\n| Date | Version | Action | Summary |\n|------|---------|--------|--------|\n| ${date} | ${version} | ${action} | ${summary} |`;
|
|
1192
|
+
fileContent = fileContent.slice(0, insertPos) + tableContent + fileContent.slice(insertPos);
|
|
1193
|
+
}
|
|
1194
|
+
} else {
|
|
1195
|
+
// Section does not exist — append to end of file
|
|
1196
|
+
const tableContent = `\n## Refinement Log\n\n| Date | Version | Action | Summary |\n|------|---------|--------|--------|\n| ${date} | ${version} | ${action} | ${summary} |\n`;
|
|
1197
|
+
fileContent = fileContent.trimEnd() + '\n' + tableContent;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
fs.writeFileSync(spec.path, fileContent, 'utf-8');
|
|
1201
|
+
|
|
1202
|
+
const result = {
|
|
1203
|
+
id: spec.frontmatter.id,
|
|
1204
|
+
filename: spec.filename,
|
|
1205
|
+
action,
|
|
1206
|
+
version,
|
|
1207
|
+
summary,
|
|
1208
|
+
message: `\u2713 ${spec.filename}: ${action} log entry added (v${version})`,
|
|
1209
|
+
};
|
|
1210
|
+
output(result, raw);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// ─── Link Milestone ──────────────────────────────────────────────────────────
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Link a milestone to a spec by adding it to the milestones array.
|
|
1217
|
+
* No duplicates allowed.
|
|
1218
|
+
*
|
|
1219
|
+
* @param {string} cwd - Working directory
|
|
1220
|
+
* @param {string} idOrFilename - Spec id or filename
|
|
1221
|
+
* @param {string} milestone - Milestone version string (e.g., "v13.0")
|
|
1222
|
+
* @param {boolean} raw - Raw output mode
|
|
1223
|
+
* @param {string} [author] - Author string for updated_by
|
|
1224
|
+
*/
|
|
1225
|
+
function cmdSpecsLinkMilestone(cwd, idOrFilename, milestone, raw, author) {
|
|
1226
|
+
if (!idOrFilename) {
|
|
1227
|
+
error('id required for specs link-milestone');
|
|
1228
|
+
}
|
|
1229
|
+
if (!milestone) {
|
|
1230
|
+
error('milestone required for specs link-milestone');
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const spec = findSpecFile(cwd, idOrFilename);
|
|
1234
|
+
if (!spec) {
|
|
1235
|
+
error(`spec not found: ${idOrFilename}`);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Read file content and detect format
|
|
1239
|
+
const fileContent = fs.readFileSync(spec.path, 'utf-8');
|
|
1240
|
+
const format = detectSpecFormat(fileContent);
|
|
1241
|
+
|
|
1242
|
+
// Check for duplicate
|
|
1243
|
+
const milestones = spec.frontmatter.milestones || [];
|
|
1244
|
+
if (milestones.includes(milestone)) {
|
|
1245
|
+
const result = {
|
|
1246
|
+
id: spec.frontmatter.id,
|
|
1247
|
+
filename: spec.filename,
|
|
1248
|
+
milestone,
|
|
1249
|
+
milestones,
|
|
1250
|
+
message: `${spec.filename}: already linked to ${milestone}`,
|
|
1251
|
+
};
|
|
1252
|
+
output(result, raw);
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Add milestone
|
|
1257
|
+
milestones.push(milestone);
|
|
1258
|
+
spec.frontmatter.milestones = milestones;
|
|
1259
|
+
|
|
1260
|
+
// Update timestamp and author
|
|
1261
|
+
spec.frontmatter.updated = new Date().toISOString();
|
|
1262
|
+
if (author) {
|
|
1263
|
+
spec.frontmatter.updated_by = author;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Write back using correct format builder
|
|
1267
|
+
const content = format === 'bold-key'
|
|
1268
|
+
? buildBoldKeyContent(spec.frontmatter, spec.body)
|
|
1269
|
+
: buildSpecContent(spec.frontmatter, spec.body);
|
|
1270
|
+
fs.writeFileSync(spec.path, content, 'utf-8');
|
|
1271
|
+
|
|
1272
|
+
const result = {
|
|
1273
|
+
id: spec.frontmatter.id,
|
|
1274
|
+
filename: spec.filename,
|
|
1275
|
+
milestone,
|
|
1276
|
+
milestones: spec.frontmatter.milestones,
|
|
1277
|
+
message: `\u2713 ${spec.filename}: linked to ${milestone}`,
|
|
1278
|
+
};
|
|
1279
|
+
output(result, raw);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
1283
|
+
|
|
1284
|
+
module.exports = {
|
|
1285
|
+
ensureSpecsDir,
|
|
1286
|
+
normalizeStatus,
|
|
1287
|
+
getShippedMilestones,
|
|
1288
|
+
parseSpecFrontmatter,
|
|
1289
|
+
detectSpecFormat,
|
|
1290
|
+
parseBoldKeyFrontmatter,
|
|
1291
|
+
buildBoldKeyContent,
|
|
1292
|
+
buildSpecContent,
|
|
1293
|
+
findSpecFile,
|
|
1294
|
+
cmdSpecsCreate,
|
|
1295
|
+
cmdSpecsList,
|
|
1296
|
+
cmdSpecsUpdateStatus,
|
|
1297
|
+
cmdSpecsAppendReviewHistory,
|
|
1298
|
+
cmdSpecsFinalize,
|
|
1299
|
+
cmdSpecsSetStatus,
|
|
1300
|
+
cmdSpecsValidate,
|
|
1301
|
+
cmdSpecsAddLogEntry,
|
|
1302
|
+
cmdSpecsLinkMilestone,
|
|
1303
|
+
};
|