gsd-pi 3.0.0-dev.2e8b124f7 → 3.0.0-dev.6c9a50fd0

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 (87) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/loop.js +2 -3
  3. package/dist/resources/extensions/gsd/auto/orchestrator.js +2 -2
  4. package/dist/resources/extensions/gsd/auto/phases.js +12 -4
  5. package/dist/resources/extensions/gsd/auto-dispatch.js +34 -4
  6. package/dist/resources/extensions/gsd/auto-recovery.js +1 -0
  7. package/dist/resources/extensions/gsd/auto.js +27 -11
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +35 -4
  9. package/dist/resources/extensions/gsd/crash-recovery.js +4 -1
  10. package/dist/resources/extensions/gsd/db/auto-workers.js +21 -0
  11. package/dist/resources/extensions/gsd/preferences.js +4 -0
  12. package/dist/resources/extensions/gsd/repo-identity.js +39 -22
  13. package/dist/resources/extensions/gsd/session-lock.js +15 -2
  14. package/dist/resources/extensions/gsd/tools/complete-milestone.js +9 -1
  15. package/dist/resources/extensions/gsd/tools/complete-slice.js +50 -2
  16. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +66 -40
  17. package/dist/resources/extensions/gsd/worktree-safety.js +10 -3
  18. package/dist/resources/extensions/shared/next-action-ui.js +13 -5
  19. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  20. package/dist/web/standalone/.next/BUILD_ID +1 -1
  21. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  22. package/dist/web/standalone/.next/build-manifest.json +2 -2
  23. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  24. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.html +1 -1
  41. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  48. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  49. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  50. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  51. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  52. package/package.json +1 -1
  53. package/src/resources/extensions/gsd/auto/contracts.ts +2 -0
  54. package/src/resources/extensions/gsd/auto/loop.ts +2 -2
  55. package/src/resources/extensions/gsd/auto/orchestrator.ts +2 -2
  56. package/src/resources/extensions/gsd/auto/phases.ts +14 -4
  57. package/src/resources/extensions/gsd/auto-dispatch.ts +52 -3
  58. package/src/resources/extensions/gsd/auto-recovery.ts +1 -0
  59. package/src/resources/extensions/gsd/auto.ts +63 -18
  60. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +25 -4
  61. package/src/resources/extensions/gsd/crash-recovery.ts +3 -0
  62. package/src/resources/extensions/gsd/db/auto-workers.ts +25 -0
  63. package/src/resources/extensions/gsd/preferences.ts +4 -0
  64. package/src/resources/extensions/gsd/repo-identity.ts +45 -25
  65. package/src/resources/extensions/gsd/session-lock.ts +15 -2
  66. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +135 -0
  67. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +64 -35
  68. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +17 -15
  69. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +13 -0
  70. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +51 -1
  71. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +55 -0
  72. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +111 -1
  73. package/src/resources/extensions/gsd/tests/integration/state-machine-live-validation.test.ts +15 -0
  74. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +38 -0
  75. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +28 -1
  76. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +35 -0
  77. package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +38 -0
  78. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +120 -0
  79. package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +44 -0
  80. package/src/resources/extensions/gsd/tools/complete-milestone.ts +10 -0
  81. package/src/resources/extensions/gsd/tools/complete-slice.ts +51 -2
  82. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +31 -17
  83. package/src/resources/extensions/gsd/worktree-safety.ts +12 -4
  84. package/src/resources/extensions/shared/next-action-ui.ts +11 -5
  85. package/src/resources/extensions/shared/tests/next-action-ui-hasui.test.ts +32 -0
  86. /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_buildManifest.js +0 -0
  87. /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_ssgManifest.js +0 -0
@@ -970,7 +970,11 @@ test("hasImplementationArtifacts does not backfill untagged commits before miles
970
970
  });
971
971
 
972
972
  const result = hasImplementationArtifacts(base, "M001");
973
- assert.equal(result, "absent", "pre-milestone commits must not be attributed to the milestone");
973
+ assert.equal(
974
+ result,
975
+ "unknown",
976
+ "integration self-diff should remain unknown when pre-milestone commits cannot be attributed",
977
+ );
974
978
  assert.deepEqual(getMilestoneCommitAttributionShas("M001"), []);
975
979
  } finally {
976
980
  cleanup(base);
@@ -1007,7 +1011,11 @@ test("hasImplementationArtifacts does not backfill unrelated untagged implementa
1007
1011
  execFileSync("git", ["commit", "-m", "feat: unrelated work"], { cwd: base, stdio: "ignore" });
1008
1012
 
1009
1013
  const result = hasImplementationArtifacts(base, "M001");
1010
- assert.equal(result, "absent", "backfill must require overlap with completed task file hints");
1014
+ assert.equal(
1015
+ result,
1016
+ "unknown",
1017
+ "integration self-diff should remain unknown when unrelated untagged commits cannot be attributed",
1018
+ );
1011
1019
  assert.deepEqual(getMilestoneCommitAttributionShas("M001"), []);
1012
1020
  } finally {
1013
1021
  cleanup(base);
@@ -1126,7 +1134,7 @@ test("hasImplementationArtifacts binds GSD-Task trailer to milestone via DB stat
1126
1134
  }
1127
1135
  });
1128
1136
 
1129
- test("hasImplementationArtifacts does not claim Sxx/Tyy commit trailers across milestones when ownership points elsewhere", () => {
1137
+ test("hasImplementationArtifacts returns unknown when GSD-Task trailer cannot be bound to milestone ownership evidence", () => {
1130
1138
  const base = makeGitBase();
1131
1139
  try {
1132
1140
  writeFileSync(join(base, ".git", "info", "exclude"), ".gsd/\n");
@@ -1159,17 +1167,11 @@ test("hasImplementationArtifacts does not claim Sxx/Tyy commit trailers across m
1159
1167
  { cwd: base, stdio: "ignore" },
1160
1168
  );
1161
1169
 
1162
- const m001Result = hasImplementationArtifacts(base, "M001");
1163
- const m002Result = hasImplementationArtifacts(base, "M002");
1164
- assert.equal(
1165
- m001Result,
1166
- "absent",
1167
- "Sxx/Tyy commit trailers owned by M002 must not be attributed to M001",
1168
- );
1170
+ const result = hasImplementationArtifacts(base, "M001");
1169
1171
  assert.equal(
1170
- m002Result,
1171
- "present",
1172
- "the owning milestone should still claim the implementation-bearing commit",
1172
+ result,
1173
+ "unknown",
1174
+ "integration self-diff should not conclude absent when S01/T01 cannot be bound to M001",
1173
1175
  );
1174
1176
  } finally {
1175
1177
  cleanup(base);
@@ -1193,8 +1195,8 @@ test("hasImplementationArtifacts ignores malformed milestone IDs in commit-messa
1193
1195
  const result = hasImplementationArtifacts(base, "M001(");
1194
1196
  assert.equal(
1195
1197
  result,
1196
- "absent",
1197
- "malformed milestone IDs must not bind implementation commits through message scanning",
1198
+ "unknown",
1199
+ "malformed milestone IDs must not force an absent classification when ownership cannot be proven",
1198
1200
  );
1199
1201
  } finally {
1200
1202
  cleanup(base);
@@ -13,6 +13,7 @@ import {
13
13
  heartbeatAutoWorker,
14
14
  markWorkerCrashed,
15
15
  markWorkerStopping,
16
+ markWorkerStoppingByPid,
16
17
  getActiveAutoWorkers,
17
18
  getAutoWorker,
18
19
  } from "../db/auto-workers.ts";
@@ -71,6 +72,18 @@ test("markWorkerStopping flips status to stopping", (t) => {
71
72
  assert.equal(row.status, "stopping");
72
73
  });
73
74
 
75
+ test("markWorkerStoppingByPid flips matching active row to stopping", (t) => {
76
+ const base = makeBase();
77
+ t.after(() => cleanup(base));
78
+ openDatabase(join(base, ".gsd", "gsd.db"));
79
+
80
+ const id = registerAutoWorker({ projectRootRealpath: base });
81
+ const pid = getAutoWorker(id)!.pid;
82
+ markWorkerStoppingByPid(base, pid);
83
+ const row = getAutoWorker(id)!;
84
+ assert.equal(row.status, "stopping");
85
+ });
86
+
74
87
  test("markWorkerCrashed flips status to crashed", (t) => {
75
88
  const base = makeBase();
76
89
  t.after(() => cleanup(base));
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { invalidateAllCaches } from '../cache.ts';
9
9
  import { parseUnitId } from "../unit-id.ts";
10
- import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask } from "../gsd-db.ts";
10
+ import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, insertAssessment } from "../gsd-db.ts";
11
11
  import { clearPathCache } from "../paths.ts";
12
12
  import { clearParseCache } from "../files.ts";
13
13
 
@@ -503,6 +503,49 @@ describe("complete-milestone", () => {
503
503
  }
504
504
  });
505
505
 
506
+ test("handleCompleteMilestone refuses closeout when latest validation verdict is needs-attention (#5661)", async () => {
507
+ const { handleCompleteMilestone } = await import("../tools/complete-milestone.ts");
508
+ const base = createFixtureBase();
509
+ const mid = "M001";
510
+ const dbPath = join(base, ".gsd", "gsd.db");
511
+ try {
512
+ openDatabase(dbPath);
513
+ insertMilestone({ id: mid, title: "Test Milestone", status: "active" });
514
+ insertSlice({ id: "S01", milestoneId: mid, title: "Slice One", status: "complete" });
515
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: mid, title: "Task One", status: "complete" });
516
+ insertAssessment({
517
+ path: join(".gsd", "milestones", mid, `${mid}-VALIDATION.md`),
518
+ milestoneId: mid,
519
+ status: "needs-attention",
520
+ scope: "milestone-validation",
521
+ fullContent: "---\nverdict: needs-attention\nremediation_round: 1\n---\n\n# Validation\nNeeds attention.",
522
+ });
523
+
524
+ const result = await handleCompleteMilestone({
525
+ milestoneId: mid,
526
+ title: "Test Milestone",
527
+ oneLiner: "Test",
528
+ narrative: "Test narrative",
529
+ successCriteriaResults: "Results",
530
+ definitionOfDoneResults: "Done",
531
+ requirementOutcomes: "Outcomes",
532
+ keyDecisions: [],
533
+ keyFiles: [],
534
+ lessonsLearned: [],
535
+ followUps: "",
536
+ deviations: "",
537
+ verificationPassed: true,
538
+ }, base);
539
+
540
+ assert.ok("error" in result, "non-pass validation verdict should block completion");
541
+ assert.match((result as { error: string }).error, /needs-attention/i);
542
+ assert.match((result as { error: string }).error, /Only verdict=pass permits closeout/i);
543
+ } finally {
544
+ try { closeDatabase(); } catch { /* */ }
545
+ cleanup(base);
546
+ }
547
+ });
548
+
506
549
  test("deriveState completing-milestone integration", async () => {
507
550
  const { deriveState, isMilestoneComplete } = await import("../state.ts");
508
551
  const { invalidateAllCaches: invalidateAllCachesDynamic } = await import("../cache.ts");
@@ -655,6 +698,13 @@ describe("complete-milestone", () => {
655
698
  insertMilestone({ id: mid, title: "Empty Enrichment", status: "active" });
656
699
  insertSlice({ id: "S01", milestoneId: mid, title: "Slice", status: "complete" });
657
700
  insertTask({ id: "T01", sliceId: "S01", milestoneId: mid, title: "Task", status: "complete" });
701
+ insertAssessment({
702
+ path: join(".gsd", "milestones", mid, `${mid}-VALIDATION.md`),
703
+ milestoneId: mid,
704
+ status: "pass",
705
+ scope: "milestone-validation",
706
+ fullContent: "---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nValidated.",
707
+ });
658
708
 
659
709
  const result = await handleCompleteMilestone({
660
710
  milestoneId: mid,
@@ -13,6 +13,7 @@ import {
13
13
  getSlice,
14
14
  updateSliceStatus,
15
15
  getSliceTasks,
16
+ setSliceSummaryMd,
16
17
  SCHEMA_VERSION,
17
18
  } from '../gsd-db.ts';
18
19
  import { handleCompleteSlice } from '../tools/complete-slice.ts';
@@ -407,6 +408,60 @@ console.log('\n=== complete-slice: handler with missing roadmap ===');
407
408
  cleanup(dbPath);
408
409
  }
409
410
 
411
+ // ═══════════════════════════════════════════════════════════════════════════
412
+ // complete-slice: backfills omitted requirements from rendered summary
413
+ // ═══════════════════════════════════════════════════════════════════════════
414
+
415
+ console.log('\n=== complete-slice: backfills omitted requirements from rendered summary ===');
416
+ {
417
+ const dbPath = tempDbPath();
418
+ openDatabase(dbPath);
419
+
420
+ const { basePath } = createTempProject();
421
+
422
+ insertMilestone({ id: 'M001' });
423
+ insertSlice({ id: 'S01', milestoneId: 'M001' });
424
+ insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' });
425
+
426
+ const seedParams = makeValidSliceParams();
427
+ const seeded = await handleCompleteSlice(seedParams, basePath);
428
+ assertTrue(!('error' in seeded), 'seed completion should succeed');
429
+ if ('error' in seeded) {
430
+ cleanupDir(basePath);
431
+ cleanup(dbPath);
432
+ throw new Error('seed completion unexpectedly failed');
433
+ }
434
+
435
+ const seededSummary = fs.readFileSync(seeded.summaryPath, 'utf-8');
436
+ transaction(() => {
437
+ updateSliceStatus('M001', 'S01', 'pending', undefined);
438
+ setSliceSummaryMd('M001', 'S01', seededSummary, '');
439
+ });
440
+
441
+ const backfillParams = makeValidSliceParams();
442
+ delete (backfillParams as Partial<CompleteSliceParams>).requirementsAdvanced;
443
+ delete (backfillParams as Partial<CompleteSliceParams>).requirementsValidated;
444
+ delete (backfillParams as Partial<CompleteSliceParams>).requirementsInvalidated;
445
+ const backfilled = await handleCompleteSlice(backfillParams as CompleteSliceParams, basePath);
446
+ assertTrue(!('error' in backfilled), 'backfill completion should succeed');
447
+ if (!('error' in backfilled)) {
448
+ const summary = fs.readFileSync(backfilled.summaryPath, 'utf-8');
449
+ assertMatch(summary, /## Requirements Advanced/, 'summary should include advanced requirements heading');
450
+ assertMatch(summary, /- R001 — Handler validates task completion/, 'advanced requirement should be backfilled from summary markdown');
451
+
452
+ const sliceAfterBackfill = getSlice('M001', 'S01');
453
+ assertTrue(sliceAfterBackfill !== null, 'slice should exist after backfill');
454
+ assertMatch(
455
+ sliceAfterBackfill!.full_summary_md,
456
+ /- R001 — Handler validates task completion/,
457
+ 'DB full_summary_md should persist the backfilled advanced requirement',
458
+ );
459
+ }
460
+
461
+ cleanupDir(basePath);
462
+ cleanup(dbPath);
463
+ }
464
+
410
465
  // ═══════════════════════════════════════════════════════════════════════════
411
466
  // complete-slice: PROJECT refresh uses DB-backed artifact tool.
412
467
  // ═══════════════════════════════════════════════════════════════════════════
@@ -11,12 +11,14 @@
11
11
 
12
12
  import test from "node:test";
13
13
  import assert from "node:assert/strict";
14
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
14
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { tmpdir } from "node:os";
17
17
  import { resolveDispatch } from "../auto-dispatch.ts";
18
18
  import type { DispatchContext } from "../auto-dispatch.ts";
19
+ import type { AutoSession } from "../auto/session.ts";
19
20
  import type { GSDState } from "../types.ts";
21
+ import { enableDebug, disableDebug, getDebugLogPath } from "../debug-logger.ts";
20
22
 
21
23
  function makeState(overrides: Partial<GSDState> = {}): GSDState {
22
24
  return {
@@ -42,6 +44,27 @@ function makeContext(basePath: string, stateOverrides?: Partial<GSDState>): Disp
42
44
  };
43
45
  }
44
46
 
47
+ function makeContextFor(
48
+ basePath: string,
49
+ mid: string,
50
+ sid: string,
51
+ tid: string,
52
+ session?: Partial<AutoSession>,
53
+ ): DispatchContext {
54
+ return {
55
+ basePath,
56
+ mid,
57
+ midTitle: "Test Milestone",
58
+ state: makeState({
59
+ activeMilestone: { id: mid, title: "Test Milestone" },
60
+ activeSlice: { id: sid, title: "Second Slice" },
61
+ activeTask: { id: tid, title: "First Task" },
62
+ }),
63
+ prefs: undefined,
64
+ session: session as AutoSession | undefined,
65
+ };
66
+ }
67
+
45
68
  // ─── Scaffold helpers ──────────────────────────────────────────────────────
46
69
 
47
70
  function scaffoldSlicePlan(basePath: string, mid: string, sid: string): void {
@@ -118,6 +141,57 @@ test("dispatch: present task plan proceeds to execute-task normally", async (t)
118
141
  `unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
119
142
  });
120
143
 
144
+ test("dispatch: executing recovery checks active milestone worktree task plans before re-dispatching plan-slice", async (t) => {
145
+ const tmp = mkdtempSync(join(tmpdir(), "gsd-6192-"));
146
+ t.after(() => rmSync(tmp, { recursive: true, force: true }));
147
+
148
+ scaffoldMilestoneContext(tmp, "M002");
149
+ scaffoldSlicePlan(tmp, "M002", "S03");
150
+
151
+ const worktreeRoot = join(tmp, ".gsd", "worktrees", "M002");
152
+ mkdirSync(worktreeRoot, { recursive: true });
153
+ writeFileSync(join(worktreeRoot, ".git"), "gitdir: /tmp/fake-worktree-gitdir\n");
154
+ scaffoldMilestoneContext(worktreeRoot, "M002");
155
+ scaffoldSlicePlan(worktreeRoot, "M002", "S03");
156
+ scaffoldTaskPlan(worktreeRoot, "M002", "S03", "T01");
157
+
158
+ const ctx = makeContext(tmp);
159
+ const result = await resolveDispatch(ctx);
160
+
161
+ assert.equal(result.action, "dispatch");
162
+ assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
163
+ `unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
164
+ assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01",
165
+ `unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
166
+ });
167
+
168
+ test("dispatch: active session worktree task plan wins over missing original-root task plan", async (t) => {
169
+ const tmp = mkdtempSync(join(tmpdir(), "gsd-worktree-artifact-root-"));
170
+ t.after(() => rmSync(tmp, { recursive: true, force: true }));
171
+
172
+ scaffoldMilestoneContext(tmp, "M004");
173
+ scaffoldSlicePlan(tmp, "M004", "S02");
174
+
175
+ const worktreeRoot = join(tmp, ".gsd", "worktrees", "M004");
176
+ mkdirSync(worktreeRoot, { recursive: true });
177
+ scaffoldMilestoneContext(worktreeRoot, "M004");
178
+ scaffoldSlicePlan(worktreeRoot, "M004", "S02");
179
+ scaffoldTaskPlan(worktreeRoot, "M004", "S02", "T01");
180
+
181
+ const ctx = makeContextFor(tmp, "M004", "S02", "T01", {
182
+ basePath: worktreeRoot,
183
+ originalBasePath: tmp,
184
+ currentMilestoneId: "M004",
185
+ });
186
+ const result = await resolveDispatch(ctx);
187
+
188
+ assert.equal(result.action, "dispatch");
189
+ assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
190
+ `unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
191
+ assert.ok(result.action === "dispatch" && result.unitId === "M004/S02/T01",
192
+ `unitId should be M004/S02/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
193
+ });
194
+
121
195
  test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async (t) => {
122
196
  // Simulate: plan-slice ran but T01-PLAN.md is still missing (e.g. agent crashed mid-write).
123
197
  // Dispatch should still re-dispatch plan-slice, not hard-stop.
@@ -138,3 +212,39 @@ test("dispatch: plan-slice recovery loop — second call after plan-slice still
138
212
  assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice",
139
213
  "should keep dispatching plan-slice until task plans appear");
140
214
  });
215
+
216
+ test("dispatch: missing task plan recovery logs root/worktree diagnostic when debug enabled — issue #6194", async (t) => {
217
+ // The diagnostic exists to surface root/worktree artifact-path mismatches
218
+ // when the recovery rule fires. It must report the paths that were checked
219
+ // so a stuck session can be traced — not just that recovery happened.
220
+ const tmp = mkdtempSync(join(tmpdir(), "gsd-6194-"));
221
+ t.after(() => rmSync(tmp, { recursive: true, force: true }));
222
+
223
+ scaffoldMilestoneContext(tmp, "M002");
224
+ scaffoldSlicePlan(tmp, "M002", "S03");
225
+
226
+ enableDebug(tmp);
227
+ t.after(() => disableDebug());
228
+
229
+ const ctx = makeContext(tmp);
230
+ const result = await resolveDispatch(ctx);
231
+ assert.ok(result.action === "dispatch" && result.unitType === "plan-slice",
232
+ "recovery rule must fire for the diagnostic to be exercised");
233
+
234
+ const logPath = getDebugLogPath();
235
+ assert.ok(logPath, "debug log path should be set while debug is enabled");
236
+
237
+ const entry = readFileSync(logPath!, "utf8")
238
+ .trim()
239
+ .split("\n")
240
+ .filter(Boolean)
241
+ .map((line) => JSON.parse(line) as Record<string, unknown>)
242
+ .find((e) => e.event === "dispatch-missing-task-plan-recovery");
243
+
244
+ assert.ok(entry, "diagnostic event should be logged when recovery fires in debug mode");
245
+ assert.equal(entry!.basePathUsedForArtifactChecks, tmp);
246
+ assert.equal(entry!.artifactExists, false, "task plan is genuinely absent");
247
+ assert.equal(entry!.projectionArtifactExists, false, "projection task plan is genuinely absent");
248
+ assert.equal(typeof entry!.expectedTaskPlanPath, "string");
249
+ assert.equal(typeof entry!.projectionTaskPlanPath, "string");
250
+ });
@@ -31,6 +31,7 @@ import {
31
31
  openDatabase,
32
32
  closeDatabase,
33
33
  insertMilestone,
34
+ insertAssessment,
34
35
  insertSlice,
35
36
  insertTask,
36
37
  getTask,
@@ -280,6 +281,16 @@ function makeMilestoneParams(milestoneId: string): Record<string, unknown> {
280
281
  };
281
282
  }
282
283
 
284
+ function insertPassingMilestoneValidation(milestoneId: string): void {
285
+ insertAssessment({
286
+ path: `.gsd/milestones/${milestoneId}/${milestoneId}-VALIDATION.md`,
287
+ milestoneId,
288
+ status: "pass",
289
+ scope: "milestone-validation",
290
+ fullContent: "# Validation\n\nverdict: PASS",
291
+ });
292
+ }
293
+
283
294
  // ═══════════════════════════════════════════════════════════════════════════
284
295
  // Test Suite
285
296
  // ═══════════════════════════════════════════════════════════════════════════
@@ -424,6 +435,7 @@ describe("state-machine-live-validation", () => {
424
435
  insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Impl", status: "complete" });
425
436
  insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", title: "Test", status: "complete" });
426
437
  insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", title: "Impl", status: "complete" });
438
+ insertPassingMilestoneValidation("M001");
427
439
 
428
440
  const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
429
441
  assert.ok(!("error" in result), `expected success, got: ${JSON.stringify(result)}`);
@@ -528,6 +540,7 @@ describe("state-machine-live-validation", () => {
528
540
  base = createFullFixture();
529
541
  openDatabase(join(base, ".gsd", "gsd.db"));
530
542
  insertMilestone({ id: "M001", title: "Active", status: "active" });
543
+ insertPassingMilestoneValidation("M001");
531
544
 
532
545
  const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
533
546
  assert.ok("error" in result);
@@ -542,6 +555,7 @@ describe("state-machine-live-validation", () => {
542
555
  insertSlice({ id: "S02", milestoneId: "M001", status: "in_progress" });
543
556
  insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
544
557
  insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
558
+ insertPassingMilestoneValidation("M001");
545
559
 
546
560
  const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
547
561
  assert.ok("error" in result);
@@ -555,6 +569,7 @@ describe("state-machine-live-validation", () => {
555
569
  // Slice marked complete but task is still pending — simulates inconsistent state
556
570
  insertSlice({ id: "S01", milestoneId: "M001", status: "complete" });
557
571
  insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
572
+ insertPassingMilestoneValidation("M001");
558
573
 
559
574
  const result = await handleCompleteMilestone(makeMilestoneParams("M001") as any, base);
560
575
  assert.ok("error" in result);
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
+ import { _handlePausedSessionResumeRecoveryForTest } from "../auto.ts";
8
9
  import { assessInterruptedSession } from "../interrupted-session.ts";
9
10
  import {
10
11
  openDatabase,
@@ -186,6 +187,43 @@ test("direct /gsd auto source only resumes paused-session metadata for recoverab
186
187
  assert.ok(source.includes('|| !!freshStartAssessment.lock'));
187
188
  });
188
189
 
190
+ test("direct /gsd auto skips paused-session replay when recovered unit already completed", async () => {
191
+ const base = makeTmpBase();
192
+ try {
193
+ writeRoadmap(base, false);
194
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
195
+ const tasksDir = join(sliceDir, "tasks");
196
+ mkdirSync(tasksDir, { recursive: true });
197
+ writeFileSync(
198
+ join(sliceDir, "S01-PLAN.md"),
199
+ [
200
+ "# S01: Test Slice",
201
+ "",
202
+ "## Tasks",
203
+ "",
204
+ "- [ ] **T01: First task** `est:1h`",
205
+ ].join("\n"),
206
+ "utf-8",
207
+ );
208
+ writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.\n", "utf-8");
209
+
210
+ const state = {
211
+ pausedSessionFile: join(base, ".gsd", "activity", "paused-session.jsonl"),
212
+ currentUnit: { type: "plan-slice", id: "M001/S01" },
213
+ pausedUnitType: null,
214
+ pausedUnitId: null,
215
+ pendingCrashRecovery: null,
216
+ };
217
+
218
+ const result = _handlePausedSessionResumeRecoveryForTest(base, state);
219
+ assert.equal(result.skippedReplay, true);
220
+ assert.equal(state.pausedSessionFile, null);
221
+ assert.equal(state.pendingCrashRecovery, null);
222
+ } finally {
223
+ cleanup(base);
224
+ }
225
+ });
226
+
189
227
  test("auto module imports successfully after interrupted-session changes", async () => {
190
228
  const mod = await import(`../auto.ts?ts=${Date.now()}-${Math.random()}`);
191
229
  assert.equal(typeof mod.startAuto, "function");
@@ -1,6 +1,6 @@
1
1
  import { describe, test, before, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, rmSync, writeFileSync, existsSync, lstatSync, realpathSync, mkdirSync, symlinkSync, renameSync } from "node:fs";
3
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, lstatSync, realpathSync, mkdirSync, symlinkSync, renameSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { execSync } from "node:child_process";
@@ -228,4 +228,31 @@ test('validateProjectId accepts valid values', () => {
228
228
  }
229
229
  });
230
230
 
231
+ test('ensureGsdSymlink prefers marker directory when marker/computed identities diverge and both have state (#5685)', () => {
232
+ const repo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-split-brain-")));
233
+ run("git init -b main", repo);
234
+ run('git config user.name "Pi Test"', repo);
235
+ run('git config user.email "pi@example.com"', repo);
236
+ writeFileSync(join(repo, "README.md"), "# Split Brain Test\n", "utf-8");
237
+ run("git add README.md", repo);
238
+ run('git commit -m "init"', repo);
239
+
240
+ const computedPath = externalGsdRoot(repo);
241
+ mkdirSync(computedPath, { recursive: true });
242
+ writeFileSync(join(computedPath, "computed-state.txt"), "computed\n", "utf-8");
243
+
244
+ const markerId = "marker-state-id";
245
+ const markerPath = join(stateDir, "projects", markerId);
246
+ mkdirSync(markerPath, { recursive: true });
247
+ writeFileSync(join(markerPath, "marker-state.txt"), "marker\n", "utf-8");
248
+ writeFileSync(join(repo, ".gsd-id"), `${markerId}\n`, "utf-8");
249
+
250
+ const resolved = ensureGsdSymlink(repo);
251
+ assert.deepStrictEqual(resolved, markerPath, "marker-backed state directory is preferred in split-brain condition");
252
+ assert.deepStrictEqual(realpathSync(join(repo, ".gsd")), realpathSync(markerPath), ".gsd symlink resolves to marker-backed state directory");
253
+ assert.deepStrictEqual(readFileSync(join(repo, ".gsd-id"), "utf-8").trim(), markerId, ".gsd-id marker persists the marker-backed identity");
254
+
255
+ rmSync(repo, { recursive: true, force: true });
256
+ });
257
+
231
258
  });
@@ -25,6 +25,9 @@ import {
25
25
  isSessionLockHeld,
26
26
  } from '../session-lock.ts';
27
27
  import { gsdRoot } from '../paths.ts';
28
+ import { openDatabase, closeDatabase, _getAdapter } from "../gsd-db.ts";
29
+ import { registerAutoWorker, getAutoWorker } from "../db/auto-workers.ts";
30
+ import { normalizeRealPath } from "../paths.ts";
28
31
  import { describe, test } from 'node:test';
29
32
  import assert from 'node:assert/strict';
30
33
 
@@ -94,6 +97,38 @@ describe('session-lock-regression', async () => {
94
97
  }
95
98
  }
96
99
 
100
+ // ─── 2b. Dead lock PID is marked stopping in workers table ────────────
101
+ console.log('\n=== 2b. dead lock PID marks worker stopping ===');
102
+ {
103
+ const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
104
+ mkdirSync(join(base, '.gsd'), { recursive: true });
105
+
106
+ try {
107
+ openDatabase(join(base, ".gsd", "gsd.db"));
108
+ const projectRoot = normalizeRealPath(base);
109
+ const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
110
+ const deadPid = 99999;
111
+ writeFileSync(join(gsdRoot(base), "auto.lock"), JSON.stringify({
112
+ pid: deadPid,
113
+ startedAt: new Date().toISOString(),
114
+ unitType: "starting",
115
+ unitId: "bootstrap",
116
+ unitStartedAt: new Date().toISOString(),
117
+ }));
118
+ // Align worker PID with stale lock metadata.
119
+ _getAdapter()?.prepare("UPDATE workers SET pid = :pid WHERE worker_id = :id")
120
+ .run({ ":pid": deadPid, ":id": workerId });
121
+
122
+ const result = acquireSessionLock(base);
123
+ assert.ok(result.acquired, "acquire recovers stale lock");
124
+ assert.equal(getAutoWorker(workerId)?.status, "stopping");
125
+ releaseSessionLock(base);
126
+ } finally {
127
+ try { closeDatabase(); } catch { /* noop */ }
128
+ rmSync(base, { recursive: true, force: true });
129
+ }
130
+ }
131
+
97
132
  // ─── 3. updateSessionLock preserves lock data ─────────────────────────
98
133
  console.log('\n=== 3. updateSessionLock writes metadata ===');
99
134
  {
@@ -7,12 +7,20 @@ import assert from "node:assert/strict";
7
7
  import {
8
8
  _hasEmptyAgentEndContent,
9
9
  _handleSessionSwitchAgentEnd,
10
+ handleAgentEnd,
10
11
  isBareClaudeCodeStreamAbortPlaceholder,
11
12
  isClaudeCodeSessionSwitchAbortMessage,
12
13
  } from "../bootstrap/agent-end-recovery.js";
14
+ import { _setAutoActiveForTest } from "../auto.js";
13
15
  import { shouldIgnoreAgentEndForActiveUnit } from "../auto/unit-runner-events.js";
16
+ import { _resetPendingResolve, _setCurrentResolve } from "../auto/resolve.js";
14
17
  import type { ErrorContext } from "../auto/types.js";
15
18
 
19
+ test.afterEach(() => {
20
+ _setAutoActiveForTest(false);
21
+ _resetPendingResolve();
22
+ });
23
+
16
24
  test("user-abort message during session-switch is dropped (not propagated as cancellation)", () => {
17
25
  // The Anthropic SDK emits this exact string when newSession() aborts an
18
26
  // in-flight stream during a unit-to-unit session transition. Before the fix
@@ -113,6 +121,34 @@ test("late bare Claude Code stream-aborted placeholder is classified as internal
113
121
  );
114
122
  });
115
123
 
124
+ test("mid-unit bare Claude Code stream-aborted placeholder resolves the unit", async () => {
125
+ // A placeholder without an active session-switch grace window belongs to the
126
+ // current unit. Resolving it prevents auto-mode from hanging while still
127
+ // allowing the next unit to run.
128
+ const results: unknown[] = [];
129
+ const warnings: string[] = [];
130
+ _setAutoActiveForTest(true);
131
+ _setCurrentResolve((result) => results.push(result));
132
+
133
+ const event = {
134
+ messages: [{
135
+ stopReason: "aborted",
136
+ content: [{ type: "text", text: "Claude Code stream aborted by caller" }],
137
+ }],
138
+ };
139
+
140
+ await handleAgentEnd({} as any, event, {
141
+ ui: {
142
+ notify: (message: string, level: string) => {
143
+ if (level === "warning") warnings.push(message);
144
+ },
145
+ },
146
+ } as any);
147
+
148
+ assert.deepEqual(results, [{ status: "completed", event }]);
149
+ assert.deepEqual(warnings, ["Claude Code stream aborted mid-unit (no diagnostic). Continuing."]);
150
+ });
151
+
116
152
  test("typed session-transition abort events are classified as internal", () => {
117
153
  assert.equal(
118
154
  shouldIgnoreAgentEndForActiveUnit({
@@ -205,6 +241,8 @@ test("missing agent_end content is classified as empty abort content", () => {
205
241
  assert.equal(_hasEmptyAgentEndContent(undefined), true);
206
242
  assert.equal(_hasEmptyAgentEndContent(null), true);
207
243
  assert.equal(_hasEmptyAgentEndContent([]), true);
244
+ assert.equal(_hasEmptyAgentEndContent([{ type: "text", text: "" }]), true);
245
+ assert.equal(_hasEmptyAgentEndContent([{ type: "text", text: " " }]), true);
208
246
  assert.equal(_hasEmptyAgentEndContent([{ type: "text", text: "partial" }]), false);
209
247
  });
210
248