gsdd-cli 0.27.0 → 0.29.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 +53 -22
- package/agents/DISTILLATION.md +2 -2
- package/agents/README.md +4 -4
- package/agents/approach-explorer.md +4 -4
- package/agents/executor.md +20 -20
- package/agents/integration-checker.md +2 -2
- package/agents/planner.md +10 -26
- package/agents/researcher.md +2 -2
- package/agents/roadmapper.md +6 -6
- package/agents/synthesizer.md +18 -18
- package/agents/verifier.md +3 -3
- package/bin/adapters/agents.mjs +3 -3
- package/bin/adapters/claude.mjs +16 -14
- package/bin/adapters/opencode.mjs +15 -12
- package/bin/gsdd.mjs +16 -13
- package/bin/lib/{models.mjs → config.mjs} +23 -16
- package/bin/lib/control-map.mjs +17 -488
- package/bin/lib/health-truth.mjs +8 -13
- package/bin/lib/health.mjs +25 -39
- package/bin/lib/init-flow.mjs +44 -38
- package/bin/lib/init-prompts.mjs +3 -3
- package/bin/lib/init-runtime.mjs +11 -30
- package/bin/lib/lifecycle-preflight.mjs +97 -410
- package/bin/lib/lifecycle-state.mjs +2 -1
- package/bin/lib/next.mjs +243 -20
- package/bin/lib/phase.mjs +706 -280
- package/bin/lib/plan-constants.mjs +0 -5
- package/bin/lib/rendering.mjs +64 -44
- package/bin/lib/runtime-freshness.mjs +18 -15
- package/bin/lib/state-dir.mjs +45 -0
- package/bin/lib/templates.mjs +59 -22
- package/bin/lib/work-context.mjs +12 -1
- package/bin/lib/workflows.mjs +0 -1
- package/bin/lib/workspace-root.mjs +11 -6
- package/distilled/DESIGN.md +89 -31
- package/distilled/EVIDENCE-INDEX.md +18 -5
- package/distilled/README.md +23 -33
- package/distilled/SKILL.md +9 -10
- package/distilled/templates/agents.block.md +5 -5
- package/distilled/templates/approach.md +3 -3
- package/distilled/templates/auth-matrix.md +2 -2
- package/distilled/templates/brownfield-change/CHANGE.md +1 -1
- package/distilled/templates/delegates/approach-explorer.md +5 -5
- package/distilled/templates/delegates/mapper-arch.md +3 -3
- package/distilled/templates/delegates/mapper-concerns.md +4 -4
- package/distilled/templates/delegates/mapper-quality.md +3 -3
- package/distilled/templates/delegates/mapper-tech.md +3 -3
- package/distilled/templates/delegates/plan-checker.md +18 -19
- package/distilled/templates/delegates/researcher-architecture.md +3 -3
- package/distilled/templates/delegates/researcher-features.md +3 -3
- package/distilled/templates/delegates/researcher-pitfalls.md +3 -3
- package/distilled/templates/delegates/researcher-stack.md +3 -3
- package/distilled/templates/delegates/researcher-synthesizer.md +7 -7
- package/distilled/templates/research/architecture.md +1 -1
- package/distilled/templates/research/pitfalls.md +1 -1
- package/distilled/templates/research/stack.md +1 -1
- package/distilled/templates/roadmap.md +4 -4
- package/distilled/templates/spec.md +2 -2
- package/distilled/templates/ui-proof.md +81 -181
- package/distilled/workflows/audit-milestone.md +23 -23
- package/distilled/workflows/complete-milestone.md +35 -35
- package/distilled/workflows/execute.md +34 -35
- package/distilled/workflows/map-codebase.md +30 -30
- package/distilled/workflows/new-milestone.md +18 -18
- package/distilled/workflows/new-project.md +45 -45
- package/distilled/workflows/pause.md +15 -15
- package/distilled/workflows/plan.md +106 -114
- package/distilled/workflows/progress.md +40 -39
- package/distilled/workflows/quick.md +49 -50
- package/distilled/workflows/resume.md +23 -22
- package/distilled/workflows/verify-work.md +7 -7
- package/distilled/workflows/verify.md +26 -26
- package/docs/BROWNFIELD-PROOF.md +1 -1
- package/docs/RUNTIME-SUPPORT.md +13 -13
- package/docs/USER-GUIDE.md +26 -21
- package/docs/claude/context-monitor.md +1 -1
- package/docs/proof/consumer-node-cli/README.md +1 -1
- package/package.json +3 -3
- package/bin/lib/closeout-report.mjs +0 -318
- package/bin/lib/evidence-contract.mjs +0 -325
- package/bin/lib/provenance.mjs +0 -390
- package/bin/lib/session-fingerprint.mjs +0 -223
- package/bin/lib/ui-proof.mjs +0 -1007
- package/distilled/workflows/plan-milestone-gaps.md +0 -204
package/bin/lib/provenance.mjs
DELETED
|
@@ -1,390 +0,0 @@
|
|
|
1
|
-
function normalizePrState(prState) {
|
|
2
|
-
if (!prState) return 'none';
|
|
3
|
-
return String(prState).trim().toLowerCase();
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
function normalizeCount(value) {
|
|
7
|
-
if (value === 'unknown' || value === null || value === undefined) return 'unknown';
|
|
8
|
-
const numeric = Number(value);
|
|
9
|
-
return Number.isFinite(numeric) ? numeric : 'unknown';
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function normalizeCheckpointWorkflow(workflow) {
|
|
13
|
-
const normalized = String(workflow || 'generic').trim().toLowerCase();
|
|
14
|
-
return ['phase', 'quick', 'generic'].includes(normalized) ? normalized : 'generic';
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function classifyCheckpointRouting(workflow) {
|
|
18
|
-
const normalizedWorkflow = normalizeCheckpointWorkflow(workflow);
|
|
19
|
-
const progressBlocks = normalizedWorkflow === 'phase' || normalizedWorkflow === 'quick';
|
|
20
|
-
|
|
21
|
-
return {
|
|
22
|
-
workflow: normalizedWorkflow,
|
|
23
|
-
routingClass: progressBlocks ? 'blocking' : 'informational',
|
|
24
|
-
progressBlocks,
|
|
25
|
-
resumeOwnsCleanup: true,
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function parseGitStatusShort(statusText = '') {
|
|
30
|
-
const lines = statusText
|
|
31
|
-
.replace(/\r\n/g, '\n')
|
|
32
|
-
.split('\n')
|
|
33
|
-
.map((line) => line.trimEnd())
|
|
34
|
-
.filter(Boolean);
|
|
35
|
-
|
|
36
|
-
const files = [];
|
|
37
|
-
for (const line of lines) {
|
|
38
|
-
const match = line.match(/^(.)(.)\s+(.+)$/);
|
|
39
|
-
if (!match) continue;
|
|
40
|
-
|
|
41
|
-
const indexStatus = match[1];
|
|
42
|
-
const worktreeStatus = match[2];
|
|
43
|
-
if (indexStatus === '!' && worktreeStatus === '!') continue;
|
|
44
|
-
|
|
45
|
-
const rawPath = match[3].replace(/\\/g, '/');
|
|
46
|
-
const renameMatch = rawPath.match(/^(.*?)\s+->\s+(.*?)$/);
|
|
47
|
-
const filePath = renameMatch ? renameMatch[2] : rawPath;
|
|
48
|
-
files.push({
|
|
49
|
-
path: filePath,
|
|
50
|
-
fromPath: renameMatch ? renameMatch[1] : null,
|
|
51
|
-
staged: indexStatus !== ' ' && indexStatus !== '?' && indexStatus !== '!',
|
|
52
|
-
unstaged: worktreeStatus !== ' ' && worktreeStatus !== '?' && worktreeStatus !== '!',
|
|
53
|
-
untracked: indexStatus === '?' || worktreeStatus === '?',
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
files,
|
|
59
|
-
stagedCount: files.filter((file) => file.staged).length,
|
|
60
|
-
unstagedCount: files.filter((file) => file.unstaged).length,
|
|
61
|
-
untrackedCount: files.filter((file) => file.untracked).length,
|
|
62
|
-
dirty: files.length > 0,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function classifyBrownfieldCheckpointPrecedence({
|
|
67
|
-
checkpoint = {},
|
|
68
|
-
planning = {},
|
|
69
|
-
quick = {},
|
|
70
|
-
brownfieldChange = {},
|
|
71
|
-
git = {},
|
|
72
|
-
status = parseGitStatusShort(git.statusShort || ''),
|
|
73
|
-
} = {}) {
|
|
74
|
-
const checkpointRouting = classifyCheckpointRouting(checkpoint.workflow);
|
|
75
|
-
if (!brownfieldChange?.exists) {
|
|
76
|
-
return {
|
|
77
|
-
primary: checkpointRouting.progressBlocks ? 'checkpoint' : 'none',
|
|
78
|
-
checkpointRouting,
|
|
79
|
-
branchAligned: false,
|
|
80
|
-
scopeAligned: false,
|
|
81
|
-
executionActive: false,
|
|
82
|
-
checkpointCanOverrideBrownfield: false,
|
|
83
|
-
strictMatchRequired: false,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (checkpointRouting.workflow === 'generic') {
|
|
88
|
-
return {
|
|
89
|
-
primary: 'brownfield_change',
|
|
90
|
-
checkpointRouting,
|
|
91
|
-
branchAligned: false,
|
|
92
|
-
scopeAligned: false,
|
|
93
|
-
executionActive: false,
|
|
94
|
-
checkpointCanOverrideBrownfield: false,
|
|
95
|
-
strictMatchRequired: true,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const brownfieldMismatch = classifyBrownfieldArtifactMismatch({
|
|
100
|
-
brownfieldChange,
|
|
101
|
-
git,
|
|
102
|
-
status,
|
|
103
|
-
});
|
|
104
|
-
const currentBranch = normalizeBranchName(git.branch);
|
|
105
|
-
const checkpointBranch = normalizeBranchName(checkpoint.branch);
|
|
106
|
-
const brownfieldBranch = normalizeBranchName(brownfieldChange.currentIntegrationSurface);
|
|
107
|
-
const branchAligned = Boolean(
|
|
108
|
-
currentBranch
|
|
109
|
-
&& checkpointBranch
|
|
110
|
-
&& brownfieldBranch
|
|
111
|
-
&& checkpointBranch === currentBranch
|
|
112
|
-
&& brownfieldBranch === currentBranch
|
|
113
|
-
);
|
|
114
|
-
const scopeAligned = !brownfieldMismatch.warnings.some((warning) =>
|
|
115
|
-
warning.id === 'brownfield_scope_mismatch' || warning.id === 'brownfield_status_mismatch'
|
|
116
|
-
);
|
|
117
|
-
const executionActive = checkpointExecutionIsActive({
|
|
118
|
-
checkpoint,
|
|
119
|
-
checkpointRouting,
|
|
120
|
-
planning,
|
|
121
|
-
quick,
|
|
122
|
-
});
|
|
123
|
-
const checkpointCanOverrideBrownfield = branchAligned && scopeAligned && executionActive;
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
primary: checkpointCanOverrideBrownfield ? 'checkpoint' : 'brownfield_change',
|
|
127
|
-
checkpointRouting,
|
|
128
|
-
branchAligned,
|
|
129
|
-
scopeAligned,
|
|
130
|
-
executionActive,
|
|
131
|
-
checkpointCanOverrideBrownfield,
|
|
132
|
-
strictMatchRequired: true,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function classifyBrownfieldArtifactMismatch({
|
|
137
|
-
brownfieldChange = {},
|
|
138
|
-
git = {},
|
|
139
|
-
status = parseGitStatusShort(git.statusShort || ''),
|
|
140
|
-
} = {}) {
|
|
141
|
-
if (!brownfieldChange?.exists) {
|
|
142
|
-
return {
|
|
143
|
-
warnings: [],
|
|
144
|
-
requiresAcknowledgement: false,
|
|
145
|
-
outsideOwnedPaths: [],
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const warnings = [];
|
|
150
|
-
const declaredBranch = normalizeDeclaredBranch(brownfieldChange.currentIntegrationSurface);
|
|
151
|
-
const branch = String(git.branch || '').trim().toLowerCase();
|
|
152
|
-
if (declaredBranch && branch && declaredBranch !== branch) {
|
|
153
|
-
warnings.push({
|
|
154
|
-
id: 'brownfield_branch_mismatch',
|
|
155
|
-
severity: 'acknowledgement_required',
|
|
156
|
-
summary: `CHANGE.md says the active integration surface is "${brownfieldChange.currentIntegrationSurface}", but git reports "${git.branch}".`,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const ownedPaths = normalizeOwnedPaths(brownfieldChange.declaredOwnedPaths || []);
|
|
161
|
-
const outsideOwnedPaths = ownedPaths.length === 0
|
|
162
|
-
? []
|
|
163
|
-
: status.files
|
|
164
|
-
.map((file) => file.path)
|
|
165
|
-
.filter((filePath) => !ownedPaths.some((ownedPath) => matchesOwnedPath(filePath, ownedPath)));
|
|
166
|
-
if (outsideOwnedPaths.length > 0) {
|
|
167
|
-
warnings.push({
|
|
168
|
-
id: 'brownfield_scope_mismatch',
|
|
169
|
-
severity: 'acknowledgement_required',
|
|
170
|
-
summary: `Dirty files fall outside the CHANGE.md write scope: ${outsideOwnedPaths.join(', ')}`,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if ((brownfieldChange.currentStatus === 'closed' || brownfieldChange.currentStatus === 'ready_for_verification') && status.dirty) {
|
|
175
|
-
warnings.push({
|
|
176
|
-
id: 'brownfield_status_mismatch',
|
|
177
|
-
severity: 'acknowledgement_required',
|
|
178
|
-
summary: `CHANGE.md marks the change as "${brownfieldChange.currentStatus}", but the live worktree is still dirty.`,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
warnings,
|
|
184
|
-
requiresAcknowledgement: warnings.some((warning) => warning.severity === 'acknowledgement_required'),
|
|
185
|
-
outsideOwnedPaths,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export function buildProvenanceSnapshot({
|
|
190
|
-
checkpoint = {},
|
|
191
|
-
planning = {},
|
|
192
|
-
quick = {},
|
|
193
|
-
brownfieldChange = {},
|
|
194
|
-
git = {},
|
|
195
|
-
} = {}) {
|
|
196
|
-
const checkpointRouting = classifyCheckpointRouting(checkpoint.workflow);
|
|
197
|
-
const status = parseGitStatusShort(git.statusShort || '');
|
|
198
|
-
const commitsAheadOfMain = normalizeCount(git.commitsAheadOfMain);
|
|
199
|
-
const commitsAheadOfRemote = normalizeCount(git.commitsAheadOfRemote);
|
|
200
|
-
const prState = normalizePrState(git.prState);
|
|
201
|
-
const brownfieldMismatch = classifyBrownfieldArtifactMismatch({ brownfieldChange, git, status });
|
|
202
|
-
const brownfieldRouting = classifyBrownfieldCheckpointPrecedence({
|
|
203
|
-
checkpoint,
|
|
204
|
-
planning,
|
|
205
|
-
quick,
|
|
206
|
-
brownfieldChange,
|
|
207
|
-
git,
|
|
208
|
-
status,
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
const warnings = [];
|
|
212
|
-
|
|
213
|
-
if (status.dirty) {
|
|
214
|
-
warnings.push({
|
|
215
|
-
id: 'dirty_worktree',
|
|
216
|
-
severity: 'warning',
|
|
217
|
-
summary: 'Local worktree contains staged, unstaged, or untracked changes.',
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (commitsAheadOfMain !== 'unknown' && commitsAheadOfMain > 0) {
|
|
222
|
-
warnings.push({
|
|
223
|
-
id: 'ahead_of_main',
|
|
224
|
-
severity: 'warning',
|
|
225
|
-
summary: `${commitsAheadOfMain} commit(s) are ahead of main on the current branch.`,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (commitsAheadOfRemote !== 'unknown' && commitsAheadOfRemote > 0) {
|
|
230
|
-
warnings.push({
|
|
231
|
-
id: 'unpushed_commits',
|
|
232
|
-
severity: 'warning',
|
|
233
|
-
summary: `${commitsAheadOfRemote} commit(s) are ahead of the tracked remote branch.`,
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (prState === 'none') {
|
|
238
|
-
warnings.push({
|
|
239
|
-
id: 'missing_pr',
|
|
240
|
-
severity: 'warning',
|
|
241
|
-
summary: 'No pull request is associated with the current branch.',
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (git.staleBranch) {
|
|
246
|
-
warnings.push({
|
|
247
|
-
id: 'stale_branch',
|
|
248
|
-
severity: 'warning',
|
|
249
|
-
summary: 'The current branch is stale or spent relative to the intended integration surface.',
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (git.mixedScope) {
|
|
254
|
-
warnings.push({
|
|
255
|
-
id: 'mixed_scope',
|
|
256
|
-
severity: 'warning',
|
|
257
|
-
summary: 'The current worktree appears to mix multiple write scopes or phases.',
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (git.materialCheckpointMismatch) {
|
|
262
|
-
warnings.push({
|
|
263
|
-
id: 'checkpoint_mismatch',
|
|
264
|
-
severity: 'acknowledgement_required',
|
|
265
|
-
summary: 'Checkpoint narrative truth materially understates or conflicts with the live branch/worktree truth.',
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
warnings.push(...brownfieldMismatch.warnings);
|
|
270
|
-
|
|
271
|
-
return {
|
|
272
|
-
checkpoint: {
|
|
273
|
-
workflow: checkpointRouting.workflow,
|
|
274
|
-
phase: checkpoint.phase ?? null,
|
|
275
|
-
branch: checkpoint.branch || null,
|
|
276
|
-
runtime: checkpoint.runtime || 'unknown',
|
|
277
|
-
hasNarrative: Boolean(checkpoint.hasNarrative),
|
|
278
|
-
routing: checkpointRouting,
|
|
279
|
-
},
|
|
280
|
-
planning: {
|
|
281
|
-
currentPhase: planning.currentPhase || null,
|
|
282
|
-
nextPhase: planning.nextPhase || null,
|
|
283
|
-
completedPhaseCount: Number.isFinite(Number(planning.completedPhaseCount))
|
|
284
|
-
? Number(planning.completedPhaseCount)
|
|
285
|
-
: 0,
|
|
286
|
-
},
|
|
287
|
-
brownfieldChange: {
|
|
288
|
-
exists: Boolean(brownfieldChange?.exists),
|
|
289
|
-
title: brownfieldChange?.title || null,
|
|
290
|
-
currentStatus: brownfieldChange?.currentStatus || null,
|
|
291
|
-
currentIntegrationSurface: brownfieldChange?.currentIntegrationSurface || null,
|
|
292
|
-
nextAction: brownfieldChange?.nextAction || null,
|
|
293
|
-
declaredOwnedPaths: brownfieldChange?.declaredOwnedPaths || [],
|
|
294
|
-
},
|
|
295
|
-
routing: {
|
|
296
|
-
primary: brownfieldRouting.primary,
|
|
297
|
-
strictMatchRequired: brownfieldRouting.strictMatchRequired,
|
|
298
|
-
checkpointCanOverrideBrownfield: brownfieldRouting.checkpointCanOverrideBrownfield,
|
|
299
|
-
branchAligned: brownfieldRouting.branchAligned,
|
|
300
|
-
scopeAligned: brownfieldRouting.scopeAligned,
|
|
301
|
-
executionActive: brownfieldRouting.executionActive,
|
|
302
|
-
},
|
|
303
|
-
git: {
|
|
304
|
-
branch: git.branch || 'unknown',
|
|
305
|
-
prState,
|
|
306
|
-
commitsAheadOfMain,
|
|
307
|
-
commitsAheadOfRemote,
|
|
308
|
-
stagedCount: status.stagedCount,
|
|
309
|
-
unstagedCount: status.unstagedCount,
|
|
310
|
-
untrackedCount: status.untrackedCount,
|
|
311
|
-
dirty: status.dirty,
|
|
312
|
-
},
|
|
313
|
-
integrationSurface: {
|
|
314
|
-
staleBranch: Boolean(git.staleBranch),
|
|
315
|
-
mixedScope: Boolean(git.mixedScope),
|
|
316
|
-
materialCheckpointMismatch: Boolean(git.materialCheckpointMismatch),
|
|
317
|
-
materialBrownfieldMismatch: brownfieldMismatch.requiresAcknowledgement,
|
|
318
|
-
},
|
|
319
|
-
warnings,
|
|
320
|
-
requiresAcknowledgement: warnings.some((warning) => warning.severity === 'acknowledgement_required'),
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function normalizeBranchName(value) {
|
|
325
|
-
return String(value || '')
|
|
326
|
-
.trim()
|
|
327
|
-
.toLowerCase()
|
|
328
|
-
.replace(/\\/g, '/');
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function normalizeDeclaredBranch(value) {
|
|
332
|
-
return normalizeBranchName(value);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function normalizeOwnedPaths(values) {
|
|
336
|
-
return values
|
|
337
|
-
.map((value) => String(value || '').trim().replace(/\\/g, '/'))
|
|
338
|
-
.filter(Boolean);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function matchesOwnedPath(filePath, ownedPath) {
|
|
342
|
-
if (ownedPath.includes('*')) {
|
|
343
|
-
const prefix = ownedPath.split('*')[0];
|
|
344
|
-
return filePath.startsWith(prefix);
|
|
345
|
-
}
|
|
346
|
-
return filePath === ownedPath
|
|
347
|
-
|| filePath.startsWith(`${ownedPath}/`)
|
|
348
|
-
|| ownedPath.startsWith(`${filePath}/`);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function checkpointExecutionIsActive({
|
|
352
|
-
checkpoint = {},
|
|
353
|
-
checkpointRouting = classifyCheckpointRouting(checkpoint.workflow),
|
|
354
|
-
planning = {},
|
|
355
|
-
quick = {},
|
|
356
|
-
} = {}) {
|
|
357
|
-
if (checkpointRouting.workflow === 'quick') {
|
|
358
|
-
return quick.hasIncompleteWork === true;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (checkpointRouting.workflow !== 'phase') {
|
|
362
|
-
return false;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const checkpointPhase = normalizePhaseRef(checkpoint.phase);
|
|
366
|
-
if (!checkpointPhase) return false;
|
|
367
|
-
|
|
368
|
-
const phases = Array.isArray(planning.phases) ? planning.phases : [];
|
|
369
|
-
if (phases.length > 0) {
|
|
370
|
-
const matchingPhase = phases.find((phase) => normalizePhaseRef(phase.number) === checkpointPhase);
|
|
371
|
-
return Boolean(matchingPhase && matchingPhase.status !== 'done');
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const currentPhase = normalizePhaseRef(planning.currentPhase);
|
|
375
|
-
const nextPhase = normalizePhaseRef(planning.nextPhase);
|
|
376
|
-
return checkpointPhase === currentPhase || checkpointPhase === nextPhase;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function normalizePhaseRef(value) {
|
|
380
|
-
const raw = String(value || '').trim().toLowerCase();
|
|
381
|
-
if (!raw) return '';
|
|
382
|
-
|
|
383
|
-
const match = raw.match(/^(\d+(?:\.\d+)*)([a-z]?)$/i);
|
|
384
|
-
if (!match) return raw;
|
|
385
|
-
|
|
386
|
-
const numericSegments = match[1]
|
|
387
|
-
.split('.')
|
|
388
|
-
.map((segment) => String(parseInt(segment, 10)));
|
|
389
|
-
return `${numericSegments.join('.')}${match[2] || ''}`;
|
|
390
|
-
}
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
// session-fingerprint.mjs — Planning state drift detection
|
|
2
|
-
//
|
|
3
|
-
// Computes a SHA-256 fingerprint from the combined contents of ROADMAP.md,
|
|
4
|
-
// SPEC.md, and config.json. When the fingerprint stored in
|
|
5
|
-
// .planning/.state-fingerprint.json no longer matches the live files, the
|
|
6
|
-
// preflight and health systems can warn that planning state drifted since
|
|
7
|
-
// the last recorded session.
|
|
8
|
-
//
|
|
9
|
-
// The fingerprint file is session-local and gitignored by convention.
|
|
10
|
-
|
|
11
|
-
import { createHash } from 'crypto';
|
|
12
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
13
|
-
import { join } from 'path';
|
|
14
|
-
import { output } from './cli-utils.mjs';
|
|
15
|
-
import { resolveWorkspaceContext } from './workspace-root.mjs';
|
|
16
|
-
|
|
17
|
-
const FINGERPRINT_FILE = '.state-fingerprint.json';
|
|
18
|
-
const FINGERPRINT_SOURCES = ['ROADMAP.md', 'SPEC.md', 'config.json'];
|
|
19
|
-
const FINGERPRINT_SCHEMA_VERSION = 2;
|
|
20
|
-
const FINGERPRINT_ALGORITHM = 'sha256:v2:exists-content';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Compute a SHA-256 fingerprint from the planning truth files.
|
|
24
|
-
* Missing files contribute an empty string (so a newly created file
|
|
25
|
-
* registers as drift).
|
|
26
|
-
*/
|
|
27
|
-
export function computeFingerprint(planningDir) {
|
|
28
|
-
const hash = createHash('sha256');
|
|
29
|
-
const sources = {};
|
|
30
|
-
const files = {};
|
|
31
|
-
for (const file of FINGERPRINT_SOURCES) {
|
|
32
|
-
const filePath = join(planningDir, file);
|
|
33
|
-
const exists = existsSync(filePath);
|
|
34
|
-
const content = exists ? readFileSync(filePath, 'utf-8') : '';
|
|
35
|
-
hash.update(`${file}:${exists ? 'exists' : 'missing'}:${content}\n`);
|
|
36
|
-
sources[file] = exists;
|
|
37
|
-
files[file] = {
|
|
38
|
-
exists,
|
|
39
|
-
hash: createHash('sha256').update(content).digest('hex'),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
return { hash: hash.digest('hex'), sources, files };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function computeLegacyFingerprint(planningDir) {
|
|
46
|
-
const hash = createHash('sha256');
|
|
47
|
-
const sources = {};
|
|
48
|
-
for (const file of FINGERPRINT_SOURCES) {
|
|
49
|
-
const filePath = join(planningDir, file);
|
|
50
|
-
const exists = existsSync(filePath);
|
|
51
|
-
const content = exists ? readFileSync(filePath, 'utf-8') : '';
|
|
52
|
-
hash.update(`${file}:${content}\n`);
|
|
53
|
-
sources[file] = exists;
|
|
54
|
-
}
|
|
55
|
-
return { hash: hash.digest('hex'), sources };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function cmdSessionFingerprint(...args) {
|
|
59
|
-
const { args: normalizedArgs, planningDir, invalid, error } = resolveWorkspaceContext(args);
|
|
60
|
-
if (invalid) {
|
|
61
|
-
console.error(error);
|
|
62
|
-
process.exitCode = 1;
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const [action, ...flags] = normalizedArgs;
|
|
67
|
-
if (action !== 'write') {
|
|
68
|
-
console.error('Usage: node .planning/bin/gsdd.mjs session-fingerprint write [--allow-changed <ROADMAP.md,SPEC.md,config.json>]');
|
|
69
|
-
process.exitCode = 1;
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const allowChanged = parseAllowChanged(flags);
|
|
74
|
-
if (allowChanged.invalid) {
|
|
75
|
-
console.error('Usage: node .planning/bin/gsdd.mjs session-fingerprint write [--allow-changed <ROADMAP.md,SPEC.md,config.json>]');
|
|
76
|
-
process.exitCode = 1;
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (allowChanged.files.length > 0) {
|
|
81
|
-
const drift = checkDrift(planningDir);
|
|
82
|
-
const changedFiles = drift.files.filter((file) => file.status !== 'unchanged').map((file) => file.file);
|
|
83
|
-
const unexpected = changedFiles.filter((file) => !allowChanged.files.includes(file));
|
|
84
|
-
if (unexpected.length > 0) {
|
|
85
|
-
output({
|
|
86
|
-
operation: 'session-fingerprint write',
|
|
87
|
-
changedFiles,
|
|
88
|
-
allowedChanged: allowChanged.files,
|
|
89
|
-
written: false,
|
|
90
|
-
reason: 'unexpected_planning_drift',
|
|
91
|
-
unexpected,
|
|
92
|
-
});
|
|
93
|
-
process.exitCode = 1;
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
output({ operation: 'session-fingerprint write', fingerprint: writeFingerprint(planningDir) });
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function parseAllowChanged(flags) {
|
|
102
|
-
const files = [];
|
|
103
|
-
for (let index = 0; index < flags.length; index += 1) {
|
|
104
|
-
if (flags[index] !== '--allow-changed') return { invalid: true, files: [] };
|
|
105
|
-
const value = flags[index + 1];
|
|
106
|
-
if (!value || value.startsWith('--')) return { invalid: true, files: [] };
|
|
107
|
-
files.push(...value.split(',').map((entry) => entry.trim()).filter(Boolean));
|
|
108
|
-
index += 1;
|
|
109
|
-
}
|
|
110
|
-
for (const file of files) {
|
|
111
|
-
if (!FINGERPRINT_SOURCES.includes(file)) return { invalid: true, files: [] };
|
|
112
|
-
}
|
|
113
|
-
return { invalid: false, files: [...new Set(files)] };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Read the stored fingerprint from .planning/.state-fingerprint.json.
|
|
118
|
-
* Returns null if the file does not exist or is unparseable.
|
|
119
|
-
*/
|
|
120
|
-
export function readStoredFingerprint(planningDir) {
|
|
121
|
-
const filePath = join(planningDir, FINGERPRINT_FILE);
|
|
122
|
-
if (!existsSync(filePath)) return null;
|
|
123
|
-
try {
|
|
124
|
-
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
125
|
-
} catch {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Write the current fingerprint to .planning/.state-fingerprint.json.
|
|
132
|
-
*/
|
|
133
|
-
export function writeFingerprint(planningDir) {
|
|
134
|
-
const { hash, sources, files } = computeFingerprint(planningDir);
|
|
135
|
-
const data = {
|
|
136
|
-
schemaVersion: FINGERPRINT_SCHEMA_VERSION,
|
|
137
|
-
algorithm: FINGERPRINT_ALGORITHM,
|
|
138
|
-
hash,
|
|
139
|
-
sources,
|
|
140
|
-
files,
|
|
141
|
-
timestamp: new Date().toISOString(),
|
|
142
|
-
};
|
|
143
|
-
writeFileSync(join(planningDir, FINGERPRINT_FILE), JSON.stringify(data, null, 2) + '\n');
|
|
144
|
-
return data;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Check whether the current planning state has drifted from the stored
|
|
149
|
-
* fingerprint. Returns { drifted, details, stored, current }.
|
|
150
|
-
*
|
|
151
|
-
* If no stored fingerprint exists, returns drifted: false with a note
|
|
152
|
-
* that no baseline was found (first session after adoption).
|
|
153
|
-
*/
|
|
154
|
-
export function checkDrift(planningDir) {
|
|
155
|
-
const stored = readStoredFingerprint(planningDir);
|
|
156
|
-
const { hash: currentHash, sources: currentSources, files: currentFiles } = computeFingerprint(planningDir);
|
|
157
|
-
|
|
158
|
-
if (!stored) {
|
|
159
|
-
return {
|
|
160
|
-
drifted: false,
|
|
161
|
-
noBaseline: true,
|
|
162
|
-
classification: 'no_baseline',
|
|
163
|
-
details: ['No stored fingerprint found — first session or fingerprint was cleared.'],
|
|
164
|
-
stored: null,
|
|
165
|
-
current: { hash: currentHash, sources: currentSources, files: currentFiles },
|
|
166
|
-
files: [],
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const isLegacy = !stored.schemaVersion && !stored.files;
|
|
171
|
-
const comparison = isLegacy ? computeLegacyFingerprint(planningDir) : { hash: currentHash };
|
|
172
|
-
const drifted = stored.hash !== comparison.hash;
|
|
173
|
-
const details = [];
|
|
174
|
-
const files = drifted
|
|
175
|
-
? FINGERPRINT_SOURCES.map((file) => classifyFileDrift(file, stored, currentSources, currentFiles, { legacy: isLegacy }))
|
|
176
|
-
: FINGERPRINT_SOURCES.map((file) => ({ file, status: 'unchanged' }));
|
|
177
|
-
if (drifted) {
|
|
178
|
-
for (const file of files) {
|
|
179
|
-
if (file.status === 'created') details.push(`${file.file} created`);
|
|
180
|
-
else if (file.status === 'removed') details.push(`${file.file} removed`);
|
|
181
|
-
else if (file.status === 'changed') details.push(`${file.file} changed`);
|
|
182
|
-
else if (file.status === 'unknown') details.push(`${file.file} may have changed`);
|
|
183
|
-
}
|
|
184
|
-
if (details.length === 0) {
|
|
185
|
-
details.push('Planning state hash changed since last recorded session.');
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
drifted,
|
|
191
|
-
noBaseline: false,
|
|
192
|
-
classification: drifted ? 'planning_state_drift' : 'clean',
|
|
193
|
-
compatibility: isLegacy ? 'legacy_v1' : null,
|
|
194
|
-
needsBaselineRefresh: isLegacy && !drifted,
|
|
195
|
-
details,
|
|
196
|
-
files,
|
|
197
|
-
stored: {
|
|
198
|
-
hash: stored.hash,
|
|
199
|
-
timestamp: stored.timestamp,
|
|
200
|
-
schemaVersion: stored.schemaVersion ?? null,
|
|
201
|
-
algorithm: stored.algorithm ?? null,
|
|
202
|
-
files: stored.files ?? null,
|
|
203
|
-
},
|
|
204
|
-
current: { hash: currentHash, sources: currentSources, files: currentFiles },
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function classifyFileDrift(file, stored, currentSources, currentFiles, { legacy = false } = {}) {
|
|
209
|
-
const was = stored.sources?.[file] ?? false;
|
|
210
|
-
const now = currentSources[file];
|
|
211
|
-
|
|
212
|
-
if (was && !now) return { file, status: 'removed' };
|
|
213
|
-
if (!was && now) return { file, status: 'created' };
|
|
214
|
-
if (!was && !now) return { file, status: 'unchanged' };
|
|
215
|
-
if (legacy) return { file, status: 'unknown' };
|
|
216
|
-
|
|
217
|
-
const storedFile = stored.files?.[file];
|
|
218
|
-
if (!storedFile?.hash) return { file, status: 'unknown' };
|
|
219
|
-
return {
|
|
220
|
-
file,
|
|
221
|
-
status: storedFile.hash === currentFiles[file].hash ? 'unchanged' : 'changed',
|
|
222
|
-
};
|
|
223
|
-
}
|