Skip to content

Commit 4d085dc

Browse files
authored
Fix planning drift preflight gating (#93)
* fix: gate planning drift before lifecycle writes * fix: preserve fingerprint drift compatibility * fix: document fingerprint helper command
1 parent 0aa4a97 commit 4d085dc

14 files changed

Lines changed: 408 additions & 35 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ Workflows are agent skills or commands, not plain shell utilities. How you invok
349349
| `npx -y gsdd-cli file-op <copy\|delete\|regex-sub>` | Run deterministic workspace-confined file copy, delete, and regex substitution |
350350
| `npx -y gsdd-cli find-phase [N]` | Show phase info as JSON (for agent consumption) |
351351
| `npx -y gsdd-cli phase-status <N> <status>` | Update a single ROADMAP phase status through the status-aware helper |
352+
| `npx -y gsdd-cli session-fingerprint write` | Refresh the local planning-state drift baseline |
352353
| `npx -y gsdd-cli verify <N>` | Run artifact checks for phase N |
353354
| `npx -y gsdd-cli scaffold phase <N> [name]` | Create a new phase plan file |
354355
| `npx -y gsdd-cli models [show\|profile\|set\|...]` | Inspect and manage model profile propagation |

bin/gsdd.mjs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,15 @@ import { cmdFindPhase, cmdVerify, cmdScaffold, cmdPhaseStatus } from './lib/phas
1818
import { cmdFileOp } from './lib/file-ops.mjs';
1919
import { createCmdHealth } from './lib/health.mjs';
2020
import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs';
21+
import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs';
2122
import { resolveWorkspaceContext } from './lib/workspace-root.mjs';
2223

2324
const __filename = fileURLToPath(import.meta.url);
2425
const __dirname = dirname(__filename);
2526
const DISTILLED_DIR = join(__dirname, '..', 'distilled');
2627
const AGENTS_DIR = join(__dirname, '..', 'agents');
2728
const PACKAGE_JSON = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
28-
const IS_MAIN = process.argv[1]
29-
? realpathSync(process.argv[1]) === realpathSync(__filename)
30-
: false;
29+
const IS_MAIN = process.argv[1] ? realpathSync(process.argv[1]) === realpathSync(__filename) : false;
3130

3231
const [,, command, ...args] = process.argv;
3332

@@ -107,6 +106,7 @@ const COMMANDS = {
107106
health: cmdHealth,
108107
'file-op': cmdFileOp,
109108
'lifecycle-preflight': cmdLifecyclePreflight,
109+
'session-fingerprint': cmdSessionFingerprint,
110110
'find-phase': cmdFindPhase,
111111
'phase-status': cmdPhaseStatus,
112112
verify: cmdVerify,
@@ -136,4 +136,4 @@ if (IS_MAIN) {
136136
await runCli();
137137
}
138138

139-
export { cmdHelp, cmdInit, cmdUpdate, cmdModels, cmdHealth, cmdFileOp, cmdLifecyclePreflight, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext };
139+
export { cmdHelp, cmdInit, cmdUpdate, cmdModels, cmdHealth, cmdFileOp, cmdLifecyclePreflight, cmdSessionFingerprint, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext };

bin/lib/health-truth.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ export function runTruthChecks(planningDir, frameworkDir, actualCheckIds, option
105105
id: 'W12',
106106
severity: 'WARN',
107107
message: `Planning state drifted since last recorded session (${drift.details.join('; ')})`,
108-
fix: 'Review the changes, then run a lifecycle workflow to update the fingerprint',
109-
});
108+
fix: 'Review the changed planning files. If the drift is intentional, rebaseline with `node .planning/bin/gsdd.mjs session-fingerprint write`, then rerun the blocked lifecycle preflight.',
109+
});
110110
}
111111

112112
return warnings;

bin/lib/init-runtime.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ Commands:
186186
phase-status <N> <status> Update ROADMAP.md phase status ([ ] / [-] / [x])
187187
lifecycle-preflight <surface> [phase]
188188
Inspect deterministic lifecycle gate results for a workflow surface
189+
session-fingerprint write Rebaseline planning-state drift after reviewing changed planning files
189190
help Show this summary
190191
191192
Platforms (for --tools):
@@ -254,6 +255,7 @@ Starting lanes after init:
254255
255256
Advanced/internal helpers (kept available, but not the primary first-run user story):
256257
lifecycle-preflight Inspect deterministic lifecycle gate results for a workflow surface
258+
session-fingerprint Rebaseline the local planning-state fingerprint after review
257259
phase-status Update ROADMAP.md phase status through the local helper surface
258260
file-op Deterministic workspace-confined file copy/delete/text mutation
259261
`;

bin/lib/lifecycle-preflight.mjs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ const SURFACE_POLICIES = {
1212
ownedWrites: [],
1313
explicitLifecycleMutation: 'none',
1414
},
15+
plan: {
16+
classification: 'owned_write',
17+
ownedWrites: ['research', 'plan'],
18+
explicitLifecycleMutation: 'none',
19+
phaseRequired: true,
20+
},
1521
execute: {
1622
classification: 'owned_write',
1723
ownedWrites: ['summary'],
@@ -125,15 +131,33 @@ export function evaluateLifecyclePreflight({
125131
}
126132

127133
const warnings = [];
134+
let planningState = null;
128135

129136
if (existsSync(planningDir)) {
130137
const drift = checkDrift(planningDir);
138+
planningState = {
139+
classification: drift.classification,
140+
drifted: drift.drifted,
141+
noBaseline: drift.noBaseline,
142+
details: drift.details,
143+
files: drift.files,
144+
};
131145
if (drift.drifted) {
132-
warnings.push({
146+
const driftNotice = {
133147
code: 'planning_state_drift',
134-
message: `Planning state has drifted since the last recorded session: ${drift.details.join('; ')}`,
148+
message: `${surface} cannot proceed because planning state drifted since the last recorded session: ${drift.details.join('; ')}`,
135149
artifacts: ['.planning/ROADMAP.md', '.planning/SPEC.md', '.planning/config.json'],
136-
});
150+
details: drift.details,
151+
files: drift.files,
152+
};
153+
if (policy.classification === 'owned_write') {
154+
blockers.push(driftNotice);
155+
} else {
156+
warnings.push({
157+
...driftNotice,
158+
message: `Planning state has drifted since the last recorded session: ${drift.details.join('; ')}`,
159+
});
160+
}
137161
}
138162
}
139163

@@ -158,6 +182,7 @@ export function evaluateLifecyclePreflight({
158182
reason: blockers[0]?.code ?? null,
159183
blockers,
160184
warnings,
185+
planningState,
161186
lifecycle: {
162187
currentMilestone: lifecycle.currentMilestone,
163188
currentPhase: lifecycle.currentPhase ? lifecycle.currentPhase.number : null,
@@ -187,6 +212,16 @@ function buildPhaseBlockers({ lifecycle, phaseToken, surface }) {
187212
(artifact) => !summaryArtifacts.some((candidate) => candidate.dir === artifact.dir && candidate.baseId === artifact.baseId)
188213
);
189214

215+
if (surface === 'plan' && phaseEntry.status === 'done') {
216+
blockers.push(
217+
blocker(
218+
'phase_already_complete',
219+
`Phase ${phaseToken} is already complete. Reopen the phase before writing a new PLAN artifact.`,
220+
['.planning/ROADMAP.md']
221+
)
222+
);
223+
}
224+
190225
if (surface === 'execute') {
191226
if (planArtifacts.length === 0) {
192227
blockers.push(

bin/lib/rendering.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,14 @@ function renderPlanningCliLauncher() {
4646
import { cmdFileOp } from './lib/file-ops.mjs';
4747
import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs';
4848
import { cmdPhaseStatus } from './lib/phase.mjs';
49+
import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs';
4950
import { bootstrapHelperWorkspace, consumeWorkspaceRootArg, resolveWorkspaceContext } from './lib/workspace-root.mjs';
5051
5152
const COMMANDS = {
5253
'file-op': cmdFileOp,
5354
'lifecycle-preflight': cmdLifecyclePreflight,
5455
'phase-status': cmdPhaseStatus,
56+
'session-fingerprint': cmdSessionFingerprint,
5557
};
5658
5759
function printHelp() {
@@ -67,6 +69,8 @@ function printHelp() {
6769
' lifecycle-preflight <surface> [phase]',
6870
' Inspect lifecycle gate results for a workflow surface',
6971
' Example: node .planning/bin/gsdd.mjs lifecycle-preflight verify 1 --expects-mutation phase-status',
72+
' session-fingerprint write',
73+
' Rebaseline planning-state drift after reviewing changed planning files',
7074
'',
7175
'Advanced option:',
7276
' --workspace-root <path> Override workspace root discovery before or after the subcommand',

bin/lib/session-fingerprint.mjs

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@
1111
import { createHash } from 'crypto';
1212
import { existsSync, readFileSync, writeFileSync } from 'fs';
1313
import { join } from 'path';
14+
import { output } from './cli-utils.mjs';
15+
import { resolveWorkspaceContext } from './workspace-root.mjs';
1416

1517
const FINGERPRINT_FILE = '.state-fingerprint.json';
1618
const FINGERPRINT_SOURCES = ['ROADMAP.md', 'SPEC.md', 'config.json'];
19+
const FINGERPRINT_SCHEMA_VERSION = 2;
20+
const FINGERPRINT_ALGORITHM = 'sha256:v2:exists-content';
1721

1822
/**
1923
* Compute a SHA-256 fingerprint from the planning truth files.
@@ -23,15 +27,52 @@ const FINGERPRINT_SOURCES = ['ROADMAP.md', 'SPEC.md', 'config.json'];
2327
export function computeFingerprint(planningDir) {
2428
const hash = createHash('sha256');
2529
const sources = {};
30+
const files = {};
2631
for (const file of FINGERPRINT_SOURCES) {
2732
const filePath = join(planningDir, file);
28-
const content = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '';
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') : '';
2952
hash.update(`${file}:${content}\n`);
30-
sources[file] = existsSync(filePath);
53+
sources[file] = exists;
3154
}
3255
return { hash: hash.digest('hex'), sources };
3356
}
3457

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] = normalizedArgs;
67+
if (action !== 'write') {
68+
console.error('Usage: node .planning/bin/gsdd.mjs session-fingerprint write');
69+
process.exitCode = 1;
70+
return;
71+
}
72+
73+
output({ operation: 'session-fingerprint write', fingerprint: writeFingerprint(planningDir) });
74+
}
75+
3576
/**
3677
* Read the stored fingerprint from .planning/.state-fingerprint.json.
3778
* Returns null if the file does not exist or is unparseable.
@@ -50,10 +91,13 @@ export function readStoredFingerprint(planningDir) {
5091
* Write the current fingerprint to .planning/.state-fingerprint.json.
5192
*/
5293
export function writeFingerprint(planningDir) {
53-
const { hash, sources } = computeFingerprint(planningDir);
94+
const { hash, sources, files } = computeFingerprint(planningDir);
5495
const data = {
96+
schemaVersion: FINGERPRINT_SCHEMA_VERSION,
97+
algorithm: FINGERPRINT_ALGORITHM,
5598
hash,
5699
sources,
100+
files,
57101
timestamp: new Date().toISOString(),
58102
};
59103
writeFileSync(join(planningDir, FINGERPRINT_FILE), JSON.stringify(data, null, 2) + '\n');
@@ -69,27 +113,33 @@ export function writeFingerprint(planningDir) {
69113
*/
70114
export function checkDrift(planningDir) {
71115
const stored = readStoredFingerprint(planningDir);
72-
const { hash: currentHash, sources: currentSources } = computeFingerprint(planningDir);
116+
const { hash: currentHash, sources: currentSources, files: currentFiles } = computeFingerprint(planningDir);
73117

74118
if (!stored) {
75119
return {
76120
drifted: false,
77121
noBaseline: true,
122+
classification: 'no_baseline',
78123
details: ['No stored fingerprint found — first session or fingerprint was cleared.'],
79124
stored: null,
80-
current: { hash: currentHash, sources: currentSources },
125+
current: { hash: currentHash, sources: currentSources, files: currentFiles },
126+
files: [],
81127
};
82128
}
83129

84-
const drifted = stored.hash !== currentHash;
130+
const isLegacy = !stored.schemaVersion && !stored.files;
131+
const comparison = isLegacy ? computeLegacyFingerprint(planningDir) : { hash: currentHash };
132+
const drifted = stored.hash !== comparison.hash;
85133
const details = [];
134+
const files = drifted
135+
? FINGERPRINT_SOURCES.map((file) => classifyFileDrift(file, stored, currentSources, currentFiles, { legacy: isLegacy }))
136+
: FINGERPRINT_SOURCES.map((file) => ({ file, status: 'unchanged' }));
86137
if (drifted) {
87-
for (const file of FINGERPRINT_SOURCES) {
88-
const was = stored.sources?.[file] ?? false;
89-
const now = currentSources[file];
90-
if (was && !now) details.push(`${file} was removed`);
91-
else if (!was && now) details.push(`${file} was created`);
92-
else if (was && now) details.push(`${file} may have changed`);
138+
for (const file of files) {
139+
if (file.status === 'created') details.push(`${file.file} created`);
140+
else if (file.status === 'removed') details.push(`${file.file} removed`);
141+
else if (file.status === 'changed') details.push(`${file.file} changed`);
142+
else if (file.status === 'unknown') details.push(`${file.file} may have changed`);
93143
}
94144
if (details.length === 0) {
95145
details.push('Planning state hash changed since last recorded session.');
@@ -99,8 +149,35 @@ export function checkDrift(planningDir) {
99149
return {
100150
drifted,
101151
noBaseline: false,
152+
classification: drifted ? 'planning_state_drift' : 'clean',
153+
compatibility: isLegacy ? 'legacy_v1' : null,
154+
needsBaselineRefresh: isLegacy && !drifted,
102155
details,
103-
stored: { hash: stored.hash, timestamp: stored.timestamp },
104-
current: { hash: currentHash, sources: currentSources },
156+
files,
157+
stored: {
158+
hash: stored.hash,
159+
timestamp: stored.timestamp,
160+
schemaVersion: stored.schemaVersion ?? null,
161+
algorithm: stored.algorithm ?? null,
162+
files: stored.files ?? null,
163+
},
164+
current: { hash: currentHash, sources: currentSources, files: currentFiles },
165+
};
166+
}
167+
168+
function classifyFileDrift(file, stored, currentSources, currentFiles, { legacy = false } = {}) {
169+
const was = stored.sources?.[file] ?? false;
170+
const now = currentSources[file];
171+
172+
if (was && !now) return { file, status: 'removed' };
173+
if (!was && now) return { file, status: 'created' };
174+
if (!was && !now) return { file, status: 'unchanged' };
175+
if (legacy) return { file, status: 'unknown' };
176+
177+
const storedFile = stored.files?.[file];
178+
if (!storedFile?.hash) return { file, status: 'unknown' };
179+
return {
180+
file,
181+
status: storedFile.hash === currentFiles[file].hash ? 'unchanged' : 'changed',
105182
};
106183
}

distilled/DESIGN.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,7 +2157,8 @@ Sub-gap (b) was closed by D28's `<persistence>` mandate and guarded by G30. Sub-
21572157
- it reports blockers, owned writes, mutation expectations, and lifecycle posture
21582158
- it does not mutate ROADMAP or milestone state itself
21592159
- Keep `gsdd phase-status` as the only explicit ROADMAP mutator for phase-state transitions.
2160-
- Require transition-sensitive workflow contracts to call the shared preflight seam instead of narrating their own lifecycle inference.
2160+
- Require transition-sensitive workflow contracts, including plan creation, to call the shared preflight seam instead of narrating their own lifecycle inference.
2161+
- Treat planning-state drift as warning-only for read-only surfaces and blocking for owned-write surfaces, with file-level drift details from the session fingerprint helper.
21612162
- Preserve `progress` as read-only and make it explicitly defer any recommended transition back to the downstream workflow's own preflight gate.
21622163

21632164
**Why this fits the codebase:**
@@ -2170,7 +2171,9 @@ Sub-gap (b) was closed by D28's `<persistence>` mandate and guarded by G30. Sub-
21702171
- `.planning/ROADMAP.md` (Phase 30 success criteria)
21712172
- `bin/lib/lifecycle-preflight.mjs`
21722173
- `bin/lib/lifecycle-state.mjs`
2174+
- `bin/lib/session-fingerprint.mjs`
21732175
- `bin/gsdd.mjs`
2176+
- `distilled/workflows/plan.md`
21742177
- `distilled/workflows/execute.md`
21752178
- `distilled/workflows/verify.md`
21762179
- `distilled/workflows/audit-milestone.md`
@@ -2188,7 +2191,7 @@ Sub-gap (b) was closed by D28's `<persistence>` mandate and guarded by G30. Sub-
21882191
- `progress` can continue reporting lifecycle posture without inheriting write authority or becoming a hidden transition surface.
21892192
- Phase 31 can build evidence-gated closure on top of a stable deterministic preflight contract instead of competing lifecycle entry logic.
21902193

2191-
**GSDD implementation:** `bin/lib/lifecycle-preflight.mjs`, `bin/lib/lifecycle-state.mjs`, `bin/gsdd.mjs`, `bin/lib/init.mjs`, `distilled/workflows/execute.md`, `distilled/workflows/verify.md`, `distilled/workflows/audit-milestone.md`, `distilled/workflows/complete-milestone.md`, `distilled/workflows/new-milestone.md`, `distilled/workflows/resume.md`, `distilled/workflows/progress.md`, `tests/phase.test.cjs`, `tests/gsdd.guards.test.cjs`, `tests/gsdd.scenarios.test.cjs`
2194+
**GSDD implementation:** `bin/lib/lifecycle-preflight.mjs`, `bin/lib/lifecycle-state.mjs`, `bin/lib/session-fingerprint.mjs`, `bin/gsdd.mjs`, `bin/lib/init.mjs`, `distilled/workflows/plan.md`, `distilled/workflows/execute.md`, `distilled/workflows/verify.md`, `distilled/workflows/audit-milestone.md`, `distilled/workflows/complete-milestone.md`, `distilled/workflows/new-milestone.md`, `distilled/workflows/resume.md`, `distilled/workflows/progress.md`, `tests/phase.test.cjs`, `tests/session-fingerprint.test.cjs`, `tests/gsdd.guards.test.cjs`, `tests/gsdd.scenarios.test.cjs`
21922195

21932196
---
21942197

distilled/EVIDENCE-INDEX.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,12 +357,12 @@
357357
## D49 — Deterministic Lifecycle Preflight Gates
358358
- `.planning/SPEC.md` (`ENGINE-01`, `ENGINE-02`, `ENGINE-03`)
359359
- `.planning/ROADMAP.md` (Phase 30)
360-
- `bin/lib/lifecycle-preflight.mjs`, `bin/lib/lifecycle-state.mjs`
360+
- `bin/lib/lifecycle-preflight.mjs`, `bin/lib/lifecycle-state.mjs`, `bin/lib/session-fingerprint.mjs`
361361
- `bin/gsdd.mjs`, `bin/lib/init.mjs`
362-
- `distilled/workflows/execute.md`, `distilled/workflows/verify.md`
362+
- `distilled/workflows/plan.md`, `distilled/workflows/execute.md`, `distilled/workflows/verify.md`
363363
- `distilled/workflows/audit-milestone.md`, `distilled/workflows/complete-milestone.md`
364364
- `distilled/workflows/new-milestone.md`, `distilled/workflows/resume.md`, `distilled/workflows/progress.md`
365-
- `tests/phase.test.cjs`, `tests/gsdd.guards.test.cjs`, `tests/gsdd.scenarios.test.cjs`
365+
- `tests/phase.test.cjs`, `tests/session-fingerprint.test.cjs`, `tests/gsdd.guards.test.cjs`, `tests/gsdd.scenarios.test.cjs`
366366
- `get-shit-done/workflows/progress.md`
367367

368368
## D50 — Evidence-Gated Closure Matrix

0 commit comments

Comments
 (0)