gsd-pi 2.65.0-dev.16e10d7 → 2.65.0-dev.6cc5110

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 (71) hide show
  1. package/dist/resources/extensions/gsd/auto/session.js +4 -0
  2. package/dist/resources/extensions/gsd/auto-dispatch.js +5 -1
  3. package/dist/resources/extensions/gsd/auto-recovery.js +28 -14
  4. package/dist/resources/extensions/gsd/auto-start.js +7 -10
  5. package/dist/resources/extensions/gsd/auto.js +19 -13
  6. package/dist/resources/extensions/gsd/db-writer.js +13 -3
  7. package/dist/resources/extensions/gsd/json-persistence.js +5 -2
  8. package/dist/resources/extensions/gsd/state.js +12 -10
  9. package/dist/resources/extensions/gsd/tools/complete-milestone.js +15 -3
  10. package/dist/resources/extensions/gsd/tools/complete-slice.js +15 -3
  11. package/dist/resources/extensions/gsd/tools/complete-task.js +15 -3
  12. package/dist/resources/extensions/gsd/triage-resolution.js +8 -7
  13. package/dist/resources/extensions/gsd/undo.js +3 -2
  14. package/dist/resources/extensions/gsd/workflow-logger.js +1 -1
  15. package/dist/resources/extensions/gsd/workflow-reconcile.js +99 -6
  16. package/dist/web/standalone/.next/BUILD_ID +1 -1
  17. package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
  18. package/dist/web/standalone/.next/build-manifest.json +2 -2
  19. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  20. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  21. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.html +1 -1
  37. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
  44. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  45. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  46. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  47. package/package.json +1 -1
  48. package/src/resources/extensions/gsd/auto/session.ts +4 -0
  49. package/src/resources/extensions/gsd/auto-dispatch.ts +5 -1
  50. package/src/resources/extensions/gsd/auto-recovery.ts +19 -15
  51. package/src/resources/extensions/gsd/auto-start.ts +7 -10
  52. package/src/resources/extensions/gsd/auto.ts +17 -7
  53. package/src/resources/extensions/gsd/db-writer.ts +11 -3
  54. package/src/resources/extensions/gsd/json-persistence.ts +6 -3
  55. package/src/resources/extensions/gsd/state.ts +11 -9
  56. package/src/resources/extensions/gsd/tests/integration/auto-recovery.test.ts +6 -6
  57. package/src/resources/extensions/gsd/tests/wave1-critical-regressions.test.ts +49 -0
  58. package/src/resources/extensions/gsd/tests/wave2-events-regressions.test.ts +48 -0
  59. package/src/resources/extensions/gsd/tests/wave3-session-regressions.test.ts +47 -0
  60. package/src/resources/extensions/gsd/tests/wave4-write-safety-regressions.test.ts +70 -0
  61. package/src/resources/extensions/gsd/tests/workflow-logger-audit.test.ts +6 -3
  62. package/src/resources/extensions/gsd/tools/complete-milestone.ts +13 -3
  63. package/src/resources/extensions/gsd/tools/complete-slice.ts +13 -3
  64. package/src/resources/extensions/gsd/tools/complete-task.ts +13 -3
  65. package/src/resources/extensions/gsd/triage-resolution.ts +8 -7
  66. package/src/resources/extensions/gsd/undo.ts +3 -2
  67. package/src/resources/extensions/gsd/workflow-events.ts +1 -1
  68. package/src/resources/extensions/gsd/workflow-logger.ts +1 -1
  69. package/src/resources/extensions/gsd/workflow-reconcile.ts +107 -5
  70. /package/dist/web/standalone/.next/static/{Z3TgDP0c7kG9j8CVQVGcl → iueakR5x5bQbax2sGz8Yr}/_buildManifest.js +0 -0
  71. /package/dist/web/standalone/.next/static/{Z3TgDP0c7kG9j8CVQVGcl → iueakR5x5bQbax2sGz8Yr}/_ssgManifest.js +0 -0
@@ -77,6 +77,9 @@ export class AutoSession {
77
77
  milestoneMergedInPhases = false;
78
78
  // ── Dispatch circuit breakers ──────────────────────────────────────
79
79
  rewriteAttemptCount = 0;
80
+ /** Tracks consecutive bootstrap attempts that found phase === "complete".
81
+ * Moved from module-level to per-session so s.reset() clears it (#1348). */
82
+ consecutiveCompleteBootstraps = 0;
80
83
  // ── Metrics ──────────────────────────────────────────────────────────────
81
84
  autoStartTime = 0;
82
85
  lastPromptCharCount;
@@ -159,6 +162,7 @@ export class AutoSession {
159
162
  this.pendingQuickTasks = [];
160
163
  this.sidecarQueue = [];
161
164
  this.rewriteAttemptCount = 0;
165
+ this.consecutiveCompleteBootstraps = 0;
162
166
  this.lastToolInvocationError = null;
163
167
  this.isolationDegraded = false;
164
168
  this.milestoneMergedInPhases = false;
@@ -611,13 +611,17 @@ export const DISPATCH_RULES = [
611
611
  // Safety guard (#1703): verify the milestone produced implementation
612
612
  // artifacts (non-.gsd/ files). A milestone with only plan files and
613
613
  // zero implementation code should not be marked complete.
614
- if (!hasImplementationArtifacts(basePath)) {
614
+ const artifactCheck = hasImplementationArtifacts(basePath);
615
+ if (artifactCheck === "absent") {
615
616
  return {
616
617
  action: "stop",
617
618
  reason: `Cannot complete milestone ${mid}: no implementation files found outside .gsd/. The milestone has only plan files — actual code changes are required.`,
618
619
  level: "error",
619
620
  };
620
621
  }
622
+ if (artifactCheck === "unknown") {
623
+ logWarning("dispatch", `Implementation artifact check inconclusive for ${mid} — proceeding (git context unavailable)`);
624
+ }
621
625
  // Verification class compliance: if operational verification was planned,
622
626
  // ensure the validation output documents it before allowing completion.
623
627
  try {
@@ -7,6 +7,7 @@
7
7
  * globals or AutoContext dependency.
8
8
  */
9
9
  import { parseUnitId } from "./unit-id.js";
10
+ import { appendEvent } from "./workflow-events.js";
10
11
  import { clearParseCache } from "./files.js";
11
12
  import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js";
12
13
  import { isDbAvailable, getTask, getSlice, getSliceTasks, updateTaskStatus, updateSliceStatus } from "./gsd-db.js";
@@ -27,13 +28,12 @@ export { resolveExpectedArtifactPath, diagnoseExpectedArtifact };
27
28
  * in the git history. Uses `git log --name-only` to inspect all commits on the
28
29
  * current branch that touch files outside `.gsd/`.
29
30
  *
30
- * Returns true if at least one non-`.gsd/` file was committed, false otherwise.
31
- * Non-fatal: returns true on git errors to avoid blocking the pipeline when
32
- * running outside a git repo (e.g., tests).
31
+ * Returns "present" if implementation files found, "absent" if only .gsd/ files,
32
+ * "unknown" if git is unavailable or check failed (callers decide how to handle).
33
33
  */
34
34
  export function hasImplementationArtifacts(basePath) {
35
35
  try {
36
- // Verify we're in a git repo — fail open if not
36
+ // Verify we're in a git repo
37
37
  try {
38
38
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
39
39
  cwd: basePath,
@@ -43,7 +43,7 @@ export function hasImplementationArtifacts(basePath) {
43
43
  }
44
44
  catch (e) {
45
45
  logWarning("recovery", `git rev-parse check failed: ${e.message}`);
46
- return true;
46
+ return "unknown";
47
47
  }
48
48
  // Strategy: check `git diff --name-only` against the merge-base with the
49
49
  // main branch. This captures ALL files changed during the milestone's
@@ -51,20 +51,20 @@ export function hasImplementationArtifacts(basePath) {
51
51
  // back to checking the last N commits.
52
52
  const mainBranch = detectMainBranch(basePath);
53
53
  const changedFiles = getChangedFilesSinceBranch(basePath, mainBranch);
54
- // No files changed at all — fail open (could be detached HEAD, single-
54
+ // No files changed at all — unknown (could be detached HEAD, single-
55
55
  // commit repo, or other edge case where git diff returns nothing).
56
56
  if (changedFiles.length === 0)
57
- return true;
57
+ return "unknown";
58
58
  // Filter out .gsd/ files — only implementation files count.
59
59
  // If every changed file is under .gsd/, the milestone produced no
60
60
  // implementation code (#1703).
61
61
  const implFiles = changedFiles.filter(f => !f.startsWith(".gsd/") && !f.startsWith(".gsd\\"));
62
- return implFiles.length > 0;
62
+ return implFiles.length > 0 ? "present" : "absent";
63
63
  }
64
64
  catch (e) {
65
- // Non-fatal — if git operations fail, don't block the pipeline
65
+ // Non-fatal — if git operations fail, return unknown so callers can decide
66
66
  logWarning("recovery", `implementation artifact check failed: ${e.message}`);
67
- return true;
67
+ return "unknown";
68
68
  }
69
69
  }
70
70
  /**
@@ -354,7 +354,7 @@ export function verifyExpectedArtifact(unitType, unitId, base) {
354
354
  // A milestone with only .gsd/ plan files and zero implementation code is
355
355
  // not genuinely complete — the LLM wrote plan files but skipped actual work.
356
356
  if (unitType === "complete-milestone") {
357
- if (!hasImplementationArtifacts(base))
357
+ if (hasImplementationArtifacts(base) === "absent")
358
358
  return false;
359
359
  }
360
360
  return true;
@@ -386,21 +386,35 @@ export function writeBlockerPlaceholder(unitType, unitId, base, reason) {
386
386
  // re-derives the same unit indefinitely (#2531, #2653).
387
387
  if (isDbAvailable()) {
388
388
  const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
389
+ const ts = new Date().toISOString();
389
390
  if (unitType === "execute-task" && mid && sid && tid) {
390
391
  try {
391
- updateTaskStatus(mid, sid, tid, "complete", new Date().toISOString());
392
+ updateTaskStatus(mid, sid, tid, "complete", ts);
392
393
  }
393
394
  catch (e) {
394
395
  logWarning("recovery", `updateTaskStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`);
395
396
  }
397
+ // Append event so worktree reconciliation can replay this recovery completion
398
+ try {
399
+ appendEvent(base, { cmd: "complete-task", params: { milestoneId: mid, sliceId: sid, taskId: tid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" });
400
+ }
401
+ catch (e) {
402
+ logWarning("recovery", `appendEvent failed for task recovery: ${e instanceof Error ? e.message : String(e)}`);
403
+ }
396
404
  }
397
405
  if (unitType === "complete-slice" && mid && sid) {
398
406
  try {
399
- updateSliceStatus(mid, sid, "complete", new Date().toISOString());
407
+ updateSliceStatus(mid, sid, "complete", ts);
400
408
  }
401
409
  catch (e) {
402
410
  logWarning("recovery", `updateSliceStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`);
403
411
  }
412
+ try {
413
+ appendEvent(base, { cmd: "complete-slice", params: { milestoneId: mid, sliceId: sid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" });
414
+ }
415
+ catch (e) {
416
+ logWarning("recovery", `appendEvent failed for slice recovery: ${e instanceof Error ? e.message : String(e)}`);
417
+ }
404
418
  }
405
419
  }
406
420
  return diagnoseExpectedArtifact(unitType, unitId, base);
@@ -455,7 +469,7 @@ export function reconcileMergeState(basePath, ctx) {
455
469
  if (conflictedFiles.length === 0) {
456
470
  // All conflicts resolved — finalize the merge/squash commit
457
471
  try {
458
- const commitSha = nativeCommit(basePath, ""); // --no-edit equivalent: use empty message placeholder
472
+ const commitSha = nativeCommit(basePath, "chore(gsd): reconcile merge state");
459
473
  if (commitSha) {
460
474
  const mode = hasMergeHead ? "merge" : "squash commit";
461
475
  ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
@@ -48,11 +48,8 @@ import { resolveDefaultSessionModel } from "./preferences-models.js";
48
48
  * Returns false if the bootstrap aborted (e.g., guided flow returned,
49
49
  * concurrent session detected). Returns true when ready to dispatch.
50
50
  */
51
- /** Guard: tracks consecutive bootstrap attempts that found phase === "complete".
52
- * Prevents the recursive dialog loop described in #1348 where
53
- * bootstrapAutoSession → showSmartEntry → checkAutoStartAfterDiscuss → startAuto
54
- * cycles indefinitely when the discuss workflow doesn't produce a milestone. */
55
- let _consecutiveCompleteBootstraps = 0;
51
+ // Guard constant for consecutive bootstrap attempts that found phase === "complete".
52
+ // Counter moved to AutoSession.consecutiveCompleteBootstraps so s.reset() clears it.
56
53
  const MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS = 2;
57
54
  export async function openProjectDbIfPresent(basePath) {
58
55
  const gsdDbPath = resolveProjectRootDbPath(basePath);
@@ -263,9 +260,9 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
263
260
  // Guard against recursive dialog loop (#1348):
264
261
  // If we've entered this branch multiple times in quick succession,
265
262
  // the discuss workflow isn't producing a milestone. Break the cycle.
266
- _consecutiveCompleteBootstraps++;
267
- if (_consecutiveCompleteBootstraps > MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS) {
268
- _consecutiveCompleteBootstraps = 0;
263
+ s.consecutiveCompleteBootstraps++;
264
+ if (s.consecutiveCompleteBootstraps > MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS) {
265
+ s.consecutiveCompleteBootstraps = 0;
269
266
  ctx.ui.notify("All milestones are complete and the discussion didn't produce a new one. " +
270
267
  "Run /gsd to start a new milestone manually.", "warning");
271
268
  return releaseLockAndReturn();
@@ -277,7 +274,7 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
277
274
  if (postState.activeMilestone &&
278
275
  postState.phase !== "complete" &&
279
276
  postState.phase !== "pre-planning") {
280
- _consecutiveCompleteBootstraps = 0; // Successfully advanced past "complete"
277
+ s.consecutiveCompleteBootstraps = 0; // Successfully advanced past "complete"
281
278
  state = postState;
282
279
  }
283
280
  else if (postState.activeMilestone &&
@@ -338,7 +335,7 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
338
335
  return releaseLockAndReturn();
339
336
  }
340
337
  // Successfully resolved an active milestone — reset the re-entry guard
341
- _consecutiveCompleteBootstraps = 0;
338
+ s.consecutiveCompleteBootstraps = 0;
342
339
  // ── Initialize session state ──
343
340
  s.active = true;
344
341
  s.stepMode = requestedStepMode;
@@ -845,12 +845,9 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
845
845
  s.stepMode = meta.stepMode ?? requestedStepMode;
846
846
  s.autoStartTime = meta.autoStartTime || Date.now();
847
847
  s.paused = true;
848
- try {
849
- unlinkSync(pausedPath);
850
- }
851
- catch (err) { /* non-fatal */
852
- logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
853
- }
848
+ // Don't delete pause file yet — defer until lock is acquired.
849
+ // If lock fails, the file must survive for retry.
850
+ s.pausedSessionFile = pausedPath;
854
851
  ctx.ui.notify(`Resuming paused custom workflow${meta.activeRunDir ? ` (${meta.activeRunDir})` : ""}.`, "info");
855
852
  }
856
853
  else if (meta.milestoneId) {
@@ -873,13 +870,9 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
873
870
  s.stepMode = meta.stepMode ?? requestedStepMode;
874
871
  s.autoStartTime = meta.autoStartTime || Date.now();
875
872
  s.paused = true;
876
- // Clean up the persisted file — we're consuming it
877
- try {
878
- unlinkSync(pausedPath);
879
- }
880
- catch (err) { /* non-fatal */
881
- logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
882
- }
873
+ // Don't delete pause file yet defer until lock is acquired.
874
+ // If lock fails, the file must survive for retry.
875
+ s.pausedSessionFile = pausedPath;
883
876
  ctx.ui.notify(`Resuming paused session for ${meta.milestoneId}${meta.worktreePath ? ` (worktree)` : ""}.`, "info");
884
877
  }
885
878
  }
@@ -893,9 +886,22 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
893
886
  if (s.paused) {
894
887
  const resumeLock = acquireSessionLock(base);
895
888
  if (!resumeLock.acquired) {
889
+ // Reset paused state so isAutoPaused() doesn't stick true after lock failure.
890
+ // Pause file is preserved on disk for retry — not deleted.
891
+ s.paused = false;
896
892
  ctx.ui.notify(`Cannot resume: ${resumeLock.reason}`, "error");
897
893
  return;
898
894
  }
895
+ // Lock acquired — now safe to delete the pause file
896
+ if (s.pausedSessionFile) {
897
+ try {
898
+ unlinkSync(s.pausedSessionFile);
899
+ }
900
+ catch (err) {
901
+ logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
902
+ }
903
+ s.pausedSessionFile = null;
904
+ }
899
905
  s.paused = false;
900
906
  s.active = true;
901
907
  s.verbose = verboseMode;
@@ -300,8 +300,13 @@ export async function saveRequirementToDb(fields, basePath) {
300
300
  }
301
301
  catch (diskErr) {
302
302
  logError('manifest', 'disk write failed, rolling back DB row', { fn: 'saveRequirementToDb', error: String(diskErr.message) });
303
- const rollbackAdapter = db._getAdapter();
304
- rollbackAdapter?.prepare('DELETE FROM requirements WHERE id = :id').run({ ':id': id });
303
+ try {
304
+ const rollbackAdapter = db._getAdapter();
305
+ rollbackAdapter?.prepare('DELETE FROM requirements WHERE id = :id').run({ ':id': id });
306
+ }
307
+ catch (rollbackErr) {
308
+ logError('manifest', 'SPLIT BRAIN: disk write failed AND DB rollback failed — DB has orphaned row', { fn: 'saveRequirementToDb', id, error: String(rollbackErr.message) });
309
+ }
305
310
  throw diskErr;
306
311
  }
307
312
  invalidateStateCache();
@@ -399,7 +404,12 @@ export async function saveDecisionToDb(fields, basePath) {
399
404
  }
400
405
  catch (diskErr) {
401
406
  logError('manifest', 'disk write failed, rolling back DB row', { fn: 'saveDecisionToDb', error: String(diskErr.message) });
402
- adapter?.prepare('DELETE FROM decisions WHERE id = :id').run({ ':id': id });
407
+ try {
408
+ adapter?.prepare('DELETE FROM decisions WHERE id = :id').run({ ':id': id });
409
+ }
410
+ catch (rollbackErr) {
411
+ logError('manifest', 'SPLIT BRAIN: disk write failed AND DB rollback failed — DB has orphaned row', { fn: 'saveDecisionToDb', id, error: String(rollbackErr.message) });
412
+ }
403
413
  throw diskErr;
404
414
  }
405
415
  // #2661: When a decision defers a slice, update the slice status in the DB
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
+ import { randomBytes } from "node:crypto";
3
4
  /**
4
5
  * Load a JSON file with validation, returning a default on failure.
5
6
  * Handles missing files, corrupt JSON, and schema mismatches uniformly.
@@ -45,9 +46,11 @@ export function loadJsonFileOrNull(filePath, validate) {
45
46
  export function saveJsonFile(filePath, data) {
46
47
  try {
47
48
  mkdirSync(dirname(filePath), { recursive: true });
48
- const tmp = filePath + ".tmp";
49
+ // Use randomized tmp suffix to prevent concurrent-write data loss
50
+ const tmp = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
49
51
  writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
50
52
  renameSync(tmp, filePath);
53
+ // No cleanup needed — renameSync atomically removes tmp on success
51
54
  }
52
55
  catch {
53
56
  // Non-fatal — don't let persistence failures break operation
@@ -60,7 +63,7 @@ export function saveJsonFile(filePath, data) {
60
63
  export function writeJsonFileAtomic(filePath, data) {
61
64
  try {
62
65
  mkdirSync(dirname(filePath), { recursive: true });
63
- const tmp = filePath + ".tmp";
66
+ const tmp = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
64
67
  writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
65
68
  renameSync(tmp, filePath);
66
69
  }
@@ -6,7 +6,7 @@ import { parseSummary, loadFile, parseRequirementCounts, parseContextDependsOn,
6
6
  import { resolveMilestoneFile, resolveSlicePath, resolveSliceFile, resolveTaskFile, resolveTasksDir, resolveGsdRootFile, gsdRoot, } from './paths.js';
7
7
  import { findMilestoneIds } from './milestone-ids.js';
8
8
  import { loadQueueOrder, sortByQueueOrder } from './queue-order.js';
9
- import { isDeferredStatus } from './status-guards.js';
9
+ import { isClosedStatus, isDeferredStatus } from './status-guards.js';
10
10
  import { nativeBatchParseGsdFiles } from './native-parser-bridge.js';
11
11
  import { join, resolve } from 'path';
12
12
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
@@ -119,7 +119,7 @@ export async function getActiveMilestoneId(basePath) {
119
119
  const byId = new Map(allMilestones.map(m => [m.id, m]));
120
120
  for (const id of sortedIds) {
121
121
  const m = byId.get(id);
122
- if (m.status === "complete" || m.status === "done" || m.status === "parked")
122
+ if (isClosedStatus(m.status) || m.status === "parked")
123
123
  continue;
124
124
  return m.id;
125
125
  }
@@ -362,13 +362,10 @@ export async function deriveStateFromDb(basePath) {
362
362
  completeMilestoneIds.add(m.id);
363
363
  continue;
364
364
  }
365
- // Check roadmap: all slices done means milestone is complete
366
- const slices = getMilestoneSlices(m.id);
367
- if (slices.length > 0 && slices.every(s => isStatusDone(s.status))) {
368
- // All slices done but no summary — still counts as complete for dep resolution
369
- // if a summary file exists
370
- // Note: without summary file, the milestone is in validating/completing state, not complete
371
- }
365
+ // Milestones with all slices done but no SUMMARY file are in
366
+ // validating/completing state — intentionally NOT added to
367
+ // completeMilestoneIds. The SUMMARY file (checked above) is the
368
+ // terminal artifact that proves completion per #864.
372
369
  }
373
370
  // Phase 2: Build registry and find active milestone
374
371
  const registry = [];
@@ -840,7 +837,12 @@ export async function deriveStateFromDb(basePath) {
840
837
  // ── REPLAN-TRIGGER detection ─────────────────────────────────────────
841
838
  if (!blockerTaskId) {
842
839
  const sliceRow = getSlice(activeMilestone.id, activeSlice.id);
843
- if (sliceRow?.replan_triggered_at) {
840
+ // Check DB column first, fall back to disk trigger file when DB write
841
+ // was best-effort and failed (triage-resolution.ts dual-write gap).
842
+ const dbTriggered = !!sliceRow?.replan_triggered_at;
843
+ const diskTriggered = !dbTriggered &&
844
+ !!resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER");
845
+ if (dbTriggered || diskTriggered) {
844
846
  // Loop protection: if replan_history has entries, replan was already done
845
847
  const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id);
846
848
  if (replanHistory.length === 0) {
@@ -15,7 +15,7 @@ import { invalidateStateCache } from "../state.js";
15
15
  import { renderAllProjections, stripIdPrefix } from "../workflow-projections.js";
16
16
  import { writeManifest } from "../workflow-manifest.js";
17
17
  import { appendEvent } from "../workflow-events.js";
18
- import { logWarning } from "../workflow-logger.js";
18
+ import { logWarning, logError } from "../workflow-logger.js";
19
19
  function renderMilestoneSummaryMarkdown(params) {
20
20
  const now = new Date().toISOString();
21
21
  const displayTitle = stripIdPrefix(params.title, params.milestoneId);
@@ -156,9 +156,21 @@ export async function handleCompleteMilestone(params, basePath) {
156
156
  clearPathCache();
157
157
  clearParseCache();
158
158
  // ── Post-mutation hook: projections, manifest, event log ───────────────
159
+ // Separate try/catch per step so a projection failure doesn't prevent
160
+ // the event log entry (critical for worktree reconciliation).
159
161
  try {
160
162
  await renderAllProjections(basePath, params.milestoneId);
163
+ }
164
+ catch (projErr) {
165
+ logWarning("tool", `complete-milestone projection warning: ${projErr.message}`);
166
+ }
167
+ try {
161
168
  writeManifest(basePath);
169
+ }
170
+ catch (mfErr) {
171
+ logWarning("tool", `complete-milestone manifest warning: ${mfErr.message}`);
172
+ }
173
+ try {
162
174
  appendEvent(basePath, {
163
175
  cmd: "complete-milestone",
164
176
  params: { milestoneId: params.milestoneId },
@@ -168,8 +180,8 @@ export async function handleCompleteMilestone(params, basePath) {
168
180
  trigger_reason: params.triggerReason,
169
181
  });
170
182
  }
171
- catch (hookErr) {
172
- logWarning("tool", `complete-milestone post-mutation hook warning: ${hookErr.message}`);
183
+ catch (eventErr) {
184
+ logError("tool", `complete-milestone event log FAILED — completion invisible to reconciliation`, { error: eventErr.message });
173
185
  }
174
186
  return {
175
187
  milestoneId: params.milestoneId,
@@ -18,7 +18,7 @@ import { renderRoadmapCheckboxes } from "../markdown-renderer.js";
18
18
  import { renderAllProjections } from "../workflow-projections.js";
19
19
  import { writeManifest } from "../workflow-manifest.js";
20
20
  import { appendEvent } from "../workflow-events.js";
21
- import { logWarning } from "../workflow-logger.js";
21
+ import { logWarning, logError } from "../workflow-logger.js";
22
22
  /**
23
23
  * Render slice summary markdown matching the template format.
24
24
  * YAML frontmatter uses snake_case keys for parseSummary() compatibility.
@@ -276,9 +276,21 @@ export async function handleCompleteSlice(params, basePath) {
276
276
  clearPathCache();
277
277
  clearParseCache();
278
278
  // ── Post-mutation hook: projections, manifest, event log ───────────────
279
+ // Separate try/catch per step so a projection failure doesn't prevent
280
+ // the event log entry (critical for worktree reconciliation).
279
281
  try {
280
282
  await renderAllProjections(basePath, params.milestoneId);
283
+ }
284
+ catch (projErr) {
285
+ logWarning("tool", `complete-slice projection warning for ${params.milestoneId}/${params.sliceId}: ${projErr.message}`);
286
+ }
287
+ try {
281
288
  writeManifest(basePath);
289
+ }
290
+ catch (mfErr) {
291
+ logWarning("tool", `complete-slice manifest warning: ${mfErr.message}`);
292
+ }
293
+ try {
282
294
  appendEvent(basePath, {
283
295
  cmd: "complete-slice",
284
296
  params: { milestoneId: params.milestoneId, sliceId: params.sliceId },
@@ -288,8 +300,8 @@ export async function handleCompleteSlice(params, basePath) {
288
300
  trigger_reason: params.triggerReason,
289
301
  });
290
302
  }
291
- catch (hookErr) {
292
- logWarning("tool", `complete-slice post-mutation hook failed for ${params.milestoneId}/${params.sliceId}`, { error: hookErr.message });
303
+ catch (eventErr) {
304
+ logError("tool", `complete-slice event log FAILED completion invisible to reconciliation`, { error: eventErr.message });
293
305
  }
294
306
  return {
295
307
  sliceId: params.sliceId,
@@ -18,7 +18,7 @@ import { renderPlanCheckboxes } from "../markdown-renderer.js";
18
18
  import { renderAllProjections, renderSummaryContent } from "../workflow-projections.js";
19
19
  import { writeManifest } from "../workflow-manifest.js";
20
20
  import { appendEvent } from "../workflow-events.js";
21
- import { logWarning } from "../workflow-logger.js";
21
+ import { logWarning, logError } from "../workflow-logger.js";
22
22
  /**
23
23
  * Normalize a list parameter that may arrive as a string (newline-delimited
24
24
  * bullet list from the LLM) into a string array (#3361).
@@ -194,9 +194,21 @@ export async function handleCompleteTask(params, basePath) {
194
194
  clearPathCache();
195
195
  clearParseCache();
196
196
  // ── Post-mutation hook: projections, manifest, event log ───────────────
197
+ // Separate try/catch per step so a projection failure doesn't prevent
198
+ // the event log entry (critical for worktree reconciliation).
197
199
  try {
198
200
  await renderAllProjections(basePath, params.milestoneId);
201
+ }
202
+ catch (projErr) {
203
+ logWarning("tool", `complete-task projection warning: ${projErr.message}`);
204
+ }
205
+ try {
199
206
  writeManifest(basePath);
207
+ }
208
+ catch (mfErr) {
209
+ logWarning("tool", `complete-task manifest warning: ${mfErr.message}`);
210
+ }
211
+ try {
200
212
  appendEvent(basePath, {
201
213
  cmd: "complete-task",
202
214
  params: { milestoneId: params.milestoneId, sliceId: params.sliceId, taskId: params.taskId },
@@ -206,8 +218,8 @@ export async function handleCompleteTask(params, basePath) {
206
218
  trigger_reason: params.triggerReason,
207
219
  });
208
220
  }
209
- catch (hookErr) {
210
- logWarning("tool", `complete-task post-mutation hook warning: ${hookErr.message}`);
221
+ catch (eventErr) {
222
+ logError("tool", `complete-task event log FAILED — completion invisible to reconciliation`, { error: eventErr.message });
211
223
  }
212
224
  return {
213
225
  taskId: params.taskId,
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * Also provides detectFileOverlap() for surfacing downstream impact on quick tasks.
11
11
  */
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
12
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
13
+ import { atomicWriteSync } from "./atomic-write.js";
13
14
  import { join } from "node:path";
14
15
  import { createRequire } from "node:module";
15
16
  import { gsdRoot, milestonesDir } from "./paths.js";
@@ -46,11 +47,11 @@ export function executeInject(basePath, mid, sid, capture) {
46
47
  const filesSection = content.indexOf("## Files Likely Touched");
47
48
  if (filesSection !== -1) {
48
49
  const updated = content.slice(0, filesSection) + newTask + "\n\n" + content.slice(filesSection);
49
- writeFileSync(planPath, updated, "utf-8");
50
+ atomicWriteSync(planPath, updated, "utf-8");
50
51
  }
51
52
  else {
52
53
  // No Files section — append at end
53
- writeFileSync(planPath, content.trimEnd() + "\n\n" + newTask + "\n", "utf-8");
54
+ atomicWriteSync(planPath, content.trimEnd() + "\n\n" + newTask + "\n", "utf-8");
54
55
  }
55
56
  return newId;
56
57
  }
@@ -78,7 +79,7 @@ export function executeReplan(basePath, mid, sid, capture) {
78
79
  `This file was created by the triage pipeline. The next dispatch cycle`,
79
80
  `will detect it and enter the replanning-slice phase.`,
80
81
  ].join("\n");
81
- writeFileSync(triggerPath, content, "utf-8");
82
+ atomicWriteSync(triggerPath, content, "utf-8");
82
83
  // Also write replan_triggered_at column for DB-backed detection
83
84
  try {
84
85
  const req = createRequire(import.meta.url);
@@ -146,7 +147,7 @@ export function executeBacktrack(basePath, currentMilestoneId, capture) {
146
147
  `2. Identify missing features/requirements from the target milestone`,
147
148
  `3. Resume auto-mode — the state machine will re-enter discussion for the target`,
148
149
  ].join("\n");
149
- writeFileSync(triggerPath, content, "utf-8");
150
+ atomicWriteSync(triggerPath, content, "utf-8");
150
151
  // If we have a valid target, also reset that milestone's completion status
151
152
  // so deriveState() will re-enter it as the active milestone.
152
153
  if (targetMilestoneId) {
@@ -156,7 +157,7 @@ export function executeBacktrack(basePath, currentMilestoneId, capture) {
156
157
  // Write a regression marker so the state machine knows this milestone
157
158
  // needs re-discussion, not just re-execution
158
159
  const regressionPath = join(targetDir, `${targetMilestoneId}-REGRESSION.md`);
159
- writeFileSync(regressionPath, [
160
+ atomicWriteSync(regressionPath, [
160
161
  `# Milestone Regression`,
161
162
  ``,
162
163
  `**From:** ${currentMilestoneId}`,
@@ -292,7 +293,7 @@ export function ensureDeferMilestoneDir(basePath, targetMilestone, captures) {
292
293
  captureList || `(no captures yet)`,
293
294
  ``,
294
295
  ].join("\n");
295
- writeFileSync(join(msDir, `${targetMilestone}-CONTEXT-DRAFT.md`), draftContent, "utf-8");
296
+ atomicWriteSync(join(msDir, `${targetMilestone}-CONTEXT-DRAFT.md`), draftContent, "utf-8");
296
297
  return true;
297
298
  }
298
299
  catch {
@@ -2,9 +2,10 @@
2
2
  // handleUndo: Rollback the most recent completed unit (revert git, remove state, uncheck plans).
3
3
  // handleUndoTask: Reset a single task's DB status to "pending" and re-render markdown.
4
4
  // handleResetSlice: Reset a slice and all its tasks, re-rendering plan + roadmap.
5
- import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
5
+ import { existsSync, readFileSync, unlinkSync, readdirSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
7
  import { nativeRevertCommit, nativeRevertAbort } from "./native-git-bridge.js";
8
+ import { atomicWriteSync } from "./atomic-write.js";
8
9
  import { parseUnitId } from "./unit-id.js";
9
10
  import { deriveState } from "./state.js";
10
11
  import { invalidateAllCaches } from "./cache.js";
@@ -331,7 +332,7 @@ export function uncheckTaskInPlan(basePath, mid, sid, tid) {
331
332
  const regex = new RegExp(`^(\\s*-\\s*)\\[x\\](\\s*\\**${tid}\\**[:\\s])`, "mi");
332
333
  if (regex.test(content)) {
333
334
  content = content.replace(regex, "$1[ ]$2");
334
- writeFileSync(planFile, content, "utf-8");
335
+ atomicWriteSync(planFile, content);
335
336
  return true;
336
337
  }
337
338
  return false;
@@ -223,7 +223,7 @@ function _sanitizeForAudit(entry) {
223
223
  };
224
224
  if (entry.context) {
225
225
  // Allowlist: only persist known-safe structured keys
226
- const SAFE_KEYS = new Set(["fn", "tool", "mid", "sid", "tid", "worktree"]);
226
+ const SAFE_KEYS = new Set(["fn", "tool", "mid", "sid", "tid", "worktree", "id", "error", "count"]);
227
227
  const filtered = {};
228
228
  for (const [k, v] of Object.entries(entry.context)) {
229
229
  if (SAFE_KEYS.has(k)) {