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.
@@ -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
+ }