gsdd-cli 0.25.0 → 0.27.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 +98 -548
- package/bin/adapters/claude.mjs +18 -8
- package/bin/adapters/opencode.mjs +3 -3
- package/bin/gsdd.mjs +13 -30
- package/bin/lib/closeout-report.mjs +32 -6
- package/bin/lib/control-map.mjs +94 -19
- package/bin/lib/global-install.mjs +629 -0
- package/bin/lib/global-manifest.mjs +122 -0
- package/bin/lib/health.mjs +2 -1
- package/bin/lib/init-flow.mjs +3 -0
- package/bin/lib/init-prompts.mjs +6 -4
- package/bin/lib/init-runtime.mjs +28 -2
- package/bin/lib/lifecycle-preflight.mjs +211 -5
- package/bin/lib/models.mjs +136 -5
- package/bin/lib/next.mjs +834 -0
- package/bin/lib/work-context.mjs +760 -0
- package/bin/lib/workflows.mjs +27 -0
- package/distilled/README.md +30 -3
- package/distilled/templates/agents.block.md +1 -1
- package/distilled/templates/agents.md +0 -1
- package/distilled/templates/codebase/architecture.md +0 -1
- package/distilled/templates/codebase/concerns.md +0 -1
- package/distilled/templates/codebase/conventions.md +0 -1
- package/distilled/templates/codebase/stack.md +0 -1
- package/distilled/templates/roadmap.md +0 -1
- package/distilled/templates/spec.md +0 -1
- package/distilled/workflows/new-project.md +1 -1
- package/docs/RUNTIME-SUPPORT.md +23 -3
- package/docs/USER-GUIDE.md +43 -4
- package/docs/VERIFICATION-DISCIPLINE.md +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'path';
|
|
4
|
+
|
|
5
|
+
export const GLOBAL_MANIFEST_FILENAME = 'workspine-file-manifest.json';
|
|
6
|
+
|
|
7
|
+
export function sha256(content) {
|
|
8
|
+
return createHash('sha256').update(content).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fileHash(filePath) {
|
|
12
|
+
return sha256(readFileSync(filePath));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readGlobalManifest(rootDir) {
|
|
16
|
+
const manifestPath = join(rootDir, GLOBAL_MANIFEST_FILENAME);
|
|
17
|
+
if (!existsSync(manifestPath)) return null;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function writeGlobalManifest(rootDir, manifest) {
|
|
27
|
+
mkdirSync(rootDir, { recursive: true });
|
|
28
|
+
writeFileSync(join(rootDir, GLOBAL_MANIFEST_FILENAME), JSON.stringify(manifest, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toManifestPath(rootDir, absolutePath) {
|
|
32
|
+
return relative(rootDir, absolutePath).replace(/\\/g, '/');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeManifestTrackedFile({
|
|
36
|
+
rootDir,
|
|
37
|
+
relativePath,
|
|
38
|
+
content,
|
|
39
|
+
previousManifest,
|
|
40
|
+
nextFiles,
|
|
41
|
+
dryRun = false,
|
|
42
|
+
}) {
|
|
43
|
+
const absolutePath = join(rootDir, relativePath);
|
|
44
|
+
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
|
|
45
|
+
const expectedHash = sha256(content);
|
|
46
|
+
const previousHash = previousManifest?.files?.[normalizedRelativePath] || null;
|
|
47
|
+
|
|
48
|
+
if (existsSync(absolutePath)) {
|
|
49
|
+
const currentHash = fileHash(absolutePath);
|
|
50
|
+
if (currentHash === expectedHash) {
|
|
51
|
+
nextFiles[normalizedRelativePath] = expectedHash;
|
|
52
|
+
return { relativePath: normalizedRelativePath, status: 'unchanged' };
|
|
53
|
+
}
|
|
54
|
+
if (!previousHash) {
|
|
55
|
+
return {
|
|
56
|
+
relativePath: normalizedRelativePath,
|
|
57
|
+
status: 'skipped_unmanaged',
|
|
58
|
+
message: 'existing file is not tracked by Workspine manifest',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (currentHash !== previousHash) {
|
|
62
|
+
return {
|
|
63
|
+
relativePath: normalizedRelativePath,
|
|
64
|
+
status: 'skipped_modified',
|
|
65
|
+
message: 'existing Workspine-managed file was modified by the user',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
nextFiles[normalizedRelativePath] = expectedHash;
|
|
71
|
+
if (!dryRun) {
|
|
72
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
73
|
+
writeFileSync(absolutePath, content);
|
|
74
|
+
}
|
|
75
|
+
return { relativePath: normalizedRelativePath, status: dryRun ? 'would_write' : 'written' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function pruneStaleManifestTrackedFiles({
|
|
79
|
+
rootDir,
|
|
80
|
+
previousManifest,
|
|
81
|
+
nextFiles,
|
|
82
|
+
dryRun = false,
|
|
83
|
+
}) {
|
|
84
|
+
if (!previousManifest?.files) return [];
|
|
85
|
+
|
|
86
|
+
const root = resolve(rootDir);
|
|
87
|
+
const results = [];
|
|
88
|
+
for (const [relativePath, previousHash] of Object.entries(previousManifest.files)) {
|
|
89
|
+
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
|
|
90
|
+
if (nextFiles[normalizedRelativePath]) continue;
|
|
91
|
+
|
|
92
|
+
const absolutePath = resolve(rootDir, normalizedRelativePath);
|
|
93
|
+
if (absolutePath !== root && !absolutePath.startsWith(`${root}\\`) && !absolutePath.startsWith(`${root}/`)) {
|
|
94
|
+
results.push({
|
|
95
|
+
relativePath: normalizedRelativePath,
|
|
96
|
+
status: 'skipped_unsafe',
|
|
97
|
+
message: 'previous manifest path resolves outside the install root',
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!existsSync(absolutePath)) {
|
|
103
|
+
results.push({ relativePath: normalizedRelativePath, status: 'removed_missing' });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const currentHash = fileHash(absolutePath);
|
|
108
|
+
if (currentHash !== previousHash) {
|
|
109
|
+
results.push({
|
|
110
|
+
relativePath: normalizedRelativePath,
|
|
111
|
+
status: 'skipped_modified',
|
|
112
|
+
message: 'stale Workspine-managed file was modified by the user',
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!dryRun) rmSync(absolutePath, { force: true });
|
|
118
|
+
results.push({ relativePath: normalizedRelativePath, status: dryRun ? 'would_remove' : 'removed_stale' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return results;
|
|
122
|
+
}
|
package/bin/lib/health.mjs
CHANGED
|
@@ -208,11 +208,12 @@ export function buildHealthReport(ctx, healthArgs = []) {
|
|
|
208
208
|
// W5: Phase dir has PLAN but no SUMMARY (stale in-progress)
|
|
209
209
|
if (lifecycle.incompletePlans.length > 0) {
|
|
210
210
|
for (const plan of lifecycle.incompletePlans) {
|
|
211
|
+
const expectedSummary = `.planning/phases/${plan.dir}/${plan.baseId}-SUMMARY.md`;
|
|
211
212
|
warnings.push({
|
|
212
213
|
id: 'W5',
|
|
213
214
|
severity: 'WARN',
|
|
214
215
|
message: `${plan.displayPath} exists but no matching SUMMARY found (stale in-progress?)`,
|
|
215
|
-
fix:
|
|
216
|
+
fix: `Run \`gsdd execute ${plan.phaseToken}\` to write ${expectedSummary}.`,
|
|
216
217
|
});
|
|
217
218
|
}
|
|
218
219
|
}
|
package/bin/lib/init-flow.mjs
CHANGED
|
@@ -320,6 +320,7 @@ function ensureGitignoreEntry(cwd, entry, message) {
|
|
|
320
320
|
|
|
321
321
|
function printInitSummary(config) {
|
|
322
322
|
console.log('Config summary:');
|
|
323
|
+
console.log(` - rigorProfile: ${config.rigorProfile}`);
|
|
323
324
|
console.log(` - researchDepth: ${config.researchDepth}`);
|
|
324
325
|
console.log(` - parallelization: ${config.parallelization}`);
|
|
325
326
|
console.log(` - commitDocs: ${config.commitDocs}`);
|
|
@@ -330,6 +331,8 @@ function printInitSummary(config) {
|
|
|
330
331
|
console.log(` - workflow.discuss: ${config.workflow.discuss}`);
|
|
331
332
|
console.log(` - workflow.planCheck: ${config.workflow.planCheck}`);
|
|
332
333
|
console.log(` - workflow.verifier: ${config.workflow.verifier}`);
|
|
334
|
+
console.log(` - workflow.showCode: ${config.workflow.showCode}`);
|
|
335
|
+
console.log(` - workflow.askBeforeDecide: ${config.workflow.askBeforeDecide}`);
|
|
333
336
|
}
|
|
334
337
|
console.log('');
|
|
335
338
|
}
|
package/bin/lib/init-prompts.mjs
CHANGED
|
@@ -54,11 +54,12 @@ export async function promptForConfig(cwd, { input = process.stdin, output = pro
|
|
|
54
54
|
const rigor = await promptSingleSelect({
|
|
55
55
|
input,
|
|
56
56
|
output,
|
|
57
|
-
title: 'Rigor',
|
|
57
|
+
title: 'Rigor - how much the assistant does on its own vs. shows you and asks',
|
|
58
58
|
choices: [
|
|
59
|
-
{ value: '
|
|
60
|
-
{ value: '
|
|
61
|
-
{ value: '
|
|
59
|
+
{ value: 'low', label: 'low', description: 'Autopilot. Skips research, plan-check, and asking - it proceeds on its best guess.' },
|
|
60
|
+
{ value: 'medium', label: 'medium', description: 'Recommended default. Research + a fresh-context plan-check. No helpers, no extra asking.' },
|
|
61
|
+
{ value: 'high', label: 'high', description: 'Helpers propose options; shows you the code before writing; asks when something is genuinely unclear.' },
|
|
62
|
+
{ value: 'max', label: 'max', description: 'Most hands-on. Asks before deciding and shows the running screen. Pick low if you just want it to go.' },
|
|
62
63
|
],
|
|
63
64
|
defaultIndex: 1,
|
|
64
65
|
});
|
|
@@ -88,6 +89,7 @@ export async function promptForConfig(cwd, { input = process.stdin, output = pro
|
|
|
88
89
|
const costConfig = resolveCost(cost);
|
|
89
90
|
|
|
90
91
|
return {
|
|
92
|
+
rigorProfile: rigor,
|
|
91
93
|
...rigorConfig,
|
|
92
94
|
...costConfig,
|
|
93
95
|
commitDocs,
|
package/bin/lib/init-runtime.mjs
CHANGED
|
@@ -172,11 +172,17 @@ Commands:
|
|
|
172
172
|
Launch guided install wizard in TTYs, or use --tools for manual/headless setup
|
|
173
173
|
--auto: non-interactive mode with smart defaults (requires --tools)
|
|
174
174
|
--brief <file>: copy project brief to .planning/PROJECT_BRIEF.md
|
|
175
|
+
install --global [--auto] [--tools <platform>] [--dry]
|
|
176
|
+
Install reusable Workspine skills and native runtime surfaces into agent home directories
|
|
177
|
+
--auto: non-interactive mode that installs detected local agent targets
|
|
178
|
+
In TTYs, omitting --tools opens an agent picker
|
|
175
179
|
update [--tools <platform>] [--templates] [--dry]
|
|
176
180
|
Regenerate adapters from latest framework sources
|
|
177
181
|
--templates: also refresh .planning/templates/ and roles
|
|
178
182
|
--dry: preview changes without writing files
|
|
179
183
|
health [--json] Check workspace integrity (healthy/degraded/broken)
|
|
184
|
+
next [--json] [--format auto|json|human] [--init]
|
|
185
|
+
Read \`.work\` continuity state and emit the next coherent agent action
|
|
180
186
|
models [subcommand] Inspect or update model profile / runtime overrides
|
|
181
187
|
find-phase [N] Show phase info as JSON (for agent consumption)
|
|
182
188
|
verify <N> Run artifact checks for phase N
|
|
@@ -210,19 +216,32 @@ Platforms (for --tools):
|
|
|
210
216
|
gemini Generate root AGENTS.md governance block; workflows are already discovered natively from .agents/skills/ (legacy alias kept for backward compatibility)
|
|
211
217
|
all Generate all adapters (Claude, OpenCode, Codex, AGENTS.md, Cursor, Copilot, Gemini)
|
|
212
218
|
|
|
219
|
+
Global install targets:
|
|
220
|
+
claude Install ~/.claude skills, commands, and agents
|
|
221
|
+
opencode Install ~/.agents skills plus ~/.config/opencode commands and agents
|
|
222
|
+
codex Install ~/.agents skills plus ~/.codex agents
|
|
223
|
+
copilot Install ~/.agents skills plus ~/.copilot agent profiles
|
|
224
|
+
all Install all global targets above
|
|
225
|
+
|
|
213
226
|
Notes:
|
|
227
|
+
- use \`npx -y gsdd-cli init\` for repo-local setup; use \`npx -y gsdd-cli install --global --auto\` when you want reusable skills in detected agent homes
|
|
214
228
|
- init always generates open-standard skills at .agents/skills/gsdd-*; this is the shared workflow entry surface
|
|
215
229
|
- init also generates a local .planning/bin/gsdd* helper surface for workflow-embedded lifecycle helpers; it is internal/advanced, not the normal first-run user entrypoint
|
|
230
|
+
- install --global never creates .planning/ in the current repo; it writes only selected agent-home surfaces and per-runtime Workspine manifests
|
|
231
|
+
- use \`npx -y gsdd-cli install --global --auto\` for non-interactive global install into detected agent homes; use \`--tools <targets>\` to override detection explicitly
|
|
232
|
+
- repair or refresh a global install by rerunning \`npx -y gsdd-cli install --global --auto\` or \`npx -y gsdd-cli install --global --tools <targets>\`; runtime probes stay in test harnesses
|
|
216
233
|
- Workspine is the public product name; the retained package, command, workflow, and workspace contracts stay gsdd-cli, gsdd, gsdd-*, and .planning/
|
|
217
234
|
- running \`npx -y gsdd-cli init\` in a terminal opens the guided runtime-selection wizard; bare \`gsdd init\` is equivalent only when globally installed
|
|
218
235
|
- the wizard lets you pick runtimes first, then separately decide whether repo-wide AGENTS.md governance is worth installing
|
|
219
|
-
- \`npx -y gsdd-cli health\`
|
|
236
|
+
- \`npx -y gsdd-cli health\` is for repo-local .planning/ workspaces; it compares local generated surfaces and points back to \`npx -y gsdd-cli update\` when they drift
|
|
237
|
+
- \`npx -y gsdd-cli next --init\` bootstraps the local .work continuity surface; plain \`next\` is read-only and emits a typed next-action packet
|
|
238
|
+
- \`gsdd next\` defaults to JSON when stdout is captured; use \`--format human\` for the compact supervisor card
|
|
220
239
|
- directly validated launch surfaces in this repo are Claude Code, OpenCode, and Codex CLI
|
|
221
240
|
- Cursor, Copilot, and Gemini are qualified support through the shared .agents/skills/ surface plus optional governance
|
|
222
241
|
- --tools remains the advanced/manual path and preserves legacy runtime aliases for backward compatibility
|
|
223
242
|
- --tools codex generates .codex/agents/gsdd-plan-checker.toml (portable skill is the entry surface; $gsdd-plan is plan-only until explicit $gsdd-execute)
|
|
224
243
|
- root AGENTS.md is only written on init when explicitly requested via --tools agents, --tools all, or the wizard governance opt-in
|
|
225
|
-
- normal
|
|
244
|
+
- normal repo path: npx -y gsdd-cli init -> run /gsdd-* or $gsdd-* -> npx -y gsdd-cli health -> npx -y gsdd-cli update when local repair or refresh is needed
|
|
226
245
|
- post-init, choose your starting lane honestly: new-project for greenfield or fuzzy/milestone work, quick for a concrete bounded change, map-codebase first when the repo needs deeper orientation
|
|
227
246
|
|
|
228
247
|
Examples:
|
|
@@ -238,7 +257,13 @@ Examples:
|
|
|
238
257
|
npx -y gsdd-cli models clear --runtime opencode --agent plan-checker
|
|
239
258
|
npx -y gsdd-cli init --tools agents
|
|
240
259
|
npx -y gsdd-cli init --tools all
|
|
260
|
+
npx -y gsdd-cli install --global
|
|
261
|
+
npx -y gsdd-cli install --global --auto
|
|
262
|
+
npx -y gsdd-cli install --global --tools claude,opencode,codex,copilot
|
|
241
263
|
npx -y gsdd-cli update
|
|
264
|
+
npx -y gsdd-cli next --json
|
|
265
|
+
npx -y gsdd-cli next --format human
|
|
266
|
+
npx -y gsdd-cli next --init
|
|
242
267
|
npx -y gsdd-cli find-phase
|
|
243
268
|
npx -y gsdd-cli verify 1
|
|
244
269
|
npx -y gsdd-cli control-map annotate set --id canonical --write-set src/app.ts
|
|
@@ -273,6 +298,7 @@ Advanced/internal helpers (kept available, but not the primary first-run user st
|
|
|
273
298
|
ui-proof Validate UI proof metadata and compare planned slots to observed bundles
|
|
274
299
|
control-map Report computed repo/worktree/planning state; annotate only records local intent
|
|
275
300
|
closeout-report Read-only post-merge closure replay; reports blockers, warnings, and next safe action
|
|
301
|
+
next Read-only \`.work\` continuity router for the next coherent agent action
|
|
276
302
|
file-op Deterministic workspace-confined file copy/delete/text mutation
|
|
277
303
|
`;
|
|
278
304
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'fs';
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
2
2
|
import { join, resolve } from 'path';
|
|
3
3
|
import { output } from './cli-utils.mjs';
|
|
4
4
|
import { buildControlMap } from './control-map.mjs';
|
|
@@ -76,6 +76,7 @@ const RELEASE_CONTRADICTION_CHECKS = Object.freeze([
|
|
|
76
76
|
|
|
77
77
|
const RELEASE_CONTRADICTION_STATUSES = Object.freeze(['passed', 'failed', 'not_applicable']);
|
|
78
78
|
const PREFLIGHT_CONTROL_MAP_SKIP_CODES = Object.freeze(['planning_state_drift']);
|
|
79
|
+
const WORK_PHASE_LINE_RE = /^\s*[-*]\s*\[([ x-])\]\s*\*\*Phase\s+(\d+(?:\.\d+)*[A-Za-z]?):\s*(.+?)\*\*/i;
|
|
79
80
|
|
|
80
81
|
export function evaluateLifecyclePreflight({
|
|
81
82
|
planningDir,
|
|
@@ -95,7 +96,12 @@ export function evaluateLifecyclePreflight({
|
|
|
95
96
|
|
|
96
97
|
const lifecycle = evaluateLifecycleState({ planningDir });
|
|
97
98
|
const normalizedPhase = phaseNumber ? normalizePhaseToken(phaseNumber) : null;
|
|
99
|
+
const workMilestone = normalizedPhase ? evaluateWorkMilestoneState({ planningDir, phaseToken: normalizedPhase }) : null;
|
|
98
100
|
const checkpointPath = join(planningDir, '.continue-here.md');
|
|
101
|
+
const resumeWorkCheckpoint = surface === 'resume'
|
|
102
|
+
? evaluateResumeWorkCheckpoint({ planningDir, checkpointPath })
|
|
103
|
+
: null;
|
|
104
|
+
const usesWorkAuthority = Boolean(workMilestone?.phaseEntry || resumeWorkCheckpoint);
|
|
99
105
|
const specPath = join(planningDir, 'SPEC.md');
|
|
100
106
|
const milestonesPath = join(planningDir, 'MILESTONES.md');
|
|
101
107
|
const blockers = [];
|
|
@@ -119,7 +125,11 @@ export function evaluateLifecyclePreflight({
|
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
if (normalizedPhase) {
|
|
122
|
-
blockers.push(
|
|
128
|
+
blockers.push(
|
|
129
|
+
...(usesWorkAuthority
|
|
130
|
+
? buildWorkPhaseBlockers({ workMilestone, phaseToken: normalizedPhase, surface })
|
|
131
|
+
: buildPhaseBlockers({ lifecycle, phaseToken: normalizedPhase, surface }))
|
|
132
|
+
);
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
if (surface === 'audit-milestone') {
|
|
@@ -176,12 +186,17 @@ export function evaluateLifecyclePreflight({
|
|
|
176
186
|
details: drift.details,
|
|
177
187
|
files: drift.files,
|
|
178
188
|
};
|
|
179
|
-
if (policy.classification === 'owned_write') {
|
|
189
|
+
if (policy.classification === 'owned_write' && !usesWorkAuthority) {
|
|
180
190
|
blockers.push(driftNotice);
|
|
181
191
|
} else {
|
|
192
|
+
const workWarningContext = resumeWorkCheckpoint
|
|
193
|
+
? 'because the checkpoint points at .work/milestone continuity'
|
|
194
|
+
: `by .work/milestone for Phase ${normalizedPhase}`;
|
|
182
195
|
warnings.push({
|
|
183
196
|
...driftNotice,
|
|
184
|
-
message:
|
|
197
|
+
message: usesWorkAuthority
|
|
198
|
+
? `Planning state has drifted since the last recorded session, but ${surface} is using work_milestone authority ${workWarningContext}: ${drift.details.join('; ')}`
|
|
199
|
+
: `Planning state has drifted since the last recorded session: ${drift.details.join('; ')}`,
|
|
185
200
|
});
|
|
186
201
|
}
|
|
187
202
|
}
|
|
@@ -198,7 +213,7 @@ export function evaluateLifecyclePreflight({
|
|
|
198
213
|
else warnings.push(notice);
|
|
199
214
|
}
|
|
200
215
|
|
|
201
|
-
if (lifecycle.phaseStatusAlignment.mismatches.length > 0) {
|
|
216
|
+
if (!usesWorkAuthority && lifecycle.phaseStatusAlignment.mismatches.length > 0) {
|
|
202
217
|
warnings.push({
|
|
203
218
|
code: 'roadmap_phase_status_mismatch',
|
|
204
219
|
message: `ROADMAP.md overview/detail phase statuses disagree: ${lifecycle.phaseStatusAlignment.mismatches.join('; ')}`,
|
|
@@ -214,6 +229,7 @@ export function evaluateLifecyclePreflight({
|
|
|
214
229
|
explicitLifecycleMutation: policy.explicitLifecycleMutation,
|
|
215
230
|
closureEvidence: describeEvidenceSurface(surface),
|
|
216
231
|
mutationRequest: expectsMutation,
|
|
232
|
+
authority: usesWorkAuthority ? 'work_milestone' : 'planning',
|
|
217
233
|
allowed: blockers.length === 0,
|
|
218
234
|
status: blockers.length === 0 ? 'allowed' : 'blocked',
|
|
219
235
|
reason: blockers[0]?.code ?? null,
|
|
@@ -222,10 +238,20 @@ export function evaluateLifecyclePreflight({
|
|
|
222
238
|
planningState,
|
|
223
239
|
controlMap: controlMap.summary,
|
|
224
240
|
lifecycle: {
|
|
241
|
+
authority: usesWorkAuthority ? 'work_milestone' : 'planning',
|
|
225
242
|
currentMilestone: lifecycle.currentMilestone,
|
|
226
243
|
currentPhase: lifecycle.currentPhase ? lifecycle.currentPhase.number : null,
|
|
227
244
|
nextPhase: lifecycle.nextPhase ? lifecycle.nextPhase.number : null,
|
|
228
245
|
counts: lifecycle.counts,
|
|
246
|
+
workMilestone: usesWorkAuthority
|
|
247
|
+
? {
|
|
248
|
+
phase: workMilestone?.phaseEntry?.number ?? null,
|
|
249
|
+
status: workMilestone?.phaseEntry?.status ?? null,
|
|
250
|
+
roadmapPath: '.work/milestone/ROADMAP.md',
|
|
251
|
+
milestoneDir: '.work/milestone',
|
|
252
|
+
source: resumeWorkCheckpoint ? 'checkpoint' : 'phase',
|
|
253
|
+
}
|
|
254
|
+
: null,
|
|
229
255
|
},
|
|
230
256
|
};
|
|
231
257
|
}
|
|
@@ -346,6 +372,186 @@ function buildPhaseBlockers({ lifecycle, phaseToken, surface }) {
|
|
|
346
372
|
return blockers;
|
|
347
373
|
}
|
|
348
374
|
|
|
375
|
+
function evaluateWorkMilestoneState({ planningDir, phaseToken }) {
|
|
376
|
+
const workspaceRoot = resolve(planningDir, '..');
|
|
377
|
+
const milestoneDir = join(workspaceRoot, '.work', 'milestone');
|
|
378
|
+
const roadmapPath = join(milestoneDir, 'ROADMAP.md');
|
|
379
|
+
const phasesDir = join(milestoneDir, 'phases');
|
|
380
|
+
|
|
381
|
+
if (!existsSync(roadmapPath)) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const phases = parseWorkRoadmapPhases(readFileSync(roadmapPath, 'utf-8'));
|
|
386
|
+
const phaseEntry = phases.find((phase) => phase.number === phaseToken);
|
|
387
|
+
if (!phaseEntry) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
milestoneDir,
|
|
393
|
+
roadmapPath,
|
|
394
|
+
phasesDir,
|
|
395
|
+
phases,
|
|
396
|
+
phaseEntry,
|
|
397
|
+
phaseArtifacts: collectWorkPhaseArtifacts({ workspaceRoot, phasesDir, phaseToken }),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseWorkRoadmapPhases(content) {
|
|
402
|
+
return String(content || '')
|
|
403
|
+
.replace(/\r\n/g, '\n')
|
|
404
|
+
.split('\n')
|
|
405
|
+
.map((line) => {
|
|
406
|
+
const match = line.match(WORK_PHASE_LINE_RE);
|
|
407
|
+
if (!match) return null;
|
|
408
|
+
return {
|
|
409
|
+
status: parseWorkPhaseStatus(match[1]),
|
|
410
|
+
number: normalizePhaseToken(match[2]),
|
|
411
|
+
title: match[3].trim(),
|
|
412
|
+
};
|
|
413
|
+
})
|
|
414
|
+
.filter(Boolean);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function parseWorkPhaseStatus(rawStatus) {
|
|
418
|
+
const status = String(rawStatus || '').trim().toLowerCase();
|
|
419
|
+
if (status === 'x') return 'done';
|
|
420
|
+
if (status === '-') return 'in_progress';
|
|
421
|
+
return 'pending';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function evaluateResumeWorkCheckpoint({ planningDir, checkpointPath }) {
|
|
425
|
+
if (!existsSync(checkpointPath)) return null;
|
|
426
|
+
|
|
427
|
+
const workspaceRoot = resolve(planningDir, '..');
|
|
428
|
+
const milestoneDir = join(workspaceRoot, '.work', 'milestone');
|
|
429
|
+
const roadmapPath = join(milestoneDir, 'ROADMAP.md');
|
|
430
|
+
if (!existsSync(roadmapPath)) return null;
|
|
431
|
+
|
|
432
|
+
let content = '';
|
|
433
|
+
try {
|
|
434
|
+
content = readFileSync(checkpointPath, 'utf-8');
|
|
435
|
+
} catch {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!/(^|[`"'(\s])\.work[\\/]+milestone([`"')\s/]|$)/i.test(content)) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
milestoneDir,
|
|
445
|
+
roadmapPath,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function collectWorkPhaseArtifacts({ workspaceRoot, phasesDir, phaseToken }) {
|
|
450
|
+
if (!existsSync(phasesDir)) return [];
|
|
451
|
+
|
|
452
|
+
return collectMarkdownFiles(phasesDir)
|
|
453
|
+
.map((filePath) => {
|
|
454
|
+
const filename = filePath.split(/[\\/]/).pop();
|
|
455
|
+
const match = filename.match(/^(\d+(?:\.\d+)*[A-Za-z]?)-(.+?)\.md$/i);
|
|
456
|
+
if (!match || normalizePhaseToken(match[1]) !== phaseToken) return null;
|
|
457
|
+
|
|
458
|
+
const kind = parseWorkArtifactKind(match[2]);
|
|
459
|
+
if (!kind) return null;
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
phaseToken,
|
|
463
|
+
kind,
|
|
464
|
+
path: filePath,
|
|
465
|
+
displayPath: relativeDisplayPath(workspaceRoot, filePath),
|
|
466
|
+
};
|
|
467
|
+
})
|
|
468
|
+
.filter(Boolean);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function collectMarkdownFiles(dir) {
|
|
472
|
+
const files = [];
|
|
473
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
474
|
+
const fullPath = join(dir, entry.name);
|
|
475
|
+
if (entry.isDirectory()) {
|
|
476
|
+
files.push(...collectMarkdownFiles(fullPath));
|
|
477
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
478
|
+
files.push(fullPath);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return files;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function parseWorkArtifactKind(rawKind) {
|
|
485
|
+
const kind = String(rawKind || '').toLowerCase();
|
|
486
|
+
if (kind === 'plan') return 'plan';
|
|
487
|
+
if (kind === 'execute') return 'execute';
|
|
488
|
+
if (kind === 'verify' || kind === 'verification') return 'verification';
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function relativeDisplayPath(root, filePath) {
|
|
493
|
+
return filePath.slice(root.length + 1).replace(/\\/g, '/');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function buildWorkPhaseBlockers({ workMilestone, phaseToken, surface }) {
|
|
497
|
+
const blockers = [];
|
|
498
|
+
const planArtifacts = workMilestone.phaseArtifacts.filter((artifact) => artifact.kind === 'plan');
|
|
499
|
+
const executeArtifacts = workMilestone.phaseArtifacts.filter((artifact) => artifact.kind === 'execute');
|
|
500
|
+
|
|
501
|
+
if (surface === 'execute') {
|
|
502
|
+
if (planArtifacts.length === 0) {
|
|
503
|
+
blockers.push(
|
|
504
|
+
blocker(
|
|
505
|
+
'missing_plan',
|
|
506
|
+
`Phase ${phaseToken} cannot execute because no .work PLAN artifact exists.`,
|
|
507
|
+
['.work/milestone/phases/']
|
|
508
|
+
)
|
|
509
|
+
);
|
|
510
|
+
} else if (executeArtifacts.length > 0) {
|
|
511
|
+
blockers.push(
|
|
512
|
+
blocker(
|
|
513
|
+
'no_pending_plan',
|
|
514
|
+
`Phase ${phaseToken} has already been executed in .work/milestone.`,
|
|
515
|
+
executeArtifacts.map((artifact) => artifact.displayPath)
|
|
516
|
+
)
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (surface === 'plan' && workMilestone.phaseEntry.status === 'done') {
|
|
522
|
+
blockers.push(
|
|
523
|
+
blocker(
|
|
524
|
+
'phase_already_complete',
|
|
525
|
+
`Phase ${phaseToken} is already complete in .work/milestone and should not be planned again.`,
|
|
526
|
+
['.work/milestone/ROADMAP.md']
|
|
527
|
+
)
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (surface === 'verify') {
|
|
532
|
+
if (planArtifacts.length === 0) {
|
|
533
|
+
blockers.push(
|
|
534
|
+
blocker(
|
|
535
|
+
'missing_plan',
|
|
536
|
+
`Phase ${phaseToken} cannot be verified because no .work PLAN artifact exists.`,
|
|
537
|
+
['.work/milestone/phases/']
|
|
538
|
+
)
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
if (executeArtifacts.length === 0) {
|
|
542
|
+
blockers.push(
|
|
543
|
+
blocker(
|
|
544
|
+
'missing_execute',
|
|
545
|
+
`Phase ${phaseToken} cannot be verified because no .work EXECUTE artifact exists yet.`,
|
|
546
|
+
['.work/milestone/phases/']
|
|
547
|
+
)
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return blockers;
|
|
553
|
+
}
|
|
554
|
+
|
|
349
555
|
function buildRoadmapAlignmentBlockers(lifecycle) {
|
|
350
556
|
if (lifecycle.phaseStatusAlignment.mismatches.length === 0) return [];
|
|
351
557
|
return [
|