gsd-lite 0.5.10 → 0.5.13
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +39 -18
- package/agents/executor.md +8 -0
- package/agents/researcher.md +24 -0
- package/agents/reviewer.md +14 -0
- package/commands/resume.md +8 -0
- package/hooks/gsd-session-init.cjs +104 -2
- package/hooks/gsd-session-stop.cjs +69 -0
- package/hooks/hooks.json +13 -1
- package/hooks/lib/gsd-finder.cjs +84 -0
- package/package.json +1 -1
- package/references/evidence-spec.md +3 -3
- package/references/review-classification.md +1 -1
- package/references/state-diagram.md +1 -1
- package/src/schema.js +12 -2
- package/src/server.js +31 -2
- package/src/tools/orchestrator/debugger.js +94 -0
- package/src/tools/orchestrator/executor.js +162 -0
- package/src/tools/orchestrator/helpers.js +448 -0
- package/src/tools/orchestrator/index.js +6 -0
- package/src/tools/orchestrator/researcher.js +27 -0
- package/src/tools/orchestrator/resume.js +478 -0
- package/src/tools/orchestrator/reviewer.js +125 -0
- package/src/tools/state/constants.js +67 -0
- package/src/tools/{state.js → state/crud.js} +276 -493
- package/src/tools/state/index.js +5 -0
- package/src/tools/state/logic.js +508 -0
- package/src/tools/orchestrator.js +0 -1243
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// State module — re-exports all public API
|
|
2
|
+
|
|
3
|
+
export { ERROR_CODES, setLockPath } from './constants.js';
|
|
4
|
+
export { init, read, update, phaseComplete, addEvidence, pruneEvidence, patchPlan } from './crud.js';
|
|
5
|
+
export { selectRunnableTask, propagateInvalidation, buildExecutorContext, reclassifyReviewLevel, matchDecisionForBlocker, applyResearchRefresh, storeResearch } from './logic.js';
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// Automation/business logic functions
|
|
2
|
+
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { writeFile, rename, unlink } from 'node:fs/promises';
|
|
5
|
+
import { ensureDir, readJson, writeJson, getStatePath } from '../../utils.js';
|
|
6
|
+
import {
|
|
7
|
+
TASK_LIFECYCLE,
|
|
8
|
+
validateResearchArtifacts,
|
|
9
|
+
validateResearchDecisionIndex,
|
|
10
|
+
validateResearcherResult,
|
|
11
|
+
validateState,
|
|
12
|
+
} from '../../schema.js';
|
|
13
|
+
import {
|
|
14
|
+
ERROR_CODES,
|
|
15
|
+
RESEARCH_FILES,
|
|
16
|
+
DEFAULT_MAX_RETRY,
|
|
17
|
+
ensureLockPathFromStatePath,
|
|
18
|
+
withStateLock,
|
|
19
|
+
inferWorkflowModeAfterResearch,
|
|
20
|
+
normalizeResearchArtifacts,
|
|
21
|
+
} from './constants.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Select the next runnable task from a phase, respecting dependency gates.
|
|
25
|
+
* Returns { task } if a runnable task is found,
|
|
26
|
+
* { mode: 'trigger_review' } if all remaining are checkpointed,
|
|
27
|
+
* { mode: 'awaiting_user', blockers } if all are blocked,
|
|
28
|
+
* { task: undefined } if nothing can run.
|
|
29
|
+
* @param {object} phase - Phase object with todo array
|
|
30
|
+
* @param {object} state - Full state object
|
|
31
|
+
* @param {object} [options] - Options
|
|
32
|
+
* @param {number} [options.maxRetry=3] - Maximum retry count before skipping a task
|
|
33
|
+
*/
|
|
34
|
+
export function selectRunnableTask(phase, state, { maxRetry = DEFAULT_MAX_RETRY } = {}) {
|
|
35
|
+
if (!phase || !Array.isArray(phase.todo)) {
|
|
36
|
+
return { error: true, message: 'Phase todo must be an array' };
|
|
37
|
+
}
|
|
38
|
+
// D-4: Zero-task phase — immediately trigger review so phase can advance
|
|
39
|
+
if (phase.todo.length === 0) {
|
|
40
|
+
return { mode: 'trigger_review' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const runnableTasks = [];
|
|
44
|
+
|
|
45
|
+
for (const task of phase.todo) {
|
|
46
|
+
if (!['pending', 'needs_revalidation'].includes(task.lifecycle)) continue;
|
|
47
|
+
if (task.retry_count >= maxRetry) continue;
|
|
48
|
+
if (task.blocked_reason) continue;
|
|
49
|
+
|
|
50
|
+
let depsOk = true;
|
|
51
|
+
for (const dep of (task.requires || [])) {
|
|
52
|
+
if (dep.kind === 'task') {
|
|
53
|
+
const depTask = phase.todo.find(t => t.id === dep.id);
|
|
54
|
+
if (!depTask) { depsOk = false; break; }
|
|
55
|
+
const gate = dep.gate || 'accepted';
|
|
56
|
+
if (gate === 'checkpoint' && !['checkpointed', 'accepted'].includes(depTask.lifecycle)) { depsOk = false; break; }
|
|
57
|
+
if (gate === 'accepted' && depTask.lifecycle !== 'accepted') { depsOk = false; break; }
|
|
58
|
+
if (gate === 'phase_complete') { depsOk = false; break; } // phase_complete is only valid on phase-kind deps
|
|
59
|
+
} else if (dep.kind === 'phase') {
|
|
60
|
+
const depPhaseId = Number(dep.id);
|
|
61
|
+
const depPhase = (state.phases || []).find(p => p.id === depPhaseId);
|
|
62
|
+
if (!depPhase || depPhase.lifecycle !== 'accepted') { depsOk = false; break; }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (depsOk) runnableTasks.push(task);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (runnableTasks.length > 0) {
|
|
69
|
+
return {
|
|
70
|
+
task: runnableTasks[0],
|
|
71
|
+
...(runnableTasks.length > 1 ? { parallel_available: runnableTasks.slice(1) } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const awaitingReview = phase.todo.filter(t => t.lifecycle === 'checkpointed');
|
|
76
|
+
if (awaitingReview.length > 0) {
|
|
77
|
+
return { mode: 'trigger_review' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// All tasks accepted → trigger phase review if not already reviewed
|
|
81
|
+
const allAccepted = phase.todo.length > 0 && phase.todo.every(t => t.lifecycle === 'accepted');
|
|
82
|
+
if (allAccepted && phase.phase_review?.status !== 'accepted') {
|
|
83
|
+
return { mode: 'trigger_review' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const blockedTasks = phase.todo.filter(t => t.lifecycle === 'blocked');
|
|
87
|
+
if (blockedTasks.length > 0) {
|
|
88
|
+
return { mode: 'awaiting_user', blockers: blockedTasks.map(t => ({ id: t.id, reason: t.blocked_reason })) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Diagnose why no task is runnable
|
|
92
|
+
const diagnostics = [];
|
|
93
|
+
for (const task of phase.todo) {
|
|
94
|
+
if (task.lifecycle === 'accepted' || task.lifecycle === 'failed') continue;
|
|
95
|
+
const reasons = [];
|
|
96
|
+
if (!['pending', 'needs_revalidation'].includes(task.lifecycle)) {
|
|
97
|
+
reasons.push(`lifecycle=${task.lifecycle}`);
|
|
98
|
+
}
|
|
99
|
+
if (task.retry_count >= maxRetry) {
|
|
100
|
+
reasons.push(`retry_count=${task.retry_count} >= max=${maxRetry}`);
|
|
101
|
+
}
|
|
102
|
+
if (task.blocked_reason) {
|
|
103
|
+
reasons.push(`blocked: ${task.blocked_reason}`);
|
|
104
|
+
}
|
|
105
|
+
for (const dep of (task.requires || [])) {
|
|
106
|
+
if (dep.kind === 'task') {
|
|
107
|
+
const depTask = phase.todo.find(t => t.id === dep.id);
|
|
108
|
+
const gate = dep.gate || 'accepted';
|
|
109
|
+
if (!depTask) {
|
|
110
|
+
reasons.push(`dep ${dep.id} not found`);
|
|
111
|
+
} else if (gate === 'checkpoint' && !['checkpointed', 'accepted'].includes(depTask.lifecycle)) {
|
|
112
|
+
reasons.push(`dep ${dep.id} needs checkpoint (is ${depTask.lifecycle})`);
|
|
113
|
+
} else if (gate === 'accepted' && depTask.lifecycle !== 'accepted') {
|
|
114
|
+
reasons.push(`dep ${dep.id} needs accepted (is ${depTask.lifecycle})`);
|
|
115
|
+
} else if (gate === 'phase_complete') {
|
|
116
|
+
reasons.push(`dep ${dep.id} has phase_complete gate (invalid for task-kind dependency)`);
|
|
117
|
+
}
|
|
118
|
+
} else if (dep.kind === 'phase') {
|
|
119
|
+
const depPhaseId = Number(dep.id);
|
|
120
|
+
const depPhase = (state.phases || []).find(p => p.id === depPhaseId);
|
|
121
|
+
if (!depPhase || depPhase.lifecycle !== 'accepted') {
|
|
122
|
+
reasons.push(`phase dep ${dep.id} not accepted`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (reasons.length > 0) {
|
|
127
|
+
diagnostics.push({ id: task.id, reasons });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { task: undefined, diagnostics };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Propagate invalidation to downstream dependents when a task is reworked.
|
|
136
|
+
* If contractChanged is true, all transitive dependents get needs_revalidation
|
|
137
|
+
* and their evidence_refs are cleared.
|
|
138
|
+
*/
|
|
139
|
+
export function propagateInvalidation(phase, reworkTaskId, contractChanged) {
|
|
140
|
+
if (!contractChanged) return;
|
|
141
|
+
|
|
142
|
+
const affected = new Set();
|
|
143
|
+
const queue = [reworkTaskId];
|
|
144
|
+
|
|
145
|
+
while (queue.length > 0) {
|
|
146
|
+
const currentId = queue.shift();
|
|
147
|
+
for (const task of phase.todo) {
|
|
148
|
+
if (affected.has(task.id)) continue;
|
|
149
|
+
const dependsOnCurrent = (task.requires || []).some(dep =>
|
|
150
|
+
dep.kind === 'task' && dep.id === currentId
|
|
151
|
+
);
|
|
152
|
+
if (dependsOnCurrent) {
|
|
153
|
+
affected.add(task.id);
|
|
154
|
+
queue.push(task.id);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// C-2: Only transition tasks whose lifecycle allows needs_revalidation
|
|
160
|
+
const canInvalidate = new Set(
|
|
161
|
+
Object.entries(TASK_LIFECYCLE)
|
|
162
|
+
.filter(([, targets]) => targets.includes('needs_revalidation'))
|
|
163
|
+
.map(([state]) => state),
|
|
164
|
+
);
|
|
165
|
+
for (const task of phase.todo) {
|
|
166
|
+
if (affected.has(task.id) && canInvalidate.has(task.lifecycle)) {
|
|
167
|
+
task.lifecycle = 'needs_revalidation';
|
|
168
|
+
task.evidence_refs = [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build executor context for a task: 6-field protocol.
|
|
175
|
+
* Returns { task_spec, research_decisions, predecessor_outputs, project_conventions, workflows, constraints }.
|
|
176
|
+
*/
|
|
177
|
+
export function buildExecutorContext(state, taskId, phaseId) {
|
|
178
|
+
const phase = state.phases.find(p => p.id === phaseId);
|
|
179
|
+
if (!phase) {
|
|
180
|
+
return { error: true, message: `Phase ${phaseId} not found` };
|
|
181
|
+
}
|
|
182
|
+
if (!Array.isArray(phase.todo)) {
|
|
183
|
+
return { error: true, message: `Phase ${phaseId} has invalid todo list` };
|
|
184
|
+
}
|
|
185
|
+
const task = phase.todo.find(t => t.id === taskId);
|
|
186
|
+
if (!task) {
|
|
187
|
+
return { error: true, message: `Task ${taskId} not found in phase ${phaseId}` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const task_spec = `phases/phase-${phaseId}.md`;
|
|
191
|
+
|
|
192
|
+
const research_decisions = (task.research_basis || []).map(id => {
|
|
193
|
+
const decision = state.research?.decision_index?.[id];
|
|
194
|
+
return decision ? { id, ...decision } : { id, summary: 'not found' };
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const predecessor_outputs = (task.requires || [])
|
|
198
|
+
.filter(dep => dep.kind === 'task')
|
|
199
|
+
.map(dep => {
|
|
200
|
+
const depTask = phase.todo.find(t => t.id === dep.id);
|
|
201
|
+
return depTask ? { files_changed: depTask.files_changed || [], checkpoint_commit: depTask.checkpoint_commit } : null;
|
|
202
|
+
})
|
|
203
|
+
.filter(Boolean);
|
|
204
|
+
|
|
205
|
+
const project_conventions = 'CLAUDE.md';
|
|
206
|
+
const workflows = ['workflows/tdd-cycle.md', 'workflows/deviation-rules.md'];
|
|
207
|
+
if ((task.retry_count || 0) > 0) workflows.push('workflows/debugging.md');
|
|
208
|
+
if ((task.research_basis || []).length > 0) workflows.push('workflows/research.md');
|
|
209
|
+
const constraints = {
|
|
210
|
+
retry_count: task.retry_count || 0,
|
|
211
|
+
level: task.level || 'L1',
|
|
212
|
+
review_required: task.review_required !== false,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const debugger_guidance = task.debug_context ? {
|
|
216
|
+
root_cause: task.debug_context.root_cause,
|
|
217
|
+
fix_direction: task.debug_context.fix_direction,
|
|
218
|
+
fix_attempts: task.debug_context.fix_attempts,
|
|
219
|
+
evidence: task.debug_context.evidence || [],
|
|
220
|
+
} : null;
|
|
221
|
+
|
|
222
|
+
const rework_feedback = Array.isArray(task.last_review_feedback) && task.last_review_feedback.length > 0
|
|
223
|
+
? task.last_review_feedback
|
|
224
|
+
: null;
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
task_spec,
|
|
228
|
+
research_decisions,
|
|
229
|
+
predecessor_outputs,
|
|
230
|
+
project_conventions,
|
|
231
|
+
workflows,
|
|
232
|
+
constraints,
|
|
233
|
+
debugger_guidance,
|
|
234
|
+
rework_feedback,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const SENSITIVE_KEYWORDS = /\b(auth|payment|security|public.?api|login|token|credential|session|oauth)\b/i;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Reclassify review level at runtime based on executor results.
|
|
242
|
+
* Upgrades L1->L2 when: contract_changed + sensitive keywords, [LEVEL-UP], or low confidence.
|
|
243
|
+
* Downgrades L1->L0 when: confidence is high and no contract change.
|
|
244
|
+
* Never downgrades L2/L3.
|
|
245
|
+
*/
|
|
246
|
+
export function reclassifyReviewLevel(task, executorResult) {
|
|
247
|
+
const currentLevel = task.level || 'L1';
|
|
248
|
+
|
|
249
|
+
// Never downgrade
|
|
250
|
+
if (currentLevel === 'L2' || currentLevel === 'L3') {
|
|
251
|
+
return currentLevel;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Check for explicit [LEVEL-UP] in decisions
|
|
255
|
+
const hasLevelUp = (executorResult.decisions || []).some(d =>
|
|
256
|
+
(typeof d === 'string' && d.includes('[LEVEL-UP]'))
|
|
257
|
+
|| (d && typeof d === 'object' && typeof d.summary === 'string' && d.summary.includes('[LEVEL-UP]'))
|
|
258
|
+
);
|
|
259
|
+
if (hasLevelUp) return 'L2';
|
|
260
|
+
|
|
261
|
+
// Check for contract change + sensitive keyword in task name
|
|
262
|
+
if (executorResult.contract_changed && SENSITIVE_KEYWORDS.test(task.name || '')) {
|
|
263
|
+
return 'L2';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Confidence-based adjustment: low confidence upgrades L1 → L2
|
|
267
|
+
if (executorResult.confidence === 'low' && currentLevel === 'L1') {
|
|
268
|
+
return 'L2';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// High confidence on non-sensitive L1 tasks → downgrade to L0 (self-review sufficient)
|
|
272
|
+
if (executorResult.confidence === 'high' && currentLevel === 'L1'
|
|
273
|
+
&& !executorResult.contract_changed) {
|
|
274
|
+
return 'L0';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return currentLevel;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const MIN_TOKEN_LENGTH = 2;
|
|
281
|
+
const MIN_OVERLAP = 2;
|
|
282
|
+
|
|
283
|
+
// High-frequency words too generic for meaningful keyword matching
|
|
284
|
+
const STOPWORDS = new Set([
|
|
285
|
+
'the', 'and', 'for', 'with', 'this', 'that', 'from', 'have', 'not',
|
|
286
|
+
'but', 'are', 'was', 'been', 'will', 'can', 'should', 'would', 'could',
|
|
287
|
+
'use', 'using', 'need', 'needs', 'into', 'also', 'when', 'then',
|
|
288
|
+
'than', 'more', 'some', 'does', 'did', 'its', 'has', 'all', 'any',
|
|
289
|
+
'error', 'data', 'type', 'value', 'file', 'code', 'function',
|
|
290
|
+
'return', 'null', 'true', 'false', 'undefined', 'object', 'string',
|
|
291
|
+
'number', 'array', 'list', 'map', 'set', 'key', 'name',
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Tokenize a string into lowercase tokens, splitting on whitespace and punctuation.
|
|
296
|
+
* Filters out short tokens (< MIN_TOKEN_LENGTH) and stopwords.
|
|
297
|
+
*/
|
|
298
|
+
function tokenize(text) {
|
|
299
|
+
if (!text) return [];
|
|
300
|
+
return text
|
|
301
|
+
.toLowerCase()
|
|
302
|
+
.split(/[\s,.:;!?()[\]{}<>/\\|@#$%^&*+=~`'",。:;!?()【】、]+/)
|
|
303
|
+
.filter(t => t.length >= MIN_TOKEN_LENGTH && !STOPWORDS.has(t));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Match a blocked reason against research decisions by keyword overlap.
|
|
308
|
+
* Returns the best-matching decision or null if no sufficient overlap.
|
|
309
|
+
*/
|
|
310
|
+
export function matchDecisionForBlocker(decisions, blockedReason) {
|
|
311
|
+
const reasonTokens = new Set(tokenize(blockedReason));
|
|
312
|
+
if (reasonTokens.size === 0) return null;
|
|
313
|
+
|
|
314
|
+
let bestMatch = null;
|
|
315
|
+
let bestOverlap = 0;
|
|
316
|
+
|
|
317
|
+
for (const decision of decisions) {
|
|
318
|
+
const summaryTokens = tokenize(decision.summary);
|
|
319
|
+
let overlap = 0;
|
|
320
|
+
for (const token of summaryTokens) {
|
|
321
|
+
if (reasonTokens.has(token)) {
|
|
322
|
+
overlap++;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (overlap >= MIN_OVERLAP && overlap > bestOverlap) {
|
|
326
|
+
bestOverlap = overlap;
|
|
327
|
+
bestMatch = decision;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return bestMatch;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Apply research refresh: compare new research decisions against existing state.
|
|
336
|
+
* 4 rules:
|
|
337
|
+
* 1. Same ID + same summary -> update metadata (e.g. expires_at), keep task lifecycle
|
|
338
|
+
* 2. Same ID + changed summary -> invalidate dependent tasks (needs_revalidation)
|
|
339
|
+
* 3. Old ID missing from new -> invalidate dependent tasks + warning
|
|
340
|
+
* 4. Brand new ID -> add to index, no impact on existing tasks
|
|
341
|
+
* Returns { warnings: string[] }.
|
|
342
|
+
*/
|
|
343
|
+
export function applyResearchRefresh(state, newResearch) {
|
|
344
|
+
const warnings = [];
|
|
345
|
+
const oldIndex = state.research?.decision_index || {};
|
|
346
|
+
const newIndex = newResearch?.decision_index || {};
|
|
347
|
+
|
|
348
|
+
// Copy-on-write: build merged index without mutating oldIndex
|
|
349
|
+
const mergedIndex = { ...oldIndex };
|
|
350
|
+
|
|
351
|
+
// Collect IDs of decisions that changed or were removed
|
|
352
|
+
const invalidatedIds = new Set();
|
|
353
|
+
|
|
354
|
+
// Check existing decisions against new
|
|
355
|
+
for (const [id, oldDecision] of Object.entries(oldIndex)) {
|
|
356
|
+
if (id in newIndex) {
|
|
357
|
+
const newDecision = newIndex[id];
|
|
358
|
+
if (oldDecision.summary === newDecision.summary) {
|
|
359
|
+
// Rule 1: same conclusion — update metadata
|
|
360
|
+
mergedIndex[id] = { ...oldDecision, ...newDecision };
|
|
361
|
+
} else {
|
|
362
|
+
// Rule 2: changed conclusion — replace and invalidate
|
|
363
|
+
mergedIndex[id] = newDecision;
|
|
364
|
+
invalidatedIds.add(id);
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
// Rule 3: old ID missing from new research
|
|
368
|
+
invalidatedIds.add(id);
|
|
369
|
+
warnings.push(`Decision "${id}" removed in new research — dependent tasks invalidated`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Rule 4: brand new IDs — just add them
|
|
374
|
+
for (const [id, newDecision] of Object.entries(newIndex)) {
|
|
375
|
+
if (!(id in oldIndex)) {
|
|
376
|
+
mergedIndex[id] = newDecision;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Assign merged index to state (atomic replacement)
|
|
381
|
+
if (!state.research) state.research = {};
|
|
382
|
+
state.research.decision_index = mergedIndex;
|
|
383
|
+
|
|
384
|
+
// C-3: Only invalidate tasks whose lifecycle allows needs_revalidation
|
|
385
|
+
if (invalidatedIds.size > 0) {
|
|
386
|
+
const canInvalidate = new Set(
|
|
387
|
+
Object.entries(TASK_LIFECYCLE)
|
|
388
|
+
.filter(([, targets]) => targets.includes('needs_revalidation'))
|
|
389
|
+
.map(([s]) => s),
|
|
390
|
+
);
|
|
391
|
+
for (const phase of (state.phases || [])) {
|
|
392
|
+
for (const task of (phase.todo || [])) {
|
|
393
|
+
const basis = task.research_basis || [];
|
|
394
|
+
const affected = basis.some(id => invalidatedIds.has(id));
|
|
395
|
+
if (affected && canInvalidate.has(task.lifecycle)) {
|
|
396
|
+
task.lifecycle = 'needs_revalidation';
|
|
397
|
+
if (task.evidence_refs) task.evidence_refs = [];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return { warnings };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function storeResearch({ result, artifacts, decision_index, basePath = process.cwd() } = {}) {
|
|
407
|
+
const resultValidation = validateResearcherResult(result || {});
|
|
408
|
+
if (!resultValidation.valid) {
|
|
409
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid researcher result: ${resultValidation.errors.join('; ')}` };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const artifactsValidation = validateResearchArtifacts(artifacts);
|
|
413
|
+
if (!artifactsValidation.valid) {
|
|
414
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid research artifacts: ${artifactsValidation.errors.join('; ')}` };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const decisionIndexValidation = validateResearchDecisionIndex(decision_index, result.decision_ids);
|
|
418
|
+
if (!decisionIndexValidation.valid) {
|
|
419
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const statePath = await getStatePath(basePath);
|
|
423
|
+
if (!statePath) {
|
|
424
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: 'No .gsd directory found' };
|
|
425
|
+
}
|
|
426
|
+
ensureLockPathFromStatePath(statePath);
|
|
427
|
+
|
|
428
|
+
return withStateLock(async () => {
|
|
429
|
+
const current = await readJson(statePath);
|
|
430
|
+
if (!current.ok) {
|
|
431
|
+
return { error: true, code: ERROR_CODES.NO_PROJECT_DIR, message: current.error };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const state = current.data;
|
|
435
|
+
const gsdDir = dirname(statePath);
|
|
436
|
+
const researchDir = join(gsdDir, 'research');
|
|
437
|
+
await ensureDir(researchDir);
|
|
438
|
+
|
|
439
|
+
// Atomic multi-file write: write all artifacts first, then rename in batch
|
|
440
|
+
const normalizedArtifacts = normalizeResearchArtifacts(artifacts);
|
|
441
|
+
const tmpSuffix = `.${process.pid}-${Date.now()}.tmp`;
|
|
442
|
+
const tmpPaths = [];
|
|
443
|
+
try {
|
|
444
|
+
for (const fileName of RESEARCH_FILES) {
|
|
445
|
+
const finalPath = join(researchDir, fileName);
|
|
446
|
+
const tmpFile = finalPath + tmpSuffix;
|
|
447
|
+
tmpPaths.push({ tmp: tmpFile, final: finalPath });
|
|
448
|
+
await writeFile(tmpFile, normalizedArtifacts[fileName], 'utf-8');
|
|
449
|
+
}
|
|
450
|
+
// All writes succeeded — rename in batch
|
|
451
|
+
for (const { tmp, final: finalPath } of tmpPaths) {
|
|
452
|
+
await rename(tmp, finalPath);
|
|
453
|
+
}
|
|
454
|
+
} catch (err) {
|
|
455
|
+
// Cleanup any temp files on failure
|
|
456
|
+
for (const { tmp } of tmpPaths) {
|
|
457
|
+
try { await unlink(tmp); } catch {}
|
|
458
|
+
}
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const nextResearchBase = {
|
|
463
|
+
volatility: result.volatility,
|
|
464
|
+
expires_at: result.expires_at,
|
|
465
|
+
sources: result.sources,
|
|
466
|
+
files: RESEARCH_FILES,
|
|
467
|
+
updated_at: new Date().toISOString(),
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const refreshResult = state.research
|
|
471
|
+
? applyResearchRefresh(state, { ...nextResearchBase, decision_index })
|
|
472
|
+
: { warnings: [] };
|
|
473
|
+
|
|
474
|
+
// After applyResearchRefresh, state.research.decision_index is the merged result
|
|
475
|
+
const mergedDecisionIndex = state.research?.decision_index || decision_index;
|
|
476
|
+
state.research = {
|
|
477
|
+
...(state.research || {}),
|
|
478
|
+
...nextResearchBase,
|
|
479
|
+
decision_index: mergedDecisionIndex,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
if (state.workflow_mode === 'research_refresh_needed') {
|
|
483
|
+
state.workflow_mode = inferWorkflowModeAfterResearch(state);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Recompute done after applyResearchRefresh may have invalidated tasks
|
|
487
|
+
for (const phase of (state.phases || [])) {
|
|
488
|
+
if (Array.isArray(phase.todo)) {
|
|
489
|
+
phase.done = phase.todo.filter(t => t.lifecycle === 'accepted').length;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const validation = validateState(state);
|
|
494
|
+
if (!validation.valid) {
|
|
495
|
+
return { error: true, code: ERROR_CODES.VALIDATION_FAILED, message: `State validation failed: ${validation.errors.join('; ')}` };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
await writeJson(statePath, state);
|
|
499
|
+
return {
|
|
500
|
+
success: true,
|
|
501
|
+
workflow_mode: state.workflow_mode,
|
|
502
|
+
stored_files: RESEARCH_FILES,
|
|
503
|
+
decision_ids: result.decision_ids,
|
|
504
|
+
warnings: refreshResult.warnings,
|
|
505
|
+
research: state.research,
|
|
506
|
+
};
|
|
507
|
+
});
|
|
508
|
+
}
|