gsdd-cli 0.16.1 → 0.18.0
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 +109 -60
- package/agents/README.md +1 -1
- package/bin/adapters/claude.mjs +8 -1
- package/bin/adapters/codex.mjs +5 -1
- package/bin/adapters/opencode.mjs +7 -1
- package/bin/gsdd.mjs +6 -4
- package/bin/lib/evidence-contract.mjs +112 -0
- package/bin/lib/health-truth.mjs +29 -31
- package/bin/lib/health.mjs +32 -80
- package/bin/lib/init-runtime.mjs +44 -24
- package/bin/lib/init.mjs +3 -7
- package/bin/lib/lifecycle-preflight.mjs +333 -0
- package/bin/lib/lifecycle-state.mjs +293 -0
- package/bin/lib/phase.mjs +81 -26
- package/bin/lib/provenance.mjs +20 -1
- package/bin/lib/rendering.mjs +8 -0
- package/bin/lib/runtime-freshness.mjs +239 -0
- package/bin/lib/session-fingerprint.mjs +106 -0
- package/distilled/DESIGN.md +398 -12
- package/distilled/EVIDENCE-INDEX.md +105 -0
- package/distilled/README.md +44 -28
- package/distilled/SKILL.md +4 -4
- package/distilled/templates/agents.block.md +1 -1
- package/distilled/workflows/audit-milestone.md +50 -0
- package/distilled/workflows/complete-milestone.md +38 -2
- package/distilled/workflows/execute.md +13 -0
- package/distilled/workflows/map-codebase.md +14 -3
- package/distilled/workflows/new-milestone.md +13 -0
- package/distilled/workflows/new-project.md +3 -1
- package/distilled/workflows/plan.md +3 -1
- package/distilled/workflows/progress.md +68 -13
- package/distilled/workflows/quick.md +15 -8
- package/distilled/workflows/resume.md +14 -1
- package/distilled/workflows/verify.md +52 -17
- package/docs/BROWNFIELD-PROOF.md +95 -0
- package/docs/RUNTIME-SUPPORT.md +77 -0
- package/docs/USER-GUIDE.md +439 -0
- package/docs/VERIFICATION-DISCIPLINE.md +59 -0
- package/docs/claude/context-monitor.md +98 -0
- package/docs/proof/consumer-node-cli/README.md +37 -0
- package/docs/proof/consumer-node-cli/ROADMAP.md +14 -0
- package/docs/proof/consumer-node-cli/SPEC.md +17 -0
- package/docs/proof/consumer-node-cli/brief.md +9 -0
- package/docs/proof/consumer-node-cli/phases/01-foundation/01-01-PLAN.md +34 -0
- package/docs/proof/consumer-node-cli/phases/01-foundation/01-01-SUMMARY.md +10 -0
- package/docs/proof/consumer-node-cli/phases/01-foundation/01-VERIFICATION.md +30 -0
- package/package.json +34 -27
package/bin/lib/health.mjs
CHANGED
|
@@ -8,10 +8,12 @@ import { join } from 'path';
|
|
|
8
8
|
import { readManifest, detectModifications } from './manifest.mjs';
|
|
9
9
|
import { output } from './cli-utils.mjs';
|
|
10
10
|
import { runTruthChecks, TRUTH_CHECK_IDS } from './health-truth.mjs';
|
|
11
|
+
import { evaluateLifecycleState } from './lifecycle-state.mjs';
|
|
12
|
+
import { evaluateRuntimeFreshness } from './runtime-freshness.mjs';
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Factory function returning the health command.
|
|
14
|
-
* ctx
|
|
16
|
+
* ctx should provide: { frameworkVersion, workflows }
|
|
15
17
|
*/
|
|
16
18
|
export function createCmdHealth(ctx) {
|
|
17
19
|
return async function cmdHealth(...healthArgs) {
|
|
@@ -41,8 +43,10 @@ export function createCmdHealth(ctx) {
|
|
|
41
43
|
// E1: config.json missing (already handled by pre-init guard, but keep for completeness)
|
|
42
44
|
// E2: config.json missing required fields
|
|
43
45
|
let config = null;
|
|
46
|
+
let configOk = false;
|
|
44
47
|
try {
|
|
45
48
|
config = JSON.parse(readFileSync(join(planningDir, 'config.json'), 'utf-8'));
|
|
49
|
+
configOk = true;
|
|
46
50
|
const requiredFields = ['researchDepth', 'modelProfile', 'initVersion'];
|
|
47
51
|
const missing = requiredFields.filter((f) => !(f in config));
|
|
48
52
|
if (missing.length > 0) {
|
|
@@ -148,45 +152,28 @@ export function createCmdHealth(ctx) {
|
|
|
148
152
|
const roadmapPath = join(planningDir, 'ROADMAP.md');
|
|
149
153
|
const phasesDir = join(planningDir, 'phases');
|
|
150
154
|
const roadmap = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : null;
|
|
151
|
-
const
|
|
155
|
+
const lifecycle = evaluateLifecycleState({ planningDir });
|
|
152
156
|
|
|
153
157
|
if (roadmap && existsSync(phasesDir)) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
if (line.includes('</details>')) {
|
|
162
|
-
inDetails = false;
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (inDetails) continue;
|
|
166
|
-
const match = line.match(/^\s*[-*]\s*\[([x-])\]\s*\*\*Phase\s+(\d+[a-z]?)/i);
|
|
167
|
-
if (match) phaseLabels.push(normalizePhaseLabel(match[2]));
|
|
168
|
-
}
|
|
169
|
-
for (const label of phaseLabels) {
|
|
170
|
-
const hasFile = phaseArtifacts.some((artifact) => normalizePhaseLabel(artifact.phasePrefix) === label);
|
|
171
|
-
if (!hasFile) {
|
|
172
|
-
warnings.push({ id: 'W4', severity: 'WARN', message: `ROADMAP.md references active Phase ${label} but no files found in .planning/phases/`, fix: 'Create missing phase dirs or update ROADMAP' });
|
|
173
|
-
}
|
|
158
|
+
for (const phase of lifecycle.phases.filter((entry) => entry.status !== 'not_started' && !entry.hasArtifacts)) {
|
|
159
|
+
warnings.push({
|
|
160
|
+
id: 'W4',
|
|
161
|
+
severity: 'WARN',
|
|
162
|
+
message: `ROADMAP.md references active Phase ${phase.number} but no files found in .planning/phases/`,
|
|
163
|
+
fix: 'Create missing phase dirs or update ROADMAP',
|
|
164
|
+
});
|
|
174
165
|
}
|
|
175
166
|
}
|
|
176
167
|
|
|
177
168
|
// W5: Phase dir has PLAN but no SUMMARY (stale in-progress)
|
|
178
|
-
if (
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
);
|
|
187
|
-
if (!hasSummary) {
|
|
188
|
-
warnings.push({ id: 'W5', severity: 'WARN', message: `${plan.displayPath} exists but no matching SUMMARY found (stale in-progress?)`, fix: 'Resume or complete the phase' });
|
|
189
|
-
}
|
|
169
|
+
if (lifecycle.incompletePlans.length > 0) {
|
|
170
|
+
for (const plan of lifecycle.incompletePlans) {
|
|
171
|
+
warnings.push({
|
|
172
|
+
id: 'W5',
|
|
173
|
+
severity: 'WARN',
|
|
174
|
+
message: `${plan.displayPath} exists but no matching SUMMARY found (stale in-progress?)`,
|
|
175
|
+
fix: 'Resume or complete the phase',
|
|
176
|
+
});
|
|
190
177
|
}
|
|
191
178
|
}
|
|
192
179
|
|
|
@@ -202,7 +189,11 @@ export function createCmdHealth(ctx) {
|
|
|
202
189
|
warnings.push({ id: 'W6', severity: 'WARN', message: 'No adapter surfaces detected', fix: 'Run `gsdd init --tools <platform>`' });
|
|
203
190
|
}
|
|
204
191
|
|
|
205
|
-
|
|
192
|
+
const runtimeFreshnessReport = configOk && Array.isArray(ctx.workflows)
|
|
193
|
+
? evaluateRuntimeFreshness({ cwd, workflows: ctx.workflows })
|
|
194
|
+
: null;
|
|
195
|
+
|
|
196
|
+
warnings.push(...runTruthChecks(planningDir, cwd, healthCheckIds, { runtimeFreshnessReport }));
|
|
206
197
|
|
|
207
198
|
// --- INFO checks ---
|
|
208
199
|
|
|
@@ -212,20 +203,12 @@ export function createCmdHealth(ctx) {
|
|
|
212
203
|
}
|
|
213
204
|
|
|
214
205
|
// I2: Phase completion count
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (match) {
|
|
222
|
-
total++;
|
|
223
|
-
if (match[1] === 'x') done++;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
if (total > 0) {
|
|
227
|
-
info.push({ id: 'I2', severity: 'INFO', message: `Phases: ${done}/${total} completed` });
|
|
228
|
-
}
|
|
206
|
+
if (lifecycle.counts.total > 0) {
|
|
207
|
+
info.push({
|
|
208
|
+
id: 'I2',
|
|
209
|
+
severity: 'INFO',
|
|
210
|
+
message: `Phases: ${lifecycle.counts.completed}/${lifecycle.counts.total} completed`,
|
|
211
|
+
});
|
|
229
212
|
}
|
|
230
213
|
|
|
231
214
|
// I3: Which adapters are installed
|
|
@@ -268,34 +251,3 @@ function isFrameworkSourceRepo(cwd) {
|
|
|
268
251
|
return existsSync(join(cwd, 'distilled', 'templates')) && existsSync(join(cwd, 'distilled', 'workflows'));
|
|
269
252
|
}
|
|
270
253
|
|
|
271
|
-
function normalizePhaseLabel(value) {
|
|
272
|
-
if (!value) return null;
|
|
273
|
-
return value.toLowerCase().replace(/^0+(\d)/, '$1');
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function listPhaseArtifacts(phasesDir) {
|
|
277
|
-
const artifacts = [];
|
|
278
|
-
for (const entry of readdirSync(phasesDir, { withFileTypes: true })) {
|
|
279
|
-
const entryPath = join(phasesDir, entry.name);
|
|
280
|
-
if (entry.isFile()) {
|
|
281
|
-
artifacts.push(createPhaseArtifact('', entry.name));
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
if (!entry.isDirectory()) continue;
|
|
285
|
-
for (const child of readdirSync(entryPath, { withFileTypes: true })) {
|
|
286
|
-
if (child.isFile()) {
|
|
287
|
-
artifacts.push(createPhaseArtifact(entry.name, child.name));
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return artifacts;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function createPhaseArtifact(dir, name) {
|
|
295
|
-
return {
|
|
296
|
-
dir,
|
|
297
|
-
name,
|
|
298
|
-
displayPath: dir ? `${dir}/${name}` : name,
|
|
299
|
-
phasePrefix: name.match(/^(\d+[a-z]?(?:\.\d+)?)-/i)?.[1] || dir.match(/^(\d+[a-z]?(?:\.\d+)?)-/i)?.[1] || null,
|
|
300
|
-
};
|
|
301
|
-
}
|
package/bin/lib/init-runtime.mjs
CHANGED
|
@@ -2,37 +2,37 @@ const RUNTIME_OPTIONS = [
|
|
|
2
2
|
{
|
|
3
3
|
id: 'claude',
|
|
4
4
|
label: 'Claude Code',
|
|
5
|
-
description: 'Directly validated native skills, commands, and agents',
|
|
5
|
+
description: 'Directly validated native skills, commands, and agents with local freshness checks',
|
|
6
6
|
kind: 'native',
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
id: 'opencode',
|
|
10
10
|
label: 'OpenCode',
|
|
11
|
-
description: 'Directly validated native slash commands and agents',
|
|
11
|
+
description: 'Directly validated native slash commands and agents with local freshness checks',
|
|
12
12
|
kind: 'native',
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
id: 'codex',
|
|
16
16
|
label: 'Codex CLI',
|
|
17
|
-
description: 'Directly validated portable skills plus native checker agents',
|
|
17
|
+
description: 'Directly validated portable skills plus native checker agents with local freshness checks',
|
|
18
18
|
kind: 'native',
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
id: 'cursor',
|
|
22
22
|
label: 'Cursor',
|
|
23
|
-
description: 'Qualified support via skills-native slash commands from .agents/skills/',
|
|
23
|
+
description: 'Qualified support via skills-native slash commands from .agents/skills/ with the same local freshness checks',
|
|
24
24
|
kind: 'skills_native',
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
id: 'copilot',
|
|
28
28
|
label: 'GitHub Copilot',
|
|
29
|
-
description: 'Qualified support via skills-native slash commands from .agents/skills/',
|
|
29
|
+
description: 'Qualified support via skills-native slash commands from .agents/skills/ with the same local freshness checks',
|
|
30
30
|
kind: 'skills_native',
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
id: 'gemini',
|
|
34
34
|
label: 'Gemini CLI',
|
|
35
|
-
description: 'Qualified support via skills-native slash commands from .agents/skills/',
|
|
35
|
+
description: 'Qualified support via skills-native slash commands from .agents/skills/ with the same local freshness checks',
|
|
36
36
|
kind: 'skills_native',
|
|
37
37
|
},
|
|
38
38
|
];
|
|
@@ -161,25 +161,31 @@ export function getPostInitRoutingLines(selectedRuntimes) {
|
|
|
161
161
|
|
|
162
162
|
export function getHelpText() {
|
|
163
163
|
return `
|
|
164
|
-
gsdd -
|
|
165
|
-
|
|
164
|
+
gsdd - Workspine CLI
|
|
165
|
+
Portable multi-runtime software delivery framework for AI coding agents.
|
|
166
166
|
|
|
167
167
|
Usage: gsdd <command> [args]
|
|
168
168
|
|
|
169
|
-
Commands:
|
|
170
|
-
init [--tools <platform>] [--auto] [--brief <file>]
|
|
171
|
-
Launch guided install wizard in TTYs, or use --tools for manual/headless setup
|
|
172
|
-
--auto: non-interactive mode with smart defaults (requires --tools)
|
|
173
|
-
--brief <file>: copy project brief to .planning/PROJECT_BRIEF.md
|
|
169
|
+
Commands:
|
|
170
|
+
init [--tools <platform>] [--auto] [--brief <file>]
|
|
171
|
+
Launch guided install wizard in TTYs, or use --tools for manual/headless setup
|
|
172
|
+
--auto: non-interactive mode with smart defaults (requires --tools)
|
|
173
|
+
--brief <file>: copy project brief to .planning/PROJECT_BRIEF.md
|
|
174
174
|
update [--tools <platform>] [--templates] [--dry]
|
|
175
175
|
Regenerate adapters from latest framework sources
|
|
176
176
|
--templates: also refresh .planning/templates/ and roles
|
|
177
177
|
--dry: preview changes without writing files
|
|
178
|
-
health [--json] Check workspace integrity (healthy/degraded/broken)
|
|
179
|
-
models [subcommand] Inspect or update model profile / runtime overrides
|
|
180
|
-
find-phase [N] Show phase info as JSON (for agent consumption)
|
|
181
|
-
verify <N> Run artifact checks for phase N
|
|
182
|
-
scaffold phase <N> [name] Create a new phase plan file
|
|
178
|
+
health [--json] Check workspace integrity (healthy/degraded/broken)
|
|
179
|
+
models [subcommand] Inspect or update model profile / runtime overrides
|
|
180
|
+
find-phase [N] Show phase info as JSON (for agent consumption)
|
|
181
|
+
verify <N> Run artifact checks for phase N
|
|
182
|
+
scaffold phase <N> [name] Create a new phase plan file
|
|
183
|
+
file-op <copy|delete|regex-sub>
|
|
184
|
+
Run deterministic workspace-confined file copy/delete/text mutation
|
|
185
|
+
phase-status <N> <status> Update ROADMAP.md phase status ([ ] / [-] / [x])
|
|
186
|
+
lifecycle-preflight <surface> [phase]
|
|
187
|
+
Inspect deterministic lifecycle gate results for a workflow surface
|
|
188
|
+
help Show this summary
|
|
183
189
|
|
|
184
190
|
Platforms (for --tools):
|
|
185
191
|
claude Generate Claude Code skills (.claude/skills/gsdd-*), commands (.claude/commands/gsdd-*.md), and native agents (.claude/agents/gsdd-*.md)
|
|
@@ -193,12 +199,14 @@ Platforms (for --tools):
|
|
|
193
199
|
|
|
194
200
|
Notes:
|
|
195
201
|
- init always generates open-standard skills at .agents/skills/gsdd-* (portable workflow entrypoints)
|
|
202
|
+
- Workspine is the public product name; the retained package, command, workflow, and workspace contracts stay gsdd-cli, gsdd, gsdd-*, and .planning/
|
|
196
203
|
- running plain \`gsdd init\` in a terminal opens the guided runtime-selection wizard
|
|
197
204
|
- the wizard lets you pick runtimes first, then separately decide whether repo-wide AGENTS.md governance is worth installing
|
|
205
|
+
- \`gsdd health\` compares any installed generated runtime surfaces against current render output and points back to \`gsdd update\` when they drift
|
|
198
206
|
- directly validated launch surfaces in this repo are Claude Code, OpenCode, and Codex CLI
|
|
199
207
|
- Cursor, Copilot, and Gemini are qualified support through the shared .agents/skills/ surface plus optional governance
|
|
200
208
|
- --tools remains the advanced/manual path and preserves legacy runtime aliases for backward compatibility
|
|
201
|
-
- --tools codex generates .codex/agents/gsdd-plan-checker.toml (portable skill is the entry surface)
|
|
209
|
+
- --tools codex generates .codex/agents/gsdd-plan-checker.toml (portable skill is the entry surface; $gsdd-plan is plan-only until explicit $gsdd-execute)
|
|
202
210
|
- root AGENTS.md is only written on init when explicitly requested via --tools agents, --tools all, or the wizard governance opt-in
|
|
203
211
|
|
|
204
212
|
Examples:
|
|
@@ -219,8 +227,20 @@ Examples:
|
|
|
219
227
|
npx gsdd-cli verify 1
|
|
220
228
|
npx gsdd-cli scaffold phase 4 Payments
|
|
221
229
|
|
|
222
|
-
Workflows (run via skills/adapters generated by init, not direct CLI):
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
230
|
+
Workflows (run via skills/adapters generated by init, not direct CLI):
|
|
231
|
+
gsdd-new-project Full initializer: questioning, brownfield audit, research, spec, roadmap
|
|
232
|
+
gsdd-map-codebase Map or refresh brownfield codebase context
|
|
233
|
+
gsdd-plan Research, plan, and fresh-context plan check for a phase
|
|
234
|
+
gsdd-execute Execute a phase plan and write phase summaries
|
|
235
|
+
gsdd-verify Verify a completed phase with 3-level checks
|
|
236
|
+
gsdd-verify-work Conversational UAT validation for user-facing behavior
|
|
237
|
+
gsdd-audit-milestone Cross-phase integration, requirements coverage, and E2E audit
|
|
238
|
+
gsdd-complete-milestone Archive a shipped milestone and collapse roadmap state
|
|
239
|
+
gsdd-new-milestone Start the next milestone with goals, requirements, and phases
|
|
240
|
+
gsdd-plan-milestone-gaps Turn milestone-audit gaps into closure phases
|
|
241
|
+
gsdd-quick Bounded brownfield lane for sub-hour work
|
|
242
|
+
gsdd-pause Save session context to checkpoint
|
|
243
|
+
gsdd-resume Restore context and route to the next action
|
|
244
|
+
gsdd-progress Read-only status and routing surface
|
|
245
|
+
`;
|
|
246
|
+
}
|
package/bin/lib/init.mjs
CHANGED
|
@@ -4,13 +4,9 @@ import { createCmdInit, createCmdUpdate } from './init-flow.mjs';
|
|
|
4
4
|
import { createInitPromptApi, promptChoiceList } from './init-prompts.mjs';
|
|
5
5
|
import { buildRuntimeChoices, detectPlatforms, getHelpText, normalizeRequestedTools } from './init-runtime.mjs';
|
|
6
6
|
|
|
7
|
-
function cmdHelp() {
|
|
8
|
-
console.log(
|
|
9
|
-
|
|
10
|
-
Run deterministic workspace-confined file copy/delete/text mutation
|
|
11
|
-
phase-status <N> <status> Update ROADMAP.md phase status ([ ] / [-] / [x])
|
|
12
|
-
`);
|
|
13
|
-
}
|
|
7
|
+
function cmdHelp() {
|
|
8
|
+
console.log(getHelpText().trimEnd());
|
|
9
|
+
}
|
|
14
10
|
|
|
15
11
|
export {
|
|
16
12
|
buildRuntimeChoices,
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { output } from './cli-utils.mjs';
|
|
4
|
+
import { describeEvidenceSurface } from './evidence-contract.mjs';
|
|
5
|
+
import { evaluateLifecycleState, normalizePhaseToken } from './lifecycle-state.mjs';
|
|
6
|
+
import { checkDrift } from './session-fingerprint.mjs';
|
|
7
|
+
|
|
8
|
+
const SURFACE_POLICIES = {
|
|
9
|
+
progress: {
|
|
10
|
+
classification: 'read_only',
|
|
11
|
+
ownedWrites: [],
|
|
12
|
+
explicitLifecycleMutation: 'none',
|
|
13
|
+
},
|
|
14
|
+
execute: {
|
|
15
|
+
classification: 'owned_write',
|
|
16
|
+
ownedWrites: ['summary'],
|
|
17
|
+
explicitLifecycleMutation: 'phase-status',
|
|
18
|
+
phaseRequired: true,
|
|
19
|
+
},
|
|
20
|
+
verify: {
|
|
21
|
+
classification: 'owned_write',
|
|
22
|
+
ownedWrites: ['verification'],
|
|
23
|
+
explicitLifecycleMutation: 'phase-status',
|
|
24
|
+
phaseRequired: true,
|
|
25
|
+
},
|
|
26
|
+
'audit-milestone': {
|
|
27
|
+
classification: 'owned_write',
|
|
28
|
+
ownedWrites: ['milestone-audit'],
|
|
29
|
+
explicitLifecycleMutation: 'none',
|
|
30
|
+
},
|
|
31
|
+
'complete-milestone': {
|
|
32
|
+
classification: 'owned_write',
|
|
33
|
+
ownedWrites: ['milestone-archives', 'milestones-ledger', 'spec', 'roadmap'],
|
|
34
|
+
explicitLifecycleMutation: 'none',
|
|
35
|
+
},
|
|
36
|
+
'new-milestone': {
|
|
37
|
+
classification: 'owned_write',
|
|
38
|
+
ownedWrites: ['spec', 'roadmap', 'phase-directories'],
|
|
39
|
+
explicitLifecycleMutation: 'none',
|
|
40
|
+
},
|
|
41
|
+
resume: {
|
|
42
|
+
classification: 'owned_write',
|
|
43
|
+
ownedWrites: ['checkpoint-cleanup'],
|
|
44
|
+
explicitLifecycleMutation: 'none',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function evaluateLifecyclePreflight({
|
|
49
|
+
planningDir,
|
|
50
|
+
surface,
|
|
51
|
+
phaseNumber = null,
|
|
52
|
+
expectsMutation = 'none',
|
|
53
|
+
} = {}) {
|
|
54
|
+
if (!planningDir) {
|
|
55
|
+
throw new Error('planningDir is required');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const policy = SURFACE_POLICIES[surface];
|
|
59
|
+
if (!policy) {
|
|
60
|
+
throw new Error(`Unsupported lifecycle surface: ${surface}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lifecycle = evaluateLifecycleState({ planningDir });
|
|
64
|
+
const normalizedPhase = phaseNumber ? normalizePhaseToken(phaseNumber) : null;
|
|
65
|
+
const checkpointPath = join(planningDir, '.continue-here.md');
|
|
66
|
+
const specPath = join(planningDir, 'SPEC.md');
|
|
67
|
+
const milestonesPath = join(planningDir, 'MILESTONES.md');
|
|
68
|
+
const blockers = [];
|
|
69
|
+
|
|
70
|
+
if (!existsSync(planningDir)) {
|
|
71
|
+
blockers.push(blocker('missing_planning_dir', '.planning/ does not exist yet.', ['.planning/']));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (expectsMutation !== 'none' && expectsMutation !== policy.explicitLifecycleMutation) {
|
|
75
|
+
blockers.push(
|
|
76
|
+
blocker(
|
|
77
|
+
'illegal_lifecycle_mutation',
|
|
78
|
+
`${surface} is classified as ${policy.classification} and cannot mutate lifecycle state via ${expectsMutation}.`,
|
|
79
|
+
[]
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (policy.phaseRequired && !normalizedPhase) {
|
|
85
|
+
blockers.push(blocker('missing_phase_argument', `${surface} requires an explicit phase number.`, []));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (normalizedPhase) {
|
|
89
|
+
blockers.push(...buildPhaseBlockers({ lifecycle, phaseToken: normalizedPhase, surface }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (surface === 'audit-milestone') {
|
|
93
|
+
blockers.push(...buildAuditBlockers(lifecycle));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (surface === 'complete-milestone') {
|
|
97
|
+
blockers.push(...buildAuditBlockers(lifecycle, { allowArchivedBlocker: true }));
|
|
98
|
+
blockers.push(...buildCompletionBlockers(planningDir, lifecycle));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (surface === 'new-milestone') {
|
|
102
|
+
if (!existsSync(specPath)) {
|
|
103
|
+
blockers.push(blocker('missing_spec', 'SPEC.md is required before starting a new milestone.', ['.planning/SPEC.md']));
|
|
104
|
+
}
|
|
105
|
+
if (!existsSync(milestonesPath)) {
|
|
106
|
+
blockers.push(blocker('missing_milestones', 'MILESTONES.md is required before starting a new milestone.', ['.planning/MILESTONES.md']));
|
|
107
|
+
}
|
|
108
|
+
if (lifecycle.currentMilestone.version && lifecycle.currentMilestone.archiveState !== 'archived') {
|
|
109
|
+
blockers.push(
|
|
110
|
+
blocker(
|
|
111
|
+
'active_milestone_in_progress',
|
|
112
|
+
`Milestone ${lifecycle.currentMilestone.version} is still active. Archive or remove the active roadmap before starting the next milestone.`,
|
|
113
|
+
['.planning/ROADMAP.md']
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (surface === 'resume' && !existsSync(checkpointPath)) {
|
|
120
|
+
blockers.push(blocker('missing_checkpoint', 'resume requires .planning/.continue-here.md to exist.', ['.planning/.continue-here.md']));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const warnings = [];
|
|
124
|
+
|
|
125
|
+
if (existsSync(planningDir)) {
|
|
126
|
+
const drift = checkDrift(planningDir);
|
|
127
|
+
if (drift.drifted) {
|
|
128
|
+
warnings.push({
|
|
129
|
+
code: 'planning_state_drift',
|
|
130
|
+
message: `Planning state has drifted since the last recorded session: ${drift.details.join('; ')}`,
|
|
131
|
+
artifacts: ['.planning/ROADMAP.md', '.planning/SPEC.md', '.planning/config.json'],
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
surface,
|
|
138
|
+
phase: normalizedPhase,
|
|
139
|
+
classification: policy.classification,
|
|
140
|
+
ownedWrites: policy.ownedWrites,
|
|
141
|
+
explicitLifecycleMutation: policy.explicitLifecycleMutation,
|
|
142
|
+
closureEvidence: describeEvidenceSurface(surface),
|
|
143
|
+
mutationRequest: expectsMutation,
|
|
144
|
+
allowed: blockers.length === 0,
|
|
145
|
+
status: blockers.length === 0 ? 'allowed' : 'blocked',
|
|
146
|
+
reason: blockers[0]?.code ?? null,
|
|
147
|
+
blockers,
|
|
148
|
+
warnings,
|
|
149
|
+
lifecycle: {
|
|
150
|
+
currentMilestone: lifecycle.currentMilestone,
|
|
151
|
+
currentPhase: lifecycle.currentPhase ? lifecycle.currentPhase.number : null,
|
|
152
|
+
nextPhase: lifecycle.nextPhase ? lifecycle.nextPhase.number : null,
|
|
153
|
+
counts: lifecycle.counts,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildPhaseBlockers({ lifecycle, phaseToken, surface }) {
|
|
159
|
+
const blockers = [];
|
|
160
|
+
const phaseEntry = lifecycle.phases.find((phase) => phase.number === phaseToken);
|
|
161
|
+
if (!phaseEntry) {
|
|
162
|
+
blockers.push(
|
|
163
|
+
blocker(
|
|
164
|
+
'missing_phase',
|
|
165
|
+
`Phase ${phaseToken} was not found in the active roadmap.`,
|
|
166
|
+
['.planning/ROADMAP.md']
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
return blockers;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const planArtifacts = lifecycle.phaseArtifacts.filter((artifact) => artifact.phaseToken === phaseToken && artifact.kind === 'plan');
|
|
173
|
+
const summaryArtifacts = lifecycle.phaseArtifacts.filter((artifact) => artifact.phaseToken === phaseToken && artifact.kind === 'summary');
|
|
174
|
+
const pendingPlans = planArtifacts.filter(
|
|
175
|
+
(artifact) => !summaryArtifacts.some((candidate) => candidate.dir === artifact.dir && candidate.baseId === artifact.baseId)
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (surface === 'execute') {
|
|
179
|
+
if (planArtifacts.length === 0) {
|
|
180
|
+
blockers.push(
|
|
181
|
+
blocker(
|
|
182
|
+
'missing_plan',
|
|
183
|
+
`Phase ${phaseToken} cannot execute because no PLAN artifact exists.`,
|
|
184
|
+
['.planning/phases/']
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
} else if (pendingPlans.length === 0) {
|
|
188
|
+
blockers.push(
|
|
189
|
+
blocker(
|
|
190
|
+
'no_pending_plan',
|
|
191
|
+
`Phase ${phaseToken} has no pending PLAN artifacts left to execute.`,
|
|
192
|
+
planArtifacts.map((artifact) => artifact.displayPath)
|
|
193
|
+
)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (surface === 'verify') {
|
|
199
|
+
if (planArtifacts.length === 0) {
|
|
200
|
+
blockers.push(
|
|
201
|
+
blocker(
|
|
202
|
+
'missing_plan',
|
|
203
|
+
`Phase ${phaseToken} cannot be verified because no PLAN artifact exists.`,
|
|
204
|
+
['.planning/phases/']
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (summaryArtifacts.length === 0) {
|
|
209
|
+
blockers.push(
|
|
210
|
+
blocker(
|
|
211
|
+
'missing_summary',
|
|
212
|
+
`Phase ${phaseToken} cannot be verified because no SUMMARY artifact exists yet.`,
|
|
213
|
+
['.planning/phases/']
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return blockers;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildAuditBlockers(lifecycle, { allowArchivedBlocker = false } = {}) {
|
|
223
|
+
const blockers = [];
|
|
224
|
+
if (!lifecycle.currentMilestone.version) {
|
|
225
|
+
blockers.push(blocker('missing_milestone', 'No active or retained milestone could be derived from ROADMAP.md.', ['.planning/ROADMAP.md']));
|
|
226
|
+
return blockers;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (lifecycle.currentMilestone.archiveState === 'archived') {
|
|
230
|
+
blockers.push(
|
|
231
|
+
blocker(
|
|
232
|
+
allowArchivedBlocker ? 'milestone_already_archived' : 'milestone_already_archived',
|
|
233
|
+
`Milestone ${lifecycle.currentMilestone.version} is already archived-with-ROADMAP.md evidence.`,
|
|
234
|
+
['.planning/ROADMAP.md', '.planning/MILESTONES.md']
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (lifecycle.counts.total === 0) {
|
|
240
|
+
blockers.push(blocker('missing_phases', 'No active milestone phases were found in ROADMAP.md.', ['.planning/ROADMAP.md']));
|
|
241
|
+
} else if (lifecycle.counts.completed !== lifecycle.counts.total) {
|
|
242
|
+
blockers.push(
|
|
243
|
+
blocker(
|
|
244
|
+
'incomplete_phases',
|
|
245
|
+
`Milestone ${lifecycle.currentMilestone.version} still has incomplete phases (${lifecycle.counts.completed}/${lifecycle.counts.total} complete).`,
|
|
246
|
+
['.planning/ROADMAP.md']
|
|
247
|
+
)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const phasesMissingVerification = lifecycle.phases
|
|
252
|
+
.filter((phase) => phase.status === 'done')
|
|
253
|
+
.filter((phase) => !phase.artifacts.some((artifact) => artifact.kind === 'verification'))
|
|
254
|
+
.map((phase) => phase.number);
|
|
255
|
+
|
|
256
|
+
if (phasesMissingVerification.length > 0) {
|
|
257
|
+
blockers.push(
|
|
258
|
+
blocker(
|
|
259
|
+
'missing_verification',
|
|
260
|
+
`Completed phases are missing VERIFICATION artifacts (${phasesMissingVerification.join(', ')}).`,
|
|
261
|
+
['.planning/phases/']
|
|
262
|
+
)
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return blockers;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function buildCompletionBlockers(planningDir, lifecycle) {
|
|
270
|
+
const auditPath = join(planningDir, `${lifecycle.currentMilestone.version}-MILESTONE-AUDIT.md`);
|
|
271
|
+
if (!existsSync(auditPath)) {
|
|
272
|
+
return [
|
|
273
|
+
blocker(
|
|
274
|
+
'missing_milestone_audit',
|
|
275
|
+
`Milestone ${lifecycle.currentMilestone.version} cannot be completed without a milestone audit artifact.`,
|
|
276
|
+
[auditPath]
|
|
277
|
+
),
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const auditContent = readFileSync(auditPath, 'utf-8');
|
|
282
|
+
const statusMatch = auditContent.match(/^status:\s*(.+)$/m);
|
|
283
|
+
if (!statusMatch || statusMatch[1].trim() !== 'passed') {
|
|
284
|
+
return [
|
|
285
|
+
blocker(
|
|
286
|
+
'audit_not_passed',
|
|
287
|
+
`Milestone ${lifecycle.currentMilestone.version} requires a passed audit before completion.`,
|
|
288
|
+
[auditPath]
|
|
289
|
+
),
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function blocker(code, message, artifacts) {
|
|
297
|
+
return { code, message, artifacts };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function cmdLifecyclePreflight(...args) {
|
|
301
|
+
const cwd = process.cwd();
|
|
302
|
+
const planningDir = join(cwd, '.planning');
|
|
303
|
+
const [surface, maybePhase, ...rest] = args;
|
|
304
|
+
|
|
305
|
+
if (!surface) {
|
|
306
|
+
console.error('Usage: gsdd lifecycle-preflight <surface> [phase] [--expects-mutation <none|phase-status>]');
|
|
307
|
+
process.exitCode = 1;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let phaseNumber = maybePhase && !maybePhase.startsWith('--') ? maybePhase : null;
|
|
312
|
+
let expectsMutation = 'none';
|
|
313
|
+
|
|
314
|
+
const flagArgs = phaseNumber ? rest : [maybePhase, ...rest].filter(Boolean);
|
|
315
|
+
for (let index = 0; index < flagArgs.length; index += 1) {
|
|
316
|
+
const arg = flagArgs[index];
|
|
317
|
+
if (arg === '--expects-mutation') {
|
|
318
|
+
expectsMutation = flagArgs[index + 1] ?? 'none';
|
|
319
|
+
index += 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const result = evaluateLifecyclePreflight({ planningDir, surface, phaseNumber, expectsMutation });
|
|
325
|
+
output(result);
|
|
326
|
+
if (!result.allowed) {
|
|
327
|
+
process.exitCode = 1;
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error(error.message);
|
|
331
|
+
process.exitCode = 1;
|
|
332
|
+
}
|
|
333
|
+
}
|