@smartmemory/compose 0.2.8-beta → 0.2.9-beta

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.
Files changed (63) hide show
  1. package/bin/compose.js +75 -1
  2. package/contracts/gsd-state.json +140 -0
  3. package/dist/assets/{App-D3ehVPvi.js → App-CG-2euMe.js} +164 -164
  4. package/dist/assets/{arc-Dmf69iHG.js → arc-7QBWoLra.js} +1 -1
  5. package/dist/assets/{architectureDiagram-3BPJPVTR-xYo993Yw.js → architectureDiagram-3BPJPVTR-CUw-7uLm.js} +1 -1
  6. package/dist/assets/{blockDiagram-GPEHLZMM-UX4EF98O.js → blockDiagram-GPEHLZMM-COU1vmr7.js} +1 -1
  7. package/dist/assets/{c4Diagram-AAUBKEIU-DaP9CGWb.js → c4Diagram-AAUBKEIU-XPO9PSJL.js} +1 -1
  8. package/dist/assets/channel-Bcu04MIK.js +1 -0
  9. package/dist/assets/{chunk-2J33WTMH-CKk_RN3A.js → chunk-2J33WTMH-zMzVB2a6.js} +1 -1
  10. package/dist/assets/{chunk-4BX2VUAB-DboAwYKw.js → chunk-4BX2VUAB-Kke_qcHU.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-Dsy9RYvI.js → chunk-55IACEB6-hMeFx5Nh.js} +1 -1
  12. package/dist/assets/{chunk-727SXJPM-fAH0QO9v.js → chunk-727SXJPM-DesUnrEw.js} +1 -1
  13. package/dist/assets/{chunk-AQP2D5EJ-DyZYerFP.js → chunk-AQP2D5EJ-1uGGvkxW.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-BnboGO5t.js → chunk-FMBD7UC4-DYHv1PcZ.js} +1 -1
  15. package/dist/assets/{chunk-ND2GUHAM-Di9tYXme.js → chunk-ND2GUHAM-D0MENOLX.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-zRPRlAIL.js → chunk-QZHKN3VN-8nn3HP-N.js} +1 -1
  17. package/dist/assets/classDiagram-4FO5ZUOK-DU4yxldU.js +1 -0
  18. package/dist/assets/classDiagram-v2-Q7XG4LA2-DU4yxldU.js +1 -0
  19. package/dist/assets/{cose-bilkent-S5V4N54A-C7Hqukaf.js → cose-bilkent-S5V4N54A-BoZPVIny.js} +1 -1
  20. package/dist/assets/{dagre-BM42HDAG-B-cR-BjI.js → dagre-BM42HDAG-BgZzdLG9.js} +1 -1
  21. package/dist/assets/{diagram-2AECGRRQ-B6-5onDk.js → diagram-2AECGRRQ-CknAnpSu.js} +1 -1
  22. package/dist/assets/{diagram-5GNKFQAL-DoZZgFAM.js → diagram-5GNKFQAL-CZUEbKim.js} +1 -1
  23. package/dist/assets/{diagram-KO2AKTUF-77jEGlJh.js → diagram-KO2AKTUF-DCs-pLdH.js} +1 -1
  24. package/dist/assets/{diagram-LMA3HP47-D3S7XDRD.js → diagram-LMA3HP47-lRaDjIfM.js} +1 -1
  25. package/dist/assets/{diagram-OG6HWLK6-KbYL9aCY.js → diagram-OG6HWLK6-CIGqmehP.js} +1 -1
  26. package/dist/assets/{erDiagram-TEJ5UH35-DezFbJP-.js → erDiagram-TEJ5UH35-Lx3c2N6F.js} +1 -1
  27. package/dist/assets/{flowDiagram-I6XJVG4X-4x31cK9j.js → flowDiagram-I6XJVG4X-VoluKqSq.js} +1 -1
  28. package/dist/assets/{ganttDiagram-6RSMTGT7-FopfSTyZ.js → ganttDiagram-6RSMTGT7-D7hETiNZ.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-PVQCEYII-DSiQGKbN.js → gitGraphDiagram-PVQCEYII-DenEcUvY.js} +1 -1
  30. package/dist/assets/{index-ClX6LVAf.js → index-B4dv3acY.js} +2 -2
  31. package/dist/assets/{infoDiagram-5YYISTIA-DE6BqzK_.js → infoDiagram-5YYISTIA-v7cq9Er9.js} +1 -1
  32. package/dist/assets/{ishikawaDiagram-YF4QCWOH-Dml8NwQI.js → ishikawaDiagram-YF4QCWOH-CfCCXt2x.js} +1 -1
  33. package/dist/assets/{journeyDiagram-JHISSGLW-CwWeJgjE.js → journeyDiagram-JHISSGLW-Bbokl_xO.js} +1 -1
  34. package/dist/assets/{kanban-definition-UN3LZRKU-DnG956Wh.js → kanban-definition-UN3LZRKU-DhkOZ2hg.js} +1 -1
  35. package/dist/assets/{linear-CA3N7Rpi.js → linear-bHjluRm2.js} +1 -1
  36. package/dist/assets/{mindmap-definition-RKZ34NQL-CxfIOjLX.js → mindmap-definition-RKZ34NQL-C1bHpoXH.js} +1 -1
  37. package/dist/assets/{pieDiagram-4H26LBE5-O7aIwy1x.js → pieDiagram-4H26LBE5-CZb1i55T.js} +1 -1
  38. package/dist/assets/{quadrantDiagram-W4KKPZXB-CPQ2qq7c.js → quadrantDiagram-W4KKPZXB-o37AwRHB.js} +1 -1
  39. package/dist/assets/{requirementDiagram-4Y6WPE33-C23horL4.js → requirementDiagram-4Y6WPE33-BVErWDzU.js} +1 -1
  40. package/dist/assets/{sankeyDiagram-5OEKKPKP-DPY04kOW.js → sankeyDiagram-5OEKKPKP-BhBK8gHQ.js} +1 -1
  41. package/dist/assets/{sequenceDiagram-3UESZ5HK-BKaTfIvo.js → sequenceDiagram-3UESZ5HK-CsICF23P.js} +1 -1
  42. package/dist/assets/{stateDiagram-AJRCARHV-B9na_6mY.js → stateDiagram-AJRCARHV-TN1AXwim.js} +1 -1
  43. package/dist/assets/stateDiagram-v2-BHNVJYJU-BLR6AkKX.js +1 -0
  44. package/dist/assets/{timeline-definition-PNZ67QCA-BBWPqd7X.js → timeline-definition-PNZ67QCA-DftAajbU.js} +1 -1
  45. package/dist/assets/{vennDiagram-CIIHVFJN-tWqiHsOZ.js → vennDiagram-CIIHVFJN-cFTMstT7.js} +1 -1
  46. package/dist/assets/{wardley-L42UT6IY-DorxG6os.js → wardley-L42UT6IY-DL8CivzO.js} +1 -1
  47. package/dist/assets/{wardleyDiagram-YWT4CUSO-B49f8GzW.js → wardleyDiagram-YWT4CUSO-BDZT1hQj.js} +1 -1
  48. package/dist/assets/{xychartDiagram-2RQKCTM6-BgKSj8Qb.js → xychartDiagram-2RQKCTM6-DQQSkfC4.js} +1 -1
  49. package/dist/index.html +1 -1
  50. package/lib/build.js +36 -2
  51. package/lib/gsd-diff-capture.js +34 -0
  52. package/lib/gsd-events.js +61 -0
  53. package/lib/gsd-headless-config.js +110 -0
  54. package/lib/gsd-milestone-report.js +323 -0
  55. package/lib/gsd-state.js +165 -0
  56. package/lib/gsd-supervisor.js +223 -0
  57. package/lib/gsd-timing.js +89 -0
  58. package/lib/gsd.js +446 -45
  59. package/package.json +1 -1
  60. package/dist/assets/channel-D_RXsFFT.js +0 -1
  61. package/dist/assets/classDiagram-4FO5ZUOK-K6wdB4ic.js +0 -1
  62. package/dist/assets/classDiagram-v2-Q7XG4LA2-K6wdB4ic.js +0 -1
  63. package/dist/assets/stateDiagram-v2-BHNVJYJU-Cf84VDiH.js +0 -1
package/lib/gsd.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // V1 limitation: runtime task-to-task handoff is not implemented; tasks see
14
14
  // only spec-level upstream context (Boundary Map declarations) per blueprint.
15
15
 
16
- import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, rmSync } from 'node:fs';
16
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, rmSync, statSync, renameSync } from 'node:fs';
17
17
  import { join, resolve, dirname } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { execSync } from 'node:child_process';
@@ -27,6 +27,12 @@ import { executeParallelDispatchServer, executeShipStep } from './build.js';
27
27
  import { GsdStuckDetector, DEFAULT_THRESHOLDS } from './gsd-stuck.js';
28
28
  import { readGsdBudgetConfig, buildBudgetBlock, injectBudget, composeBudgetDiagnostic } from './gsd-budget.js';
29
29
  import { recordGsdUsage, checkGsdCumulativeBudget } from './budget-ledger.js';
30
+ // COMP-GSD-6: continuous run-state checkpoint + canonical pid-liveness probe.
31
+ // pidAlive is canonical in gsd-state.js (EPERM=alive) and imported one-way here.
32
+ import { writeGsdState, readGsdState, gsdStatePath, pidAlive, clearGsdHaltArtifacts } from './gsd-state.js';
33
+ import { generateGsdMilestoneReport } from './gsd-milestone-report.js';
34
+ import { readHeadlessConfig } from './gsd-headless-config.js';
35
+ import { appendGsdEvent, clearGsdEvents } from './gsd-events.js';
30
36
 
31
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
32
38
  const PACKAGE_ROOT = resolve(__dirname, '..');
@@ -41,6 +47,15 @@ export async function runGsd(featureCode, opts = {}) {
41
47
  }
42
48
  const cwd = opts.cwd ?? process.cwd();
43
49
 
50
+ // COMP-GSD-6: a FRESH (non-resume) run must not inherit a prior run's
51
+ // state.json. Clear it up front so that if a precondition below throws BEFORE
52
+ // the planning checkpoint, NO running state remains → the headless supervisor
53
+ // (and `query`) read 'absent' → fatal-by-absence, never a stale 'complete'
54
+ // success. A resume keeps the old state.json (the crash-bridge may need it).
55
+ if (!opts.resume) {
56
+ try { rmSync(gsdStatePath(cwd, featureCode), { force: true }); } catch { /* ignore */ }
57
+ }
58
+
44
59
  // 1. Validate preconditions: blueprint exists + Boundary Map ok
45
60
  const blueprintPath = join(cwd, 'docs', 'features', featureCode, 'blueprint.md');
46
61
  if (!existsSync(blueprintPath)) {
@@ -143,7 +158,20 @@ export async function runGsd(featureCode, opts = {}) {
143
158
  // from clobbering a concurrent resume's valid claim and (b) a claim-race loser
144
159
  // (EEXIST) from deleting the winner's lock on its way out.
145
160
  let lockClaimed = false;
161
+ let runLockClaimed = false;
162
+ // COMP-GSD-6: the in-memory run-state, threaded through stepCtx and flushed to
163
+ // state.json. Declared here so the catch/finally can read it.
164
+ let stepCtx = null;
165
+ // COMP-GSD-6-WATCHDOG: independent wall-clock heartbeat timer (see below).
166
+ // Declared here so the finally can always clear it.
167
+ let heartbeatTimer = null;
146
168
  try {
169
+ // COMP-GSD-6: claim the live-run lock BEFORE any stratum side effect, so two
170
+ // fresh `compose gsd <same-feature>` runs can't race the results dir. Takes
171
+ // over a stale lock (dead owner) and refuses a live one.
172
+ claimRunLock(cwd, featureCode);
173
+ runLockClaimed = true;
174
+
147
175
  // COMP-GSD-4: claim the resume lock HERE (first statement in the try) so the
148
176
  // finally releases it on EVERY exit — budget/stuck re-halt, throw, or clean
149
177
  // finish. loadResumeTaskGraph above already read+guarded (claim:false).
@@ -152,22 +180,90 @@ export async function runGsd(featureCode, opts = {}) {
152
180
  lockClaimed = true;
153
181
  }
154
182
 
155
- let response = await stratum.plan(specYaml, 'gsd', {
156
- featureCode,
157
- gateCommands,
158
- });
159
- const flowId = response.flow_id;
183
+ // COMP-GSD-6: pre-plan "planning" checkpoint. A crash during plan/decompose
184
+ // now leaves a dead-pid state.json — the failed-vs-fatal boundary. A throw
185
+ // BEFORE this point (preconditions) leaves no running state → fatal by
186
+ // absence; a throw AFTER → the catch converts it to status:"failed".
187
+ // On resume, seed the planning checkpoint from the (in-memory) resume graph
188
+ // so that if THIS resume re-crashes before its decompose step repopulates
189
+ // state.json, the crash-bridge still has a task graph to recover from
190
+ // (otherwise the fresh empty checkpoint would clobber the prior good data).
191
+ const resumeTasks = opts.resume ? (resumeTaskGraph?.tasks ?? []).map((t) => ({ ...t })) : [];
192
+ const initialState = {
193
+ feature: featureCode,
194
+ flowId: null,
195
+ pid: process.pid,
196
+ mode: 'gsd',
197
+ phase: 'planning',
198
+ status: 'running',
199
+ startedAt: new Date().toISOString(),
200
+ headless: !!opts.headless,
201
+ attempt: opts.attempt ?? 1,
202
+ resumeReady: opts.resume && resumeTasks.length > 0,
203
+ decomposedTasks: resumeTasks,
204
+ completedTaskIds: collectCompletedTaskIds(cwd, featureCode),
205
+ };
160
206
 
161
207
  // Track files merged into the base cwd by the execute step so ship_gsd
162
208
  // can stage them. executeShipStep's default filter only stages feature
163
209
  // docs unless context.filesChanged is provided.
164
- const stepCtx = {
210
+ stepCtx = {
165
211
  stratum, cwd, featureCode, blueprintText, gateCommands,
166
212
  filesChanged: [],
167
213
  stuckDetector,
168
214
  resumeTaskGraph,
169
215
  stuck: null, // set by runOneStep on a stuck verdict
216
+ runState: initialState, // COMP-GSD-6: flushState merges into this
217
+ // COMP-GSD-7-EVENTLOG: tasks already completed at run start (a resume
218
+ // preloads them) are seeded as already-emitted so the appended log never
219
+ // re-fires task_completed for prior-session completions.
220
+ emittedCompletions: new Set(initialState.completedTaskIds),
221
+ // COMP-GSD-7-EVENTLOG: phases already announced (dedupe — runState.phase is
222
+ // set to 'execute' before the merge checkpoint, so it can't gate emission).
223
+ emittedPhases: new Set(),
170
224
  };
225
+ flushState(stepCtx, {}); // write the planning checkpoint
226
+
227
+ // COMP-GSD-7-EVENTLOG: at the planning checkpoint — AFTER preconditions
228
+ // passed (so a failed fresh invocation never wipes a prior run's history) —
229
+ // a fresh run truncates the event log and clears stale halt artifacts so the
230
+ // timeline reflects only this run; a resume appends to the existing log.
231
+ if (!opts.resume) {
232
+ clearGsdEvents(cwd, featureCode);
233
+ clearGsdHaltArtifacts(cwd, featureCode);
234
+ }
235
+ appendGsdEvent(cwd, featureCode, 'run_started', {
236
+ mode: opts.resume ? 'resume' : 'fresh',
237
+ attempt: opts.attempt ?? 1,
238
+ });
239
+
240
+ // COMP-GSD-6-WATCHDOG: an INDEPENDENT wall-clock heartbeat. The existing
241
+ // heartbeat only advances on agent push-events (onHeartbeat below), so a
242
+ // quiet-but-healthy task would look stale. This timer restamps state.json's
243
+ // heartbeat on a fixed cadence whenever the event loop is still turning — so
244
+ // a stale heartbeat genuinely means the loop is WEDGED (or the process dead),
245
+ // which is what the headless watchdog keys its hung-kill on. .unref() so it
246
+ // never holds the process open; cleared in finally. Same empty-patch restamp
247
+ // onHeartbeat uses, so it's behavior-compatible.
248
+ //
249
+ // Gated to SUPERVISED children only (GSD_HEADLESS_ATTEMPT, set by the
250
+ // supervisor's spawner) — the supervisor is the sole watcher, so an
251
+ // interactive `compose gsd` stays byte-identical (no extra state.json writes).
252
+ if (process.env.GSD_HEADLESS_ATTEMPT != null) {
253
+ const hbMs = readHeadlessConfig(cwd).watchdogHeartbeatMs;
254
+ heartbeatTimer = setInterval(() => {
255
+ try { if (stepCtx?.runState) flushState(stepCtx, {}); } catch { /* best-effort */ }
256
+ }, hbMs);
257
+ heartbeatTimer.unref?.();
258
+ }
259
+
260
+ let response = await stratum.plan(specYaml, 'gsd', {
261
+ featureCode,
262
+ gateCommands,
263
+ });
264
+ const flowId = response.flow_id;
265
+ flushState(stepCtx, { flowId, phase: 'decompose' });
266
+ emitPhaseOnce(stepCtx, 'decompose'); // COMP-GSD-7-EVENTLOG
171
267
 
172
268
  // 5. Status loop. `stuck` (COMP-GSD-5) and `budget_exhausted` (COMP-GSD-4)
173
269
  // are terminal statuses. `stuck` is set compose-side by runOneStep; budget
@@ -184,6 +280,12 @@ export async function runGsd(featureCode, opts = {}) {
184
280
 
185
281
  if (response.status === 'stuck') {
186
282
  // Artifacts (stuck.md/json + pause.json) were written by runOneStep.
283
+ // COMP-GSD-7-EVENTLOG: flush any completions that finished before the stuck
284
+ // verdict (the stuck path returns early, before the execute-merge delta),
285
+ // then record the pause.
286
+ emitCompletionDeltas(stepCtx);
287
+ appendGsdEvent(cwd, featureCode, 'paused', { pauseKind: 'stuck', taskId: stepCtx.stuck?.taskId ?? null });
288
+ flushState(stepCtx, { status: 'stuck' }); // COMP-GSD-6 terminal checkpoint
187
289
  return {
188
290
  status: 'stuck',
189
291
  flowId,
@@ -201,6 +303,10 @@ export async function runGsd(featureCode, opts = {}) {
201
303
  writeBudgetArtifacts(stepCtx, response, budgetState);
202
304
  recordGsdUsageFromState(cwd, featureCode, budgetState);
203
305
  const axis = composeBudgetDiagnostic(budgetState, { feature: featureCode }).json.axis;
306
+ // COMP-GSD-7-EVENTLOG: flush pre-halt completions, then record the pause.
307
+ emitCompletionDeltas(stepCtx);
308
+ appendGsdEvent(cwd, featureCode, 'paused', { pauseKind: 'budget', axis });
309
+ flushState(stepCtx, { status: 'budget' }); // COMP-GSD-6 terminal checkpoint
204
310
  return { status: 'budget', flowId, axis, consumed: budgetState.consumed ?? {}, caps: budgetState.caps ?? {} };
205
311
  }
206
312
 
@@ -218,14 +324,73 @@ export async function runGsd(featureCode, opts = {}) {
218
324
  // the complete envelope carries no budget_state, e.g. un-budgeted runs).
219
325
  recordGsdUsageFromState(cwd, featureCode, response.budget_state);
220
326
  clearPauseFile(cwd, featureCode);
327
+ // COMP-GSD-7: on a clean complete, budget.json is NOT written (only halts
328
+ // write it). Persist a budget-final.json snapshot so the milestone report
329
+ // (auto + retroactive `gsd report`) has actuals-vs-caps. No-op when the
330
+ // envelope carries no budget_state (un-budgeted run). Best-effort: this is
331
+ // a derived report input — a write failure must NEVER demote a successful
332
+ // run to 'failed' via the outer catch.
333
+ if (response.budget_state) {
334
+ try {
335
+ writeBudgetFinalSnapshot(stepCtx, response.budget_state);
336
+ } catch (err) {
337
+ console.warn(`[gsd] budget-final snapshot failed: ${err.message}`);
338
+ }
339
+ }
340
+ }
341
+
342
+ // COMP-GSD-6: terminal state.json flush. Only 'complete' is a success; any
343
+ // other terminal here (e.g. stratum 'killed') maps to 'failed' so we stay
344
+ // within the closed status vocabulary the contract + supervisor share.
345
+ // COMP-GSD-7: stamp completedAt so retroactive reports can recover wall-clock.
346
+ const terminalStatus = response.status === 'complete' ? 'complete' : 'failed';
347
+ // COMP-GSD-7-EVENTLOG: emit the terminal event. complete → final completion
348
+ // deltas + 'completed'; any other terminal (e.g. stratum 'killed') → 'failed'.
349
+ if (terminalStatus === 'complete') {
350
+ emitCompletionDeltas(stepCtx);
351
+ appendGsdEvent(cwd, featureCode, 'completed', {});
352
+ } else {
353
+ appendGsdEvent(cwd, featureCode, 'failed', { reason: response.status ?? 'unknown' });
354
+ }
355
+ flushState(stepCtx, { status: terminalStatus, phase: 'done', completedAt: new Date().toISOString() });
356
+
357
+ // COMP-GSD-7: best-effort milestone report on a clean complete. A report
358
+ // failure must never fail the run — it is a derived artifact.
359
+ if (terminalStatus === 'complete') {
360
+ try {
361
+ const r = generateGsdMilestoneReport(featureCode, cwd);
362
+ if (!r.ok) console.warn(`[gsd] milestone report skipped: ${r.error}`);
363
+ } catch (err) {
364
+ console.warn(`[gsd] milestone report generation failed: ${err.message}`);
365
+ }
221
366
  }
222
367
 
368
+ // Return the normalized closed-vocabulary status (not the raw stratum status)
369
+ // so the CLI/callers don't mistake a 'killed' terminal for success.
223
370
  return {
224
- status: response.status,
371
+ status: terminalStatus,
225
372
  flowId,
226
373
  blackboardEntries: Object.keys(blackboard).length,
227
374
  };
375
+ } catch (err) {
376
+ // COMP-GSD-6: an orderly throw AFTER the planning checkpoint becomes a
377
+ // terminal status:"failed" so the supervisor treats it as non-recoverable
378
+ // (vs a hard crash → status stays "running" + dead pid → reader-derived
379
+ // "crashed"). Guard on a persisted running state so pre-checkpoint throws
380
+ // (which left no running state) stay fatal-by-absence, not "failed".
381
+ if (stepCtx?.runState && readGsdState(cwd, featureCode)?.status === 'running') {
382
+ try { flushState(stepCtx, { status: 'failed' }); } catch { /* best-effort */ }
383
+ // COMP-GSD-7-EVENTLOG: record the failure (only when a run actually started
384
+ // — a pre-checkpoint throw left no running state and gets no event). Append
385
+ // is best-effort; never mask the original error.
386
+ appendGsdEvent(cwd, featureCode, 'failed', { reason: err?.message ?? 'error' });
387
+ }
388
+ throw err;
228
389
  } finally {
390
+ // COMP-GSD-6-WATCHDOG: stop the independent heartbeat timer.
391
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
392
+ // COMP-GSD-6: release the live-run lock if THIS process claimed it.
393
+ if (runLockClaimed) releaseRunLock(cwd, featureCode);
229
394
  // COMP-GSD-4: release the resume claim ONLY if THIS process claimed it
230
395
  // (ownership-aware — never clobber a concurrent run's valid claim, and don't
231
396
  // release after losing the claim race). pause.json persists for --resume
@@ -270,11 +435,16 @@ async function runOneStep(response, ctx) {
270
435
  const outcome = await executeParallelDispatchServer(
271
436
  response,
272
437
  stratum,
273
- { cwd, featureCode },
438
+ { cwd, featureCode, gsd: true }, // COMP-GSD-7: gates timing+diff capture
274
439
  null, // progress
275
440
  { write: () => {} }, // streamWriter — no-op for v1
276
441
  cwd,
277
- { stuckDetector: ctx.stuckDetector }, // COMP-GSD-5 (null in non-gsd callers)
442
+ {
443
+ stuckDetector: ctx.stuckDetector, // COMP-GSD-5 (null in non-gsd callers)
444
+ // COMP-GSD-6: bump state.json's heartbeat on every task event so a long
445
+ // task sitting in the dispatch poll loop isn't mistaken for crashed.
446
+ onHeartbeat: ctx.runState ? () => { try { flushState(ctx, {}); } catch { /* best-effort */ } } : null,
447
+ },
278
448
  );
279
449
 
280
450
  // COMP-GSD-5: a stuck verdict halts the run. Persist the diagnostic +
@@ -290,6 +460,15 @@ async function runOneStep(response, ctx) {
290
460
  // staging. The clean-workspace precondition above guarantees every
291
461
  // file in the post-execute dirty set is genuinely a GSD-produced change.
292
462
  ctx.filesChanged = collectChangedFiles(cwd);
463
+ // COMP-GSD-6: checkpoint completed tasks after the execute merge.
464
+ // COMP-GSD-7-EVENTLOG: emit the execute-phase transition once, then a
465
+ // task_completed event per newly-completed task.
466
+ if (ctx.runState) {
467
+ const completed = collectCompletedTaskIds(cwd, featureCode);
468
+ flushState(ctx, { phase: 'execute', completedTaskIds: completed });
469
+ emitPhaseOnce(ctx, 'execute'); // dedupes; runState.phase can't gate this
470
+ emitCompletionDeltas(ctx, completed);
471
+ }
293
472
  // executeParallelDispatchServer returns the next-step dispatch envelope
294
473
  return outcome;
295
474
  }
@@ -320,6 +499,15 @@ async function runOneStep(response, ctx) {
320
499
  // Stable task IDs + no re-decompose are the whole point.
321
500
  if (stepId === 'decompose_gsd' && ctx.resumeTaskGraph) {
322
501
  ctx.lastTaskGraph = ctx.resumeTaskGraph;
502
+ // COMP-GSD-6: a resume already has the (filtered) task graph — mark
503
+ // resumeReady so a re-crash during execute resumes rather than restarts.
504
+ if (ctx.runState) {
505
+ flushState(ctx, {
506
+ phase: 'execute',
507
+ resumeReady: true,
508
+ decomposedTasks: (ctx.resumeTaskGraph.tasks ?? []).map((t) => ({ ...t })),
509
+ });
510
+ }
323
511
  return await stratum.stepDone(flowId, stepId, ctx.resumeTaskGraph);
324
512
  }
325
513
 
@@ -343,6 +531,15 @@ async function runOneStep(response, ctx) {
343
531
  // persist the full task definitions (with descriptions/produces/consumes)
344
532
  // into pause.json — resume re-dispatches these without re-enriching.
345
533
  ctx.lastTaskGraph = result;
534
+ // COMP-GSD-6: the task graph now exists → resumeReady true; persist it so a
535
+ // crash during execute can synthesize a resume graph from state.json.
536
+ if (ctx.runState) {
537
+ flushState(ctx, {
538
+ phase: 'execute',
539
+ resumeReady: true,
540
+ decomposedTasks: (result.tasks ?? []).map((t) => ({ ...t })),
541
+ });
542
+ }
346
543
  }
347
544
 
348
545
  return await stratum.stepDone(flowId, stepId, result);
@@ -507,6 +704,139 @@ function gsdDir(cwd, featureCode) {
507
704
  return join(cwd, '.compose', 'gsd', featureCode);
508
705
  }
509
706
 
707
+ // ===========================================================================
708
+ // COMP-GSD-6: run.lock (live-run exclusivity) + state.json flush helpers
709
+ // ===========================================================================
710
+
711
+ const RUN_LOCK_STALE_MS = 90000;
712
+
713
+ function runLockDir(cwd, featureCode) {
714
+ return join(gsdDir(cwd, featureCode), 'run.lock');
715
+ }
716
+
717
+ // Atomically take over a stale lock dir. The naive `rmSync` + `mkdirSync` is
718
+ // racy — two reclaimers can both see "stale", both rm, and one deletes the
719
+ // other's fresh lock. renameSync IS atomic, so only one racer can rename the
720
+ // stale dir aside; the loser gets ENOENT. The winner removes the renamed copy
721
+ // and re-creates the lock; if a NEW claimant raced into the freed name first,
722
+ // our mkdir gets EEXIST and we (correctly) report we lost. Returns true iff WE
723
+ // recreated the lock.
724
+ function takeoverStaleLock(lockPath) {
725
+ const aside = `${lockPath}.stale.${process.pid}.${Date.now()}`;
726
+ try {
727
+ renameSync(lockPath, aside); // atomic — loser gets ENOENT
728
+ } catch {
729
+ return false; // another racer already took it over (or it vanished)
730
+ }
731
+ try { rmSync(aside, { recursive: true, force: true }); } catch { /* best-effort */ }
732
+ try {
733
+ mkdirSync(lockPath);
734
+ return true;
735
+ } catch (err) {
736
+ if (err.code === 'EEXIST') return false; // a fresh claimant won the freed name
737
+ throw err;
738
+ }
739
+ }
740
+
741
+ // Read the owning pid for a run.lock: run.lock/owner.json first (lock-local
742
+ // record), then state.json (Codex review precedence). Returns a number or null.
743
+ function runLockOwnerPid(cwd, featureCode) {
744
+ const ownerPath = join(runLockDir(cwd, featureCode), 'owner.json');
745
+ if (existsSync(ownerPath)) {
746
+ try {
747
+ const o = JSON.parse(readFileSync(ownerPath, 'utf-8'));
748
+ if (typeof o.pid === 'number') return o.pid;
749
+ } catch { /* fall through to state.json */ }
750
+ }
751
+ const state = readGsdState(cwd, featureCode);
752
+ return typeof state?.pid === 'number' ? state.pid : null;
753
+ }
754
+
755
+ // Atomic live-run claim, taken BEFORE the first stratum side effect. mkdirSync
756
+ // is atomic on POSIX: the loser gets EEXIST. On EEXIST we take over a STALE lock
757
+ // — owner pid dead, OR (no owner record AND lock-dir mtime older than the stale
758
+ // window, which covers the sub-ms gap before owner.json lands). A live owner
759
+ // refuses. Writes run.lock/owner.json {pid,startedAt} immediately after winning.
760
+ export function claimRunLock(cwd, featureCode) {
761
+ const dir = gsdDir(cwd, featureCode);
762
+ mkdirSync(dir, { recursive: true });
763
+ const lock = runLockDir(cwd, featureCode);
764
+ const write = () => {
765
+ writeFileSync(
766
+ join(lock, 'owner.json'),
767
+ JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2),
768
+ );
769
+ };
770
+ try {
771
+ mkdirSync(lock);
772
+ write();
773
+ return;
774
+ } catch (err) {
775
+ if (err.code !== 'EEXIST') throw err;
776
+ }
777
+ // EEXIST — decide stale vs live.
778
+ const ownerPid = runLockOwnerPid(cwd, featureCode);
779
+ let stale = false;
780
+ if (typeof ownerPid === 'number') {
781
+ stale = !pidAlive(ownerPid);
782
+ } else {
783
+ // No owner record yet: fall back to lock-dir age.
784
+ try {
785
+ stale = Date.now() - statSync(lock).mtimeMs > RUN_LOCK_STALE_MS;
786
+ } catch { stale = true; }
787
+ }
788
+ if (!stale) {
789
+ throw new Error(
790
+ `runGsd: another gsd run owns ${featureCode} (.compose/gsd/${featureCode}/run.lock, ` +
791
+ `pid ${ownerPid ?? 'unknown'} alive). Refusing to start a concurrent run.`,
792
+ );
793
+ }
794
+ // Atomic stale takeover (rename-aside). If we lose the takeover race, another
795
+ // run now legitimately owns the feature — refuse.
796
+ if (!takeoverStaleLock(lock)) {
797
+ throw new Error(
798
+ `runGsd: another gsd run claimed ${featureCode} during stale-lock takeover. ` +
799
+ `Refusing to start a concurrent run.`,
800
+ );
801
+ }
802
+ write();
803
+ }
804
+
805
+ export function releaseRunLock(cwd, featureCode) {
806
+ rmSync(runLockDir(cwd, featureCode), { recursive: true, force: true });
807
+ }
808
+
809
+ // Merge a patch into ctx.runState and atomically flush state.json. ctx.runState
810
+ // is the single in-memory source of truth; every flush restamps heartbeatAt.
811
+ function flushState(ctx, patch) {
812
+ ctx.runState = { ...(ctx.runState ?? {}), ...patch };
813
+ writeGsdState(ctx.cwd, ctx.featureCode, ctx.runState);
814
+ }
815
+
816
+ // COMP-GSD-7-EVENTLOG: emit a `task_completed` event for each task that has
817
+ // completed since the last emit. Dedupes via ctx.emittedCompletions (seeded from
818
+ // the run's initial completed snapshot, so a resume never re-fires prior-session
819
+ // completions). Called at the execute-merge checkpoint and before each halt
820
+ // (stuck/budget) — the halt paths return early, before the merge checkpoint.
821
+ function emitCompletionDeltas(ctx, completedIds) {
822
+ if (!ctx?.emittedCompletions) return;
823
+ const ids = completedIds ?? collectCompletedTaskIds(ctx.cwd, ctx.featureCode);
824
+ for (const id of ids) {
825
+ if (!id || ctx.emittedCompletions.has(id)) continue;
826
+ ctx.emittedCompletions.add(id);
827
+ appendGsdEvent(ctx.cwd, ctx.featureCode, 'task_completed', { taskId: id });
828
+ }
829
+ }
830
+
831
+ // COMP-GSD-7-EVENTLOG: emit a `phase` event the first time a phase is entered.
832
+ // Deduped via ctx.emittedPhases — runState.phase is set to 'execute' before the
833
+ // execute-merge checkpoint runs, so it can't itself gate the emission.
834
+ function emitPhaseOnce(ctx, phase) {
835
+ if (!ctx?.emittedPhases || ctx.emittedPhases.has(phase)) return;
836
+ ctx.emittedPhases.add(phase);
837
+ appendGsdEvent(ctx.cwd, ctx.featureCode, 'phase', { phase });
838
+ }
839
+
510
840
  /**
511
841
  * Build a GsdStuckDetector from `.compose/compose.json` `gsd.stuck.*`, falling
512
842
  * back to documented defaults (sameFileEdits=3, errorRepeats=3,
@@ -662,17 +992,41 @@ State for resume is in \`pause.json\` (schema: \`contracts/gsd-stuck.json#/defin
662
992
  */
663
993
  export function loadResumeTaskGraph(cwd, featureCode, { claim = true } = {}) {
664
994
  const pausePath = join(gsdDir(cwd, featureCode), 'pause.json');
665
- if (!existsSync(pausePath)) {
666
- throw new Error(
667
- `runGsd: no pause.json to resume for ${featureCode}. ` +
668
- `Nothing to resume — run \`compose gsd ${featureCode}\` to start fresh.`,
669
- );
670
- }
671
995
  let pause;
672
- try {
673
- pause = JSON.parse(readFileSync(pausePath, 'utf-8'));
674
- } catch (err) {
675
- throw new Error(`runGsd: pause.json for ${featureCode} is unreadable: ${err.message}`);
996
+ if (existsSync(pausePath)) {
997
+ try {
998
+ pause = JSON.parse(readFileSync(pausePath, 'utf-8'));
999
+ } catch (err) {
1000
+ throw new Error(`runGsd: pause.json for ${featureCode} is unreadable: ${err.message}`);
1001
+ }
1002
+ } else {
1003
+ // COMP-GSD-6 crash bridge: a hard crash never reaches the stuck/budget halt
1004
+ // paths that write pause.json. If state.json shows a running run with a DEAD
1005
+ // pid and a populated task graph (resumeReady), synthesize a pause-shaped
1006
+ // object so the unfinished subset can be re-dispatched through the same
1007
+ // guards/filtering below. An EMPTY graph (crashed pre/at decompose) is NOT
1008
+ // resumable here — it (correctly) falls through to the throw; the supervisor
1009
+ // restarts such runs fresh rather than --resume.
1010
+ const state = readGsdState(cwd, featureCode);
1011
+ if (
1012
+ state && state.status === 'running' && !pidAlive(state.pid) &&
1013
+ Array.isArray(state.decomposedTasks) && state.decomposedTasks.length > 0
1014
+ ) {
1015
+ pause = {
1016
+ flowId: state.flowId ?? null,
1017
+ stepId: state.lastStepId ?? 'execute',
1018
+ decomposedTasks: state.decomposedTasks,
1019
+ completedTaskIds: state.completedTaskIds ?? [],
1020
+ pid: state.pid,
1021
+ mode: 'gsd',
1022
+ ts: state.heartbeatAt ?? new Date().toISOString(),
1023
+ };
1024
+ } else {
1025
+ throw new Error(
1026
+ `runGsd: no pause.json to resume for ${featureCode}. ` +
1027
+ `Nothing to resume — run \`compose gsd ${featureCode}\` to start fresh.`,
1028
+ );
1029
+ }
676
1030
  }
677
1031
 
678
1032
  // Mode guard: refuse to resume a non-gsd pause file.
@@ -687,7 +1041,7 @@ export function loadResumeTaskGraph(cwd, featureCode, { claim = true } = {}) {
687
1041
  // run still owns this feature (mirrors `compose fix --resume`). We do not
688
1042
  // make a self-pid exception: if a live process holds the pause, resuming is
689
1043
  // unsafe regardless of whether that pid happens to match ours.
690
- if (typeof pause.pid === 'number' && isPidAlive(pause.pid)) {
1044
+ if (typeof pause.pid === 'number' && pidAlive(pause.pid)) {
691
1045
  throw new Error(
692
1046
  `runGsd: cannot --resume: pid ${pause.pid} still owns this gsd run (process is live). ` +
693
1047
  `Wait for it to exit (or remove a stale pause.json) before resuming.`,
@@ -724,26 +1078,65 @@ export function loadResumeTaskGraph(cwd, featureCode, { claim = true } = {}) {
724
1078
  /**
725
1079
  * Atomic ownership claim (COMP-GSD-5 Codex review, HIGH). `mkdirSync` is an
726
1080
  * atomically exclusive create, so two concurrent --resume invocations cannot
727
- * both claim — the loser gets EEXIST and refuses. We deliberately do NOT
728
- * auto-take-over a pre-existing claim: stale-claim recovery (a crashed resume's
729
- * leftover) has an inherent TOCTOU race and is GSD-6's (crash-recovery) job,
730
- * built on this same pause-state. A claim left by a crashed resume is cleared
731
- * manually (message below) until GSD-6 lands.
1081
+ * both claim — the loser gets EEXIST and refuses.
1082
+ *
1083
+ * COMP-GSD-6: a STALE claim left by a crashed --resume is now auto-recovered.
1084
+ * The HOLDER of pause.lock writes its own pid into pause.lock/owner.json (NOT
1085
+ * pause.json.pid, which is the original crashed run's pid — always dead at
1086
+ * resume time and so useless for liveness). Takeover when that holder pid is
1087
+ * dead, OR no owner record exists and the lock-dir mtime is older than the
1088
+ * stale window. TOCTOU-safe: remove + re-attempt the atomic mkdir; a concurrent
1089
+ * winner still wins.
732
1090
  */
733
1091
  export function claimResumeLock(cwd, featureCode) {
734
1092
  const claimPath = join(gsdDir(cwd, featureCode), 'pause.lock');
1093
+ const writeOwner = () => {
1094
+ try {
1095
+ writeFileSync(
1096
+ join(claimPath, 'owner.json'),
1097
+ JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }, null, 2),
1098
+ );
1099
+ } catch { /* best-effort; mtime fallback still protects takeover */ }
1100
+ };
735
1101
  try {
736
1102
  mkdirSync(claimPath);
1103
+ writeOwner();
1104
+ return;
737
1105
  } catch (err) {
738
- if (err.code === 'EEXIST') {
739
- throw new Error(
740
- `runGsd: a resume claim already exists for ${featureCode} ` +
741
- `(.compose/gsd/${featureCode}/pause.lock). Another --resume may be in progress; ` +
742
- `if none is, remove that directory to clear a stale claim, then retry.`,
743
- );
744
- }
745
- throw err;
1106
+ if (err.code !== 'EEXIST') throw err;
1107
+ }
1108
+ // EEXIST decide stale vs live by the lock HOLDER's own owner record.
1109
+ let holderPid = null;
1110
+ const ownerPath = join(claimPath, 'owner.json');
1111
+ if (existsSync(ownerPath)) {
1112
+ try {
1113
+ const o = JSON.parse(readFileSync(ownerPath, 'utf-8'));
1114
+ if (typeof o.pid === 'number') holderPid = o.pid;
1115
+ } catch { /* fall through to mtime */ }
1116
+ }
1117
+ let stale = false;
1118
+ if (typeof holderPid === 'number') {
1119
+ stale = !pidAlive(holderPid);
1120
+ } else {
1121
+ try {
1122
+ stale = Date.now() - statSync(claimPath).mtimeMs > RUN_LOCK_STALE_MS;
1123
+ } catch { stale = true; }
1124
+ }
1125
+ if (!stale) {
1126
+ throw new Error(
1127
+ `runGsd: a resume claim already exists for ${featureCode} ` +
1128
+ `(.compose/gsd/${featureCode}/pause.lock, pid ${holderPid ?? 'unknown'} alive). ` +
1129
+ `Another --resume may be in progress; if none is, remove that directory to clear a stale claim.`,
1130
+ );
746
1131
  }
1132
+ // Atomic stale takeover (rename-aside) — a concurrent reclaimer can't delete
1133
+ // our fresh lock. If we lose the race, refuse.
1134
+ if (!takeoverStaleLock(claimPath)) {
1135
+ throw new Error(
1136
+ `runGsd: another --resume claimed ${featureCode} during stale-claim takeover; retry.`,
1137
+ );
1138
+ }
1139
+ writeOwner();
747
1140
  }
748
1141
 
749
1142
  /**
@@ -791,6 +1184,25 @@ function writeBudgetArtifacts(ctx, response, budgetState) {
791
1184
  writeFileSync(join(dir, 'pause.json'), JSON.stringify(pause, null, 2) + '\n');
792
1185
  }
793
1186
 
1187
+ /**
1188
+ * COMP-GSD-7: on a clean complete, snapshot the run's final budget actuals-vs-caps
1189
+ * to budget-final.json so the milestone report has them retroactively (a clean
1190
+ * complete writes no budget.json — only halts do). Distinct filename from the
1191
+ * halt artifact budget.json (which buildGsdQuery's precedence reads). Atomic write.
1192
+ */
1193
+ export function writeBudgetFinalSnapshot(ctx, budgetState) {
1194
+ const { cwd, featureCode } = ctx;
1195
+ const dir = gsdDir(cwd, featureCode);
1196
+ mkdirSync(dir, { recursive: true });
1197
+ const decomposedTasks = (ctx.runState?.decomposedTasks ?? []).map((t) => ({ ...t }));
1198
+ const completedTaskIds = collectCompletedTaskIds(cwd, featureCode);
1199
+ const { json } = composeBudgetDiagnostic(budgetState, { feature: featureCode, decomposedTasks, completedTaskIds });
1200
+ const target = join(dir, 'budget-final.json');
1201
+ const tmp = `${target}.tmp`;
1202
+ writeFileSync(tmp, JSON.stringify(json, null, 2) + '\n');
1203
+ renameSync(tmp, target);
1204
+ }
1205
+
794
1206
  /**
795
1207
  * COMP-GSD-4: append a run's consumed usage to the cumulative ledger. Sourced
796
1208
  * from the stratum budget_state.consumed ({tokens,dispatches,wall_s,dollars}).
@@ -836,17 +1248,6 @@ function writeCumulativeRefusal(cwd, featureCode, chk, limits) {
836
1248
  writeFileSync(join(dir, 'budget.md'), md);
837
1249
  }
838
1250
 
839
- function isPidAlive(pid) {
840
- try {
841
- // signal 0 probes existence without sending a signal.
842
- process.kill(pid, 0);
843
- return true;
844
- } catch (err) {
845
- // ESRCH = no such process; EPERM = exists but not ours (still alive).
846
- return err.code === 'EPERM';
847
- }
848
- }
849
-
850
1251
  function clearPauseFile(cwd, featureCode) {
851
1252
  const dir = gsdDir(cwd, featureCode);
852
1253
  try { rmSync(join(dir, 'pause.json'), { force: true }); } catch { /* best-effort */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.8-beta",
3
+ "version": "0.2.9-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -1 +0,0 @@
1
- import{ai as o,aj as n}from"./App-D3ehVPvi.js";const t=(a,r)=>o.lang.round(n.parse(a)[r]);export{t as c};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-727SXJPM-fAH0QO9v.js";import{_ as i}from"./App-D3ehVPvi.js";import"./chunk-FMBD7UC4-BnboGO5t.js";import"./chunk-ND2GUHAM-Di9tYXme.js";import"./chunk-55IACEB6-Dsy9RYvI.js";import"./chunk-2J33WTMH-CKk_RN3A.js";import"./mobile-CG5tLa2S.js";import"./index-ClX6LVAf.js";import"./graph-Cs_vqCR0.js";var b={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{b as diagram};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-727SXJPM-fAH0QO9v.js";import{_ as i}from"./App-D3ehVPvi.js";import"./chunk-FMBD7UC4-BnboGO5t.js";import"./chunk-ND2GUHAM-Di9tYXme.js";import"./chunk-55IACEB6-Dsy9RYvI.js";import"./chunk-2J33WTMH-CKk_RN3A.js";import"./mobile-CG5tLa2S.js";import"./index-ClX6LVAf.js";import"./graph-Cs_vqCR0.js";var b={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{b as diagram};
@@ -1 +0,0 @@
1
- import{s as r,b as e,a,S as s}from"./chunk-AQP2D5EJ-DyZYerFP.js";import{_ as i}from"./App-D3ehVPvi.js";import"./chunk-55IACEB6-Dsy9RYvI.js";import"./chunk-2J33WTMH-CKk_RN3A.js";import"./mobile-CG5tLa2S.js";import"./index-ClX6LVAf.js";import"./graph-Cs_vqCR0.js";var n={parser:a,get db(){return new s(2)},renderer:e,styles:r,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{n as diagram};