gsd-lite 0.3.6 → 0.3.7
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/hooks/gsd-statusline.cjs +2 -3
- package/package.json +1 -1
- package/src/schema.js +95 -1
- package/src/tools/orchestrator.js +4 -1
- package/src/tools/state.js +100 -39
- package/src/utils.js +47 -1
package/hooks/gsd-statusline.cjs
CHANGED
|
@@ -16,8 +16,7 @@ function findGsdDir(startDir) {
|
|
|
16
16
|
while (true) {
|
|
17
17
|
const candidate = path.join(dir, '.gsd');
|
|
18
18
|
try {
|
|
19
|
-
fs.statSync(candidate);
|
|
20
|
-
return candidate;
|
|
19
|
+
if (fs.statSync(candidate).isDirectory()) return candidate;
|
|
21
20
|
} catch {
|
|
22
21
|
const parent = path.dirname(dir);
|
|
23
22
|
if (parent === dir) return null; // reached filesystem root
|
|
@@ -44,7 +43,7 @@ process.stdin.on('end', () => {
|
|
|
44
43
|
let task = '';
|
|
45
44
|
let hasGsd = false;
|
|
46
45
|
const gsdDir = findGsdDir(cwd);
|
|
47
|
-
try {
|
|
46
|
+
if (gsdDir) try {
|
|
48
47
|
const state = JSON.parse(fs.readFileSync(path.join(gsdDir, 'state.json'), 'utf8'));
|
|
49
48
|
hasGsd = true;
|
|
50
49
|
if (state.current_task && state.current_phase) {
|
package/package.json
CHANGED
package/src/schema.js
CHANGED
|
@@ -33,7 +33,7 @@ export const PHASE_LIFECYCLE = {
|
|
|
33
33
|
reviewing: ['accepted', 'active'],
|
|
34
34
|
accepted: [],
|
|
35
35
|
blocked: ['active'],
|
|
36
|
-
failed: [],
|
|
36
|
+
failed: ['active'], // H-3: Allow recovery from failed state (gated behind explicit user action)
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
export const TASK_LEVELS = ['L0', 'L1', 'L2', 'L3'];
|
|
@@ -225,6 +225,17 @@ export function validateStateUpdate(state, updates) {
|
|
|
225
225
|
case 'evidence':
|
|
226
226
|
if (!isPlainObject(updates.evidence)) {
|
|
227
227
|
errors.push('evidence must be an object');
|
|
228
|
+
} else {
|
|
229
|
+
// M-5: Validate evidence entry structure
|
|
230
|
+
for (const [id, entry] of Object.entries(updates.evidence)) {
|
|
231
|
+
if (!isPlainObject(entry)) {
|
|
232
|
+
errors.push(`evidence["${id}"] must be an object`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (typeof entry.scope !== 'string' || entry.scope.length === 0) {
|
|
236
|
+
errors.push(`evidence["${id}"].scope must be a non-empty string`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
228
239
|
}
|
|
229
240
|
break;
|
|
230
241
|
case 'research':
|
|
@@ -237,6 +248,14 @@ export function validateStateUpdate(state, updates) {
|
|
|
237
248
|
}
|
|
238
249
|
}
|
|
239
250
|
|
|
251
|
+
// M-4: Cross-field check — current_phase ≤ total_phases (skip degenerate 0-phase case)
|
|
252
|
+
const effectivePhase = 'current_phase' in updates ? updates.current_phase : state.current_phase;
|
|
253
|
+
const effectiveTotal = 'total_phases' in updates ? updates.total_phases : state.total_phases;
|
|
254
|
+
if (Number.isFinite(effectivePhase) && Number.isFinite(effectiveTotal)
|
|
255
|
+
&& effectiveTotal > 0 && effectivePhase > effectiveTotal) {
|
|
256
|
+
errors.push(`current_phase (${effectivePhase}) must not exceed total_phases (${effectiveTotal})`);
|
|
257
|
+
}
|
|
258
|
+
|
|
240
259
|
return { valid: errors.length === 0, errors };
|
|
241
260
|
}
|
|
242
261
|
|
|
@@ -320,6 +339,22 @@ export function validateState(state) {
|
|
|
320
339
|
}
|
|
321
340
|
if (!isPlainObject(state.evidence)) {
|
|
322
341
|
errors.push('evidence must be an object');
|
|
342
|
+
} else {
|
|
343
|
+
// M-5: Validate evidence entry structure
|
|
344
|
+
for (const [id, entry] of Object.entries(state.evidence)) {
|
|
345
|
+
if (!isPlainObject(entry)) {
|
|
346
|
+
errors.push(`evidence["${id}"] must be an object`);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (typeof entry.scope !== 'string' || entry.scope.length === 0) {
|
|
350
|
+
errors.push(`evidence["${id}"].scope must be a non-empty string`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// M-4: Cross-field check — current_phase ≤ total_phases (skip degenerate 0-phase case)
|
|
355
|
+
if (Number.isFinite(state.current_phase) && Number.isFinite(state.total_phases)
|
|
356
|
+
&& state.total_phases > 0 && state.current_phase > state.total_phases) {
|
|
357
|
+
errors.push(`current_phase (${state.current_phase}) must not exceed total_phases (${state.total_phases})`);
|
|
323
358
|
}
|
|
324
359
|
if (Array.isArray(state.phases)) {
|
|
325
360
|
if (typeof state.total_phases === 'number' && state.total_phases !== state.phases.length) {
|
|
@@ -532,6 +567,33 @@ export function validateDebuggerResult(r) {
|
|
|
532
567
|
return { valid: errors.length === 0, errors };
|
|
533
568
|
}
|
|
534
569
|
|
|
570
|
+
// C-1: Schema migration infrastructure
|
|
571
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Migrate state from older schema versions to current.
|
|
575
|
+
* Apply sequential migrations: v0→v1, v1→v2, etc.
|
|
576
|
+
* Mutates and returns the state object.
|
|
577
|
+
*/
|
|
578
|
+
export function migrateState(state) {
|
|
579
|
+
if (!state || typeof state !== 'object') return state;
|
|
580
|
+
const version = state.schema_version || 0;
|
|
581
|
+
|
|
582
|
+
// Migration v0 → v1: add missing fields introduced in v1
|
|
583
|
+
if (version < 1) {
|
|
584
|
+
if (!state.evidence) state.evidence = {};
|
|
585
|
+
if (!state.research) state.research = null;
|
|
586
|
+
if (!state.decisions) state.decisions = [];
|
|
587
|
+
if (!state.context) state.context = { last_session: new Date().toISOString(), remaining_percentage: 100 };
|
|
588
|
+
state.schema_version = 1;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Future migrations go here:
|
|
592
|
+
// if (version < 2) { migrateV1toV2(state); state.schema_version = 2; }
|
|
593
|
+
|
|
594
|
+
return state;
|
|
595
|
+
}
|
|
596
|
+
|
|
535
597
|
export function createInitialState({ project, phases }) {
|
|
536
598
|
if (!Array.isArray(phases)) {
|
|
537
599
|
return { error: true, message: 'phases must be an array' };
|
|
@@ -550,6 +612,38 @@ export function createInitialState({ project, phases }) {
|
|
|
550
612
|
seenIds.add(id);
|
|
551
613
|
}
|
|
552
614
|
}
|
|
615
|
+
|
|
616
|
+
// M-7: Detect circular dependencies within each phase (Kahn's algorithm)
|
|
617
|
+
for (const [pi, p] of phases.entries()) {
|
|
618
|
+
const tasks = p.tasks || [];
|
|
619
|
+
const taskIds = tasks.map((t, ti) => `${pi + 1}.${t.index ?? (ti + 1)}`);
|
|
620
|
+
const inDegree = new Map(taskIds.map(id => [id, 0]));
|
|
621
|
+
const adj = new Map(taskIds.map(id => [id, []]));
|
|
622
|
+
for (const [ti, t] of tasks.entries()) {
|
|
623
|
+
const id = `${pi + 1}.${t.index ?? (ti + 1)}`;
|
|
624
|
+
for (const dep of (t.requires || [])) {
|
|
625
|
+
if (dep.kind === 'task' && inDegree.has(dep.id)) {
|
|
626
|
+
adj.get(dep.id).push(id);
|
|
627
|
+
inDegree.set(id, inDegree.get(id) + 1);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id);
|
|
632
|
+
let sorted = 0;
|
|
633
|
+
while (queue.length > 0) {
|
|
634
|
+
const node = queue.shift();
|
|
635
|
+
sorted++;
|
|
636
|
+
for (const neighbor of adj.get(node)) {
|
|
637
|
+
const d = inDegree.get(neighbor) - 1;
|
|
638
|
+
inDegree.set(neighbor, d);
|
|
639
|
+
if (d === 0) queue.push(neighbor);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (sorted < taskIds.length) {
|
|
643
|
+
const cycleNodes = [...inDegree.entries()].filter(([, d]) => d > 0).map(([id]) => id);
|
|
644
|
+
return { error: true, message: `Circular dependency detected in phase ${pi + 1}: ${cycleNodes.join(', ')}` };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
553
647
|
return {
|
|
554
648
|
project,
|
|
555
649
|
schema_version: 1,
|
|
@@ -17,6 +17,7 @@ import { getGitHead, getGsdDir } from '../utils.js';
|
|
|
17
17
|
const MAX_DEBUG_RETRY = 3;
|
|
18
18
|
const MAX_RESUME_DEPTH = 3;
|
|
19
19
|
const CONTEXT_RESUME_THRESHOLD = 40;
|
|
20
|
+
const MAX_DECISIONS = 200;
|
|
20
21
|
|
|
21
22
|
function isTerminalWorkflowMode(workflowMode) {
|
|
22
23
|
return workflowMode === 'completed' || workflowMode === 'failed';
|
|
@@ -696,7 +697,9 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
|
|
|
696
697
|
}
|
|
697
698
|
|
|
698
699
|
const decisionEntries = buildDecisionEntries(result.decisions, phase.id, task.id, (state.decisions || []).length);
|
|
699
|
-
const
|
|
700
|
+
const allDecisions = [...(state.decisions || []), ...decisionEntries];
|
|
701
|
+
// H-1: Cap decisions to prevent unbounded growth
|
|
702
|
+
const decisions = allDecisions.length > MAX_DECISIONS ? allDecisions.slice(-MAX_DECISIONS) : allDecisions;
|
|
700
703
|
|
|
701
704
|
if (result.outcome === 'checkpointed') {
|
|
702
705
|
const reviewLevel = reclassifyReviewLevel(task, result);
|
package/src/tools/state.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { stat, writeFile, rename, unlink } from 'node:fs/promises';
|
|
5
|
-
import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject, clearGsdDirCache } from '../utils.js';
|
|
5
|
+
import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject, clearGsdDirCache, withFileLock } from '../utils.js';
|
|
6
6
|
import {
|
|
7
7
|
CANONICAL_FIELDS,
|
|
8
8
|
TASK_LIFECYCLE,
|
|
@@ -13,16 +13,42 @@ import {
|
|
|
13
13
|
validateStateUpdate,
|
|
14
14
|
validateTransition,
|
|
15
15
|
createInitialState,
|
|
16
|
+
migrateState,
|
|
16
17
|
} from '../schema.js';
|
|
17
18
|
import { runAll } from './verify.js';
|
|
18
19
|
|
|
19
20
|
const RESEARCH_FILES = ['STACK.md', 'ARCHITECTURE.md', 'PITFALLS.md', 'SUMMARY.md'];
|
|
20
21
|
const MAX_EVIDENCE_ENTRIES = 200;
|
|
22
|
+
const MAX_ARCHIVE_ENTRIES = 1000;
|
|
23
|
+
|
|
24
|
+
// M-10: Structured error codes
|
|
25
|
+
export const ERROR_CODES = {
|
|
26
|
+
NO_PROJECT_DIR: 'NO_PROJECT_DIR',
|
|
27
|
+
INVALID_INPUT: 'INVALID_INPUT',
|
|
28
|
+
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
29
|
+
STATE_EXISTS: 'STATE_EXISTS',
|
|
30
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
31
|
+
TERMINAL_STATE: 'TERMINAL_STATE',
|
|
32
|
+
TRANSITION_ERROR: 'TRANSITION_ERROR',
|
|
33
|
+
HANDOFF_GATE: 'HANDOFF_GATE',
|
|
34
|
+
};
|
|
21
35
|
|
|
22
36
|
// C-1: Serialize all state mutations to prevent TOCTOU races
|
|
37
|
+
// C-2: Layer cross-process advisory file lock on top of in-process queue
|
|
23
38
|
let _mutationQueue = Promise.resolve();
|
|
39
|
+
let _fileLockPath = null;
|
|
40
|
+
|
|
41
|
+
export function setLockPath(lockPath) {
|
|
42
|
+
_fileLockPath = lockPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
function withStateLock(fn) {
|
|
25
|
-
const p = _mutationQueue.then(
|
|
46
|
+
const p = _mutationQueue.then(() => {
|
|
47
|
+
if (_fileLockPath) {
|
|
48
|
+
return withFileLock(_fileLockPath, fn);
|
|
49
|
+
}
|
|
50
|
+
return fn();
|
|
51
|
+
});
|
|
26
52
|
_mutationQueue = p.catch(() => {});
|
|
27
53
|
return p;
|
|
28
54
|
}
|
|
@@ -47,10 +73,10 @@ function normalizeResearchArtifacts(artifacts) {
|
|
|
47
73
|
*/
|
|
48
74
|
export async function init({ project, phases, research, force = false, basePath = process.cwd() }) {
|
|
49
75
|
if (!project || typeof project !== 'string') {
|
|
50
|
-
return { error: true, message: 'project must be a non-empty string' };
|
|
76
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'project must be a non-empty string' };
|
|
51
77
|
}
|
|
52
78
|
if (!Array.isArray(phases)) {
|
|
53
|
-
return { error: true, message: 'phases must be an array' };
|
|
79
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'phases must be an array' };
|
|
54
80
|
}
|
|
55
81
|
const gsdDir = join(basePath, '.gsd');
|
|
56
82
|
const statePath = join(gsdDir, 'state.json');
|
|
@@ -60,8 +86,16 @@ export async function init({ project, phases, research, force = false, basePath
|
|
|
60
86
|
if (!force) {
|
|
61
87
|
try {
|
|
62
88
|
await stat(statePath);
|
|
63
|
-
return { error: true, message: 'state.json already exists; pass force: true to reinitialize' };
|
|
89
|
+
return { error: true, code: ERROR_CODES.STATE_EXISTS, message: 'state.json already exists; pass force: true to reinitialize' };
|
|
64
90
|
} catch {} // File doesn't exist, proceed
|
|
91
|
+
} else {
|
|
92
|
+
// H-8: Backup existing state before force overwrite
|
|
93
|
+
try {
|
|
94
|
+
const existing = await readJson(statePath);
|
|
95
|
+
if (existing.ok) {
|
|
96
|
+
await writeJson(join(gsdDir, 'state.json.bak'), existing.data);
|
|
97
|
+
}
|
|
98
|
+
} catch {} // No existing state to backup
|
|
65
99
|
}
|
|
66
100
|
|
|
67
101
|
const phasesDir = join(gsdDir, 'phases');
|
|
@@ -105,17 +139,25 @@ export async function init({ project, phases, research, force = false, basePath
|
|
|
105
139
|
/**
|
|
106
140
|
* Read state.json, optionally filtering to specific fields.
|
|
107
141
|
*/
|
|
108
|
-
export async function read({ fields, basePath = process.cwd() } = {}) {
|
|
142
|
+
export async function read({ fields, basePath = process.cwd(), validate = false } = {}) {
|
|
109
143
|
const statePath = await getStatePath(basePath);
|
|
110
144
|
if (!statePath) {
|
|
111
|
-
return { error: true, message: 'No .gsd directory found' };
|
|
145
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
|
|
112
146
|
}
|
|
113
147
|
|
|
114
148
|
const result = await readJson(statePath);
|
|
115
149
|
if (!result.ok) {
|
|
116
|
-
return { error: true, message: result.error };
|
|
150
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: result.error };
|
|
151
|
+
}
|
|
152
|
+
const state = migrateState(result.data);
|
|
153
|
+
|
|
154
|
+
// H-7: Optional semantic validation on read
|
|
155
|
+
if (validate) {
|
|
156
|
+
const validation = validateState(state);
|
|
157
|
+
if (!validation.valid) {
|
|
158
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `State validation failed: ${validation.errors.join('; ')}` };
|
|
159
|
+
}
|
|
117
160
|
}
|
|
118
|
-
const state = result.data;
|
|
119
161
|
|
|
120
162
|
if (fields && Array.isArray(fields) && fields.length > 0) {
|
|
121
163
|
const filtered = {};
|
|
@@ -135,7 +177,7 @@ export async function read({ fields, basePath = process.cwd() } = {}) {
|
|
|
135
177
|
*/
|
|
136
178
|
export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
137
179
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
|
138
|
-
return { error: true, message: 'updates must be a non-null object' };
|
|
180
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'updates must be a non-null object' };
|
|
139
181
|
}
|
|
140
182
|
// Guard: reject non-canonical fields
|
|
141
183
|
const nonCanonical = Object.keys(updates).filter(
|
|
@@ -144,19 +186,22 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
144
186
|
if (nonCanonical.length > 0) {
|
|
145
187
|
return {
|
|
146
188
|
error: true,
|
|
189
|
+
code: ERROR_CODES.INVALID_INPUT,
|
|
147
190
|
message: `Non-canonical fields rejected: ${nonCanonical.join(', ')}`,
|
|
148
191
|
};
|
|
149
192
|
}
|
|
150
193
|
|
|
151
194
|
const statePath = await getStatePath(basePath);
|
|
152
195
|
if (!statePath) {
|
|
153
|
-
return { error: true, message: 'No .gsd directory found' };
|
|
196
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
|
|
154
197
|
}
|
|
198
|
+
// C-2: Initialize cross-process lock path on first mutation
|
|
199
|
+
if (!_fileLockPath) _fileLockPath = join(dirname(statePath), 'state.lock');
|
|
155
200
|
|
|
156
201
|
return withStateLock(async () => {
|
|
157
202
|
const result = await readJson(statePath);
|
|
158
203
|
if (!result.ok) {
|
|
159
|
-
return { error: true, message: result.error };
|
|
204
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: result.error };
|
|
160
205
|
}
|
|
161
206
|
const state = result.data;
|
|
162
207
|
|
|
@@ -165,7 +210,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
165
210
|
const currentMode = state.workflow_mode;
|
|
166
211
|
if ((currentMode === 'completed' || currentMode === 'failed')
|
|
167
212
|
&& updates.workflow_mode !== currentMode) {
|
|
168
|
-
return { error: true, message: `Cannot change workflow_mode from terminal state '${currentMode}'` };
|
|
213
|
+
return { error: true, code: ERROR_CODES.TERMINAL_STATE, message: `Cannot change workflow_mode from terminal state '${currentMode}'` };
|
|
169
214
|
}
|
|
170
215
|
}
|
|
171
216
|
|
|
@@ -178,7 +223,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
178
223
|
// Check phase lifecycle transition
|
|
179
224
|
if (newPhase.lifecycle && newPhase.lifecycle !== oldPhase.lifecycle) {
|
|
180
225
|
const tr = validateTransition('phase', oldPhase.lifecycle, newPhase.lifecycle);
|
|
181
|
-
if (!tr.valid) return { error: true, message: tr.error };
|
|
226
|
+
if (!tr.valid) return { error: true, code: ERROR_CODES.TRANSITION_ERROR, message: tr.error };
|
|
182
227
|
}
|
|
183
228
|
|
|
184
229
|
// Check task lifecycle transitions
|
|
@@ -188,7 +233,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
188
233
|
if (!oldTask) continue;
|
|
189
234
|
if (newTask.lifecycle && newTask.lifecycle !== oldTask.lifecycle) {
|
|
190
235
|
const tr = validateTransition('task', oldTask.lifecycle, newTask.lifecycle);
|
|
191
|
-
if (!tr.valid) return { error: true, message: tr.error };
|
|
236
|
+
if (!tr.valid) return { error: true, code: ERROR_CODES.TRANSITION_ERROR, message: tr.error };
|
|
192
237
|
}
|
|
193
238
|
}
|
|
194
239
|
}
|
|
@@ -238,6 +283,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
238
283
|
if (!validation.valid) {
|
|
239
284
|
return {
|
|
240
285
|
error: true,
|
|
286
|
+
code: ERROR_CODES.VALIDATION_FAILED,
|
|
241
287
|
message: `Validation failed: ${validation.errors.join('; ')}`,
|
|
242
288
|
};
|
|
243
289
|
}
|
|
@@ -275,20 +321,20 @@ export async function phaseComplete({
|
|
|
275
321
|
direction_ok,
|
|
276
322
|
} = {}) {
|
|
277
323
|
if (typeof phase_id !== 'number') {
|
|
278
|
-
return { error: true, message: 'phase_id must be a number' };
|
|
324
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'phase_id must be a number' };
|
|
279
325
|
}
|
|
280
326
|
if (verification != null && (typeof verification !== 'object' || Array.isArray(verification))) {
|
|
281
|
-
return { error: true, message: 'verification must be an object when provided' };
|
|
327
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'verification must be an object when provided' };
|
|
282
328
|
}
|
|
283
329
|
if (typeof run_verify !== 'boolean') {
|
|
284
|
-
return { error: true, message: 'run_verify must be a boolean' };
|
|
330
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'run_verify must be a boolean' };
|
|
285
331
|
}
|
|
286
332
|
if (direction_ok !== undefined && typeof direction_ok !== 'boolean') {
|
|
287
|
-
return { error: true, message: 'direction_ok must be a boolean when provided' };
|
|
333
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'direction_ok must be a boolean when provided' };
|
|
288
334
|
}
|
|
289
335
|
const statePath = await getStatePath(basePath);
|
|
290
336
|
if (!statePath) {
|
|
291
|
-
return { error: true, message: 'No .gsd directory found' };
|
|
337
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
|
|
292
338
|
}
|
|
293
339
|
|
|
294
340
|
return withStateLock(async () => {
|
|
@@ -300,13 +346,13 @@ export async function phaseComplete({
|
|
|
300
346
|
|
|
301
347
|
const phase = state.phases.find((p) => p.id === phase_id);
|
|
302
348
|
if (!phase) {
|
|
303
|
-
return { error: true, message: `Phase ${phase_id} not found` };
|
|
349
|
+
return { error: true, code: ERROR_CODES.NOT_FOUND, message: `Phase ${phase_id} not found` };
|
|
304
350
|
}
|
|
305
351
|
if (!Array.isArray(phase.todo)) {
|
|
306
|
-
return { error: true, message: `Phase ${phase_id} has invalid todo list` };
|
|
352
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Phase ${phase_id} has invalid todo list` };
|
|
307
353
|
}
|
|
308
354
|
if (!phase.phase_handoff || typeof phase.phase_handoff !== 'object') {
|
|
309
|
-
return { error: true, message: `Phase ${phase_id} is missing phase_handoff metadata` };
|
|
355
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Phase ${phase_id} is missing phase_handoff metadata` };
|
|
310
356
|
}
|
|
311
357
|
|
|
312
358
|
// Validate phase lifecycle transition FIRST (fail-fast) [I-4]
|
|
@@ -316,7 +362,7 @@ export async function phaseComplete({
|
|
|
316
362
|
'accepted',
|
|
317
363
|
);
|
|
318
364
|
if (!transitionResult.valid) {
|
|
319
|
-
return { error: true, message: transitionResult.error };
|
|
365
|
+
return { error: true, code: ERROR_CODES.TRANSITION_ERROR, message: transitionResult.error };
|
|
320
366
|
}
|
|
321
367
|
|
|
322
368
|
// Check handoff gate: all tasks must be accepted
|
|
@@ -324,6 +370,7 @@ export async function phaseComplete({
|
|
|
324
370
|
if (pendingTasks.length > 0) {
|
|
325
371
|
return {
|
|
326
372
|
error: true,
|
|
373
|
+
code: ERROR_CODES.HANDOFF_GATE,
|
|
327
374
|
message: `Handoff gate not met: ${pendingTasks.length} task(s) not accepted — ${pendingTasks.map((t) => `${t.id}:${t.lifecycle}`).join(', ')}`,
|
|
328
375
|
};
|
|
329
376
|
}
|
|
@@ -332,6 +379,7 @@ export async function phaseComplete({
|
|
|
332
379
|
if (phase.phase_handoff.critical_issues_open > 0) {
|
|
333
380
|
return {
|
|
334
381
|
error: true,
|
|
382
|
+
code: ERROR_CODES.HANDOFF_GATE,
|
|
335
383
|
message: `Handoff gate not met: ${phase.phase_handoff.critical_issues_open} critical issue(s) open`,
|
|
336
384
|
};
|
|
337
385
|
}
|
|
@@ -341,6 +389,7 @@ export async function phaseComplete({
|
|
|
341
389
|
if (!reviewPassed) {
|
|
342
390
|
return {
|
|
343
391
|
error: true,
|
|
392
|
+
code: ERROR_CODES.HANDOFF_GATE,
|
|
344
393
|
message: 'Handoff gate not met: required reviews not passed',
|
|
345
394
|
};
|
|
346
395
|
}
|
|
@@ -352,6 +401,7 @@ export async function phaseComplete({
|
|
|
352
401
|
if (!testsPassed) {
|
|
353
402
|
return {
|
|
354
403
|
error: true,
|
|
404
|
+
code: ERROR_CODES.HANDOFF_GATE,
|
|
355
405
|
message: `Handoff gate not met: verification checks failed — ${verificationSummary(verificationResult)}`,
|
|
356
406
|
};
|
|
357
407
|
}
|
|
@@ -369,7 +419,7 @@ export async function phaseComplete({
|
|
|
369
419
|
phase.phase_handoff.direction_ok = false;
|
|
370
420
|
const driftValidation = validateState(state);
|
|
371
421
|
if (!driftValidation.valid) {
|
|
372
|
-
return { error: true, message: `Validation failed: ${driftValidation.errors.join('; ')}` };
|
|
422
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Validation failed: ${driftValidation.errors.join('; ')}` };
|
|
373
423
|
}
|
|
374
424
|
await writeJson(statePath, state);
|
|
375
425
|
return {
|
|
@@ -392,10 +442,11 @@ export async function phaseComplete({
|
|
|
392
442
|
// Increment current_phase if this was the active phase
|
|
393
443
|
if (state.current_phase === phase_id && phase_id < state.total_phases) {
|
|
394
444
|
state.current_phase = phase_id + 1;
|
|
395
|
-
// Activate the next phase
|
|
445
|
+
// Activate the next phase (M-3: use validateTransition for consistency)
|
|
396
446
|
const nextPhase = state.phases.find((p) => p.id === state.current_phase);
|
|
397
|
-
if (nextPhase
|
|
398
|
-
nextPhase.lifecycle
|
|
447
|
+
if (nextPhase) {
|
|
448
|
+
const nextTr = validateTransition('phase', nextPhase.lifecycle, 'active');
|
|
449
|
+
if (nextTr.valid) nextPhase.lifecycle = 'active';
|
|
399
450
|
}
|
|
400
451
|
} else if (state.current_phase === phase_id && phase_id >= state.total_phases) {
|
|
401
452
|
// Final phase completed — mark workflow as completed
|
|
@@ -420,18 +471,18 @@ export async function phaseComplete({
|
|
|
420
471
|
export async function addEvidence({ id, data, basePath = process.cwd() }) {
|
|
421
472
|
// I-8: Validate inputs
|
|
422
473
|
if (!id || typeof id !== 'string') {
|
|
423
|
-
return { error: true, message: 'id must be a non-empty string' };
|
|
474
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'id must be a non-empty string' };
|
|
424
475
|
}
|
|
425
476
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
426
|
-
return { error: true, message: 'data must be a non-null object' };
|
|
477
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'data must be a non-null object' };
|
|
427
478
|
}
|
|
428
479
|
if (typeof data.scope !== 'string') {
|
|
429
|
-
return { error: true, message: 'data.scope must be a string' };
|
|
480
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'data.scope must be a string' };
|
|
430
481
|
}
|
|
431
482
|
|
|
432
483
|
const statePath = await getStatePath(basePath);
|
|
433
484
|
if (!statePath) {
|
|
434
|
-
return { error: true, message: 'No .gsd directory found' };
|
|
485
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
|
|
435
486
|
}
|
|
436
487
|
|
|
437
488
|
return withStateLock(async () => {
|
|
@@ -485,8 +536,15 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
|
|
|
485
536
|
const existing = await readJson(archivePath);
|
|
486
537
|
const archive = existing.ok ? existing.data : {};
|
|
487
538
|
Object.assign(archive, toArchive);
|
|
488
|
-
await writeJson(archivePath, archive);
|
|
489
539
|
|
|
540
|
+
// H-2: Cap archive size to prevent unbounded growth
|
|
541
|
+
const archiveKeys = Object.keys(archive);
|
|
542
|
+
if (archiveKeys.length > MAX_ARCHIVE_ENTRIES) {
|
|
543
|
+
const toRemove = archiveKeys.slice(0, archiveKeys.length - MAX_ARCHIVE_ENTRIES);
|
|
544
|
+
for (const key of toRemove) delete archive[key];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
await writeJson(archivePath, archive);
|
|
490
548
|
state.evidence = toKeep;
|
|
491
549
|
}
|
|
492
550
|
|
|
@@ -498,9 +556,12 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
|
|
|
498
556
|
* Scope format is "task:X.Y" where X is the phase number.
|
|
499
557
|
*/
|
|
500
558
|
export async function pruneEvidence({ currentPhase, basePath = process.cwd() }) {
|
|
559
|
+
if (typeof currentPhase !== 'number' || !Number.isFinite(currentPhase)) {
|
|
560
|
+
return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'currentPhase must be a finite number' };
|
|
561
|
+
}
|
|
501
562
|
const statePath = await getStatePath(basePath);
|
|
502
563
|
if (!statePath) {
|
|
503
|
-
return { error: true, message: 'No .gsd directory found' };
|
|
564
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
|
|
504
565
|
}
|
|
505
566
|
|
|
506
567
|
return withStateLock(async () => {
|
|
@@ -885,7 +946,7 @@ export function applyResearchRefresh(state, newResearch) {
|
|
|
885
946
|
export async function storeResearch({ result, artifacts, decision_index, basePath = process.cwd() } = {}) {
|
|
886
947
|
const resultValidation = validateResearcherResult(result || {});
|
|
887
948
|
if (!resultValidation.valid) {
|
|
888
|
-
return { error: true, message: `Invalid researcher result: ${resultValidation.errors.join('; ')}` };
|
|
949
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid researcher result: ${resultValidation.errors.join('; ')}` };
|
|
889
950
|
}
|
|
890
951
|
|
|
891
952
|
const artifactsValidation = validateResearchArtifacts(artifacts, {
|
|
@@ -894,17 +955,17 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
|
|
|
894
955
|
expiresAt: result.expires_at,
|
|
895
956
|
});
|
|
896
957
|
if (!artifactsValidation.valid) {
|
|
897
|
-
return { error: true, message: `Invalid research artifacts: ${artifactsValidation.errors.join('; ')}` };
|
|
958
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid research artifacts: ${artifactsValidation.errors.join('; ')}` };
|
|
898
959
|
}
|
|
899
960
|
|
|
900
961
|
const decisionIndexValidation = validateResearchDecisionIndex(decision_index, result.decision_ids);
|
|
901
962
|
if (!decisionIndexValidation.valid) {
|
|
902
|
-
return { error: true, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
|
|
963
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
|
|
903
964
|
}
|
|
904
965
|
|
|
905
966
|
const statePath = await getStatePath(basePath);
|
|
906
967
|
if (!statePath) {
|
|
907
|
-
return { error: true, message: 'No .gsd directory found' };
|
|
968
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
|
|
908
969
|
}
|
|
909
970
|
|
|
910
971
|
return withStateLock(async () => {
|
|
@@ -967,7 +1028,7 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
|
|
|
967
1028
|
|
|
968
1029
|
const validation = validateState(state);
|
|
969
1030
|
if (!validation.valid) {
|
|
970
|
-
return { error: true, message: `State validation failed: ${validation.errors.join('; ')}` };
|
|
1031
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `State validation failed: ${validation.errors.join('; ')}` };
|
|
971
1032
|
}
|
|
972
1033
|
|
|
973
1034
|
await writeJson(statePath, state);
|
package/src/utils.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function getGsdDir(startDir = process.cwd()) {
|
|
|
23
23
|
} catch {}
|
|
24
24
|
const parent = dirname(dir);
|
|
25
25
|
if (parent === dir) {
|
|
26
|
-
|
|
26
|
+
// H-9: Don't cache negative results — .gsd may be created later by init()
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
29
|
dir = parent;
|
|
@@ -52,6 +52,52 @@ export async function getGitHead(cwd = process.cwd()) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// C-2: Advisory file lock for cross-process serialization
|
|
56
|
+
const LOCK_STALE_MS = 10_000;
|
|
57
|
+
const LOCK_RETRY_MS = 50;
|
|
58
|
+
const LOCK_MAX_RETRIES = 100; // 5 seconds total
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Execute fn while holding an advisory file lock.
|
|
62
|
+
* Uses O_CREAT|O_EXCL (via 'wx' flag) for atomic lock acquisition.
|
|
63
|
+
* Stale locks (>10s) are automatically broken.
|
|
64
|
+
* Falls through without locking on non-EEXIST errors (e.g., read-only fs).
|
|
65
|
+
*/
|
|
66
|
+
export async function withFileLock(lockPath, fn) {
|
|
67
|
+
let acquired = false;
|
|
68
|
+
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
69
|
+
try {
|
|
70
|
+
await writeFile(lockPath, String(process.pid), { flag: 'wx' });
|
|
71
|
+
acquired = true;
|
|
72
|
+
break;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err.code === 'EEXIST') {
|
|
75
|
+
try {
|
|
76
|
+
const s = await stat(lockPath);
|
|
77
|
+
if (Date.now() - s.mtimeMs > LOCK_STALE_MS) {
|
|
78
|
+
try { await unlink(lockPath); } catch {}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// stat failed — lock may have been released between checks
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
|
|
86
|
+
} else {
|
|
87
|
+
break; // Non-EEXIST error — proceed without lock
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
return await fn();
|
|
94
|
+
} finally {
|
|
95
|
+
if (acquired) {
|
|
96
|
+
try { await unlink(lockPath); } catch {}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
55
101
|
let _tmpCounter = 0;
|
|
56
102
|
function tmpPath(filePath) {
|
|
57
103
|
return `${filePath}.${process.pid}-${Date.now()}-${_tmpCounter++}.tmp`;
|