gsd-pi 2.32.0-dev.f3d5d53 → 2.33.0-dev.69bff0f

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 (66) hide show
  1. package/README.md +13 -18
  2. package/dist/resources/extensions/gsd/auto-dashboard.ts +3 -1
  3. package/dist/resources/extensions/gsd/auto-dispatch.ts +40 -12
  4. package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
  5. package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
  6. package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
  7. package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
  8. package/dist/resources/extensions/gsd/auto-start.ts +2 -1
  9. package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  10. package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
  11. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  12. package/dist/resources/extensions/gsd/auto-verification.ts +4 -5
  13. package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
  14. package/dist/resources/extensions/gsd/auto.ts +89 -164
  15. package/dist/resources/extensions/gsd/commands.ts +14 -2
  16. package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
  17. package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
  18. package/dist/resources/extensions/gsd/metrics.ts +3 -3
  19. package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  20. package/dist/resources/extensions/gsd/session-lock.ts +80 -16
  21. package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  22. package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  23. package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  24. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
  25. package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  26. package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  27. package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  28. package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  29. package/dist/resources/extensions/gsd/undo.ts +5 -7
  30. package/dist/resources/extensions/gsd/unit-id.ts +14 -0
  31. package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
  32. package/package.json +3 -2
  33. package/packages/pi-coding-agent/package.json +1 -1
  34. package/pkg/package.json +1 -1
  35. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
  36. package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
  37. package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
  38. package/src/resources/extensions/gsd/auto-observability.ts +2 -4
  39. package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
  40. package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
  41. package/src/resources/extensions/gsd/auto-start.ts +2 -1
  42. package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  43. package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
  44. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  45. package/src/resources/extensions/gsd/auto-verification.ts +4 -5
  46. package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
  47. package/src/resources/extensions/gsd/auto.ts +89 -164
  48. package/src/resources/extensions/gsd/commands.ts +14 -2
  49. package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
  50. package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
  51. package/src/resources/extensions/gsd/metrics.ts +3 -3
  52. package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  53. package/src/resources/extensions/gsd/session-lock.ts +80 -16
  54. package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  55. package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  56. package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  57. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
  58. package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  59. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  60. package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  61. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  62. package/src/resources/extensions/gsd/undo.ts +5 -7
  63. package/src/resources/extensions/gsd/unit-id.ts +14 -0
  64. package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
  65. package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
  66. package/src/resources/extensions/mcporter/extension-manifest.json +0 -12
package/README.md CHANGED
@@ -24,24 +24,19 @@ One command. Walk away. Come back to a built project with clean git history.
24
24
 
25
25
  ---
26
26
 
27
- ## What's New in v2.32
28
-
29
- - **Simplified pipeline** — research merged into planning, mechanical completion (ADR-003)
30
- - **Always-on health widget** — 🟢🟡🔴 traffic-light indicator in the progress widget and visualizer health tab
31
- - **Environment health checks** — progress scoring and status integration for auto-mode
32
- - **Extension registry** — user-managed enable/disable for bundled and custom extensions
33
- - **Built-in skill authoring** — create and distribute custom skills from within GSD
34
- - **Workflow templates** — right-sized workflows for every task type (research, plan, execute, complete)
35
- - **AWS Bedrock auth** — automatic credential refresh via the new `aws-auth` extension
36
- - **`-w` / `--worktree` CLI flag** — launch isolated worktree sessions from the command line
37
- - **Native MCP client** — replaced MCPorter with a built-in MCP client for better reliability
38
- - **External state directory** — `.gsd/` now lives in `~/.gsd/projects/` with a symlink (ADR-002)
39
- - **Model health indicator** — live health status based on error trends and consecutive failures
40
- - **Quick-task branch cleanup** — `/gsd quick` branches auto-merge back after completion
41
- - **Windows EPERM fallback** — migration rename uses copy+delete when NTFS blocks rename
42
- - **Worktree identity fix** — stable project hash across worktrees and main repo
43
- - **Crash recovery guidance** — actionable next-step messages based on what was interrupted
44
- - **UAT verdict gating** — non-PASS verdicts now block slice progression instead of being ignored
27
+ ## What's New in v2.33
28
+
29
+ - **Dispatch loop hardening** — defensive guards, reentrancy protection, and 125 new regression tests covering the full `deriveState → resolveDispatch` chain without an LLM
30
+ - **Live regression test harness** — post-build pipeline validation that catches dispatch, parser, and lock lifecycle regressions before promotion
31
+ - **Unified error handling** — `getErrorMessage()` helper replaces 65 inline duplicates across the codebase
32
+ - **Centralized unit ID parsing** — `parseUnitId()` eliminates fragile regex patterns scattered across dispatch, recovery, and metrics code
33
+ - **Milestone merge consolidation** — `tryMergeMilestone()` replaces 4 duplicate merge paths in the auto-mode loop
34
+ - **Lock alignment fix** — retry lock path now matches primary lock settings, preventing `ECOMPROMISED` errors on resume
35
+ - **NixOS/nix-darwin support** — symlinks in `.gsd/` are skipped during `makeTreeWritable` to prevent `EPERM` failures
36
+ - **Windows EPERM fallback** — `.gsd/` migration uses copy+delete when NTFS blocks direct rename
37
+ - **Worktree identity fix** — stable project hash resolved from main repo root, not worktree path
38
+ - **Quick-task branch cleanup** — `/gsd quick` branches auto-merge back to the original branch after completion
39
+ - **Crash recovery guidance** — actionable next-step messages based on what was interrupted and what state survived
45
40
 
46
41
  See the full [Changelog](./CHANGELOG.md) for details.
47
42
 
@@ -20,6 +20,7 @@ import { parseRoadmap, parsePlan } from "./files.js";
20
20
  import { readFileSync, existsSync } from "node:fs";
21
21
  import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
22
22
  import { makeUI, GLYPH, INDENT } from "../shared/mod.js";
23
+ import { parseUnitId } from "./unit-id.js";
23
24
 
24
25
  // ─── Dashboard Data ───────────────────────────────────────────────────────────
25
26
 
@@ -372,8 +373,9 @@ export function updateProgressWidget(
372
373
  lines.push("");
373
374
 
374
375
  const isHook = unitType.startsWith("hook/");
376
+ const hookParsed = isHook ? parseUnitId(unitId) : undefined;
375
377
  const target = isHook
376
- ? (unitId.split("/").pop() ?? unitId)
378
+ ? (hookParsed!.task ?? hookParsed!.slice ?? unitId)
377
379
  : (task ? `${task.id}: ${task.title}` : unitId);
378
380
  const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
379
381
  const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
@@ -65,6 +65,28 @@ export function resetRewriteCircuitBreaker(): void {
65
65
  rewriteAttemptCount = 0;
66
66
  }
67
67
 
68
+ /**
69
+ * Guard for accessing activeSlice/activeTask in dispatch rules.
70
+ * Returns a stop action if the expected ref is null (corrupt state).
71
+ */
72
+ function requireSlice(state: GSDState): { sid: string; sTitle: string } | DispatchAction {
73
+ if (!state.activeSlice) {
74
+ return { action: "stop", reason: `Phase "${state.phase}" but no active slice — run /gsd doctor.`, level: "error" };
75
+ }
76
+ return { sid: state.activeSlice.id, sTitle: state.activeSlice.title };
77
+ }
78
+
79
+ function requireTask(state: GSDState): { sid: string; sTitle: string; tid: string; tTitle: string } | DispatchAction {
80
+ if (!state.activeSlice || !state.activeTask) {
81
+ return { action: "stop", reason: `Phase "${state.phase}" but no active slice/task — run /gsd doctor.`, level: "error" };
82
+ }
83
+ return { sid: state.activeSlice.id, sTitle: state.activeSlice.title, tid: state.activeTask.id, tTitle: state.activeTask.title };
84
+ }
85
+
86
+ function isStopAction(v: unknown): v is DispatchAction {
87
+ return typeof v === "object" && v !== null && "action" in v;
88
+ }
89
+
68
90
  // ─── Rules ────────────────────────────────────────────────────────────────
69
91
 
70
92
  const DISPATCH_RULES: DispatchRule[] = [
@@ -93,8 +115,9 @@ const DISPATCH_RULES: DispatchRule[] = [
93
115
  name: "summarizing → complete-slice",
94
116
  match: async ({ state, mid, midTitle, basePath }) => {
95
117
  if (state.phase !== "summarizing") return null;
96
- const sid = state.activeSlice!.id;
97
- const sTitle = state.activeSlice!.title;
118
+ const sliceRef = requireSlice(state);
119
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
120
+ const { sid, sTitle } = sliceRef;
98
121
  return {
99
122
  action: "dispatch",
100
123
  unitType: "complete-slice",
@@ -222,8 +245,9 @@ const DISPATCH_RULES: DispatchRule[] = [
222
245
  if (state.phase !== "planning") return null;
223
246
  // Phase skip: skip research when preference or profile says so
224
247
  if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) return null;
225
- const sid = state.activeSlice!.id;
226
- const sTitle = state.activeSlice!.title;
248
+ const sliceRef = requireSlice(state);
249
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
250
+ const { sid, sTitle } = sliceRef;
227
251
  const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
228
252
  if (researchFile) return null; // has research, fall through
229
253
  // Skip slice research for S01 when milestone research already exists —
@@ -242,8 +266,9 @@ const DISPATCH_RULES: DispatchRule[] = [
242
266
  name: "planning → plan-slice",
243
267
  match: async ({ state, mid, midTitle, basePath }) => {
244
268
  if (state.phase !== "planning") return null;
245
- const sid = state.activeSlice!.id;
246
- const sTitle = state.activeSlice!.title;
269
+ const sliceRef = requireSlice(state);
270
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
271
+ const { sid, sTitle } = sliceRef;
247
272
  return {
248
273
  action: "dispatch",
249
274
  unitType: "plan-slice",
@@ -256,8 +281,9 @@ const DISPATCH_RULES: DispatchRule[] = [
256
281
  name: "replanning-slice → replan-slice",
257
282
  match: async ({ state, mid, midTitle, basePath }) => {
258
283
  if (state.phase !== "replanning-slice") return null;
259
- const sid = state.activeSlice!.id;
260
- const sTitle = state.activeSlice!.title;
284
+ const sliceRef = requireSlice(state);
285
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
286
+ const { sid, sTitle } = sliceRef;
261
287
  return {
262
288
  action: "dispatch",
263
289
  unitType: "replan-slice",
@@ -270,8 +296,9 @@ const DISPATCH_RULES: DispatchRule[] = [
270
296
  name: "executing → execute-task (recover missing task plan → plan-slice)",
271
297
  match: async ({ state, mid, midTitle, basePath }) => {
272
298
  if (state.phase !== "executing" || !state.activeTask) return null;
273
- const sid = state.activeSlice!.id;
274
- const sTitle = state.activeSlice!.title;
299
+ const sliceRef = requireSlice(state);
300
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
301
+ const { sid, sTitle } = sliceRef;
275
302
  const tid = state.activeTask.id;
276
303
 
277
304
  // Guard: if the slice plan exists but the individual task plan files are
@@ -296,8 +323,9 @@ const DISPATCH_RULES: DispatchRule[] = [
296
323
  name: "executing → execute-task",
297
324
  match: async ({ state, mid, basePath }) => {
298
325
  if (state.phase !== "executing" || !state.activeTask) return null;
299
- const sid = state.activeSlice!.id;
300
- const sTitle = state.activeSlice!.title;
326
+ const sliceRef = requireSlice(state);
327
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
328
+ const { sid, sTitle } = sliceRef;
301
329
  const tid = state.activeTask.id;
302
330
  const tTitle = state.activeTask.title;
303
331
 
@@ -18,6 +18,7 @@ import {
18
18
  import { resolveMilestoneFile } from "./paths.js";
19
19
  import { MAX_CONSECUTIVE_SKIPS, MAX_LIFETIME_DISPATCHES } from "./auto/session.js";
20
20
  import type { AutoSession } from "./auto/session.js";
21
+ import { parseUnitId } from "./unit-id.js";
21
22
 
22
23
  export interface IdempotencyContext {
23
24
  s: AutoSession;
@@ -54,7 +55,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult {
54
55
  s.unitConsecutiveSkips.set(idempotencyKey, skipCount);
55
56
  if (skipCount > MAX_CONSECUTIVE_SKIPS) {
56
57
  // Cross-check: verify the unit's milestone is still active (#790)
57
- const skippedMid = unitId.split("/")[0];
58
+ const skippedMid = parseUnitId(unitId).milestone;
58
59
  const skippedMilestoneComplete = skippedMid
59
60
  ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
60
61
  : false;
@@ -110,7 +111,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult {
110
111
  const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
111
112
  s.unitConsecutiveSkips.set(idempotencyKey, skipCount2);
112
113
  if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
113
- const skippedMid2 = unitId.split("/")[0];
114
+ const skippedMid2 = parseUnitId(unitId).milestone;
114
115
  const skippedMilestoneComplete2 = skippedMid2
115
116
  ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
116
117
  : false;
@@ -12,6 +12,7 @@ import {
12
12
  formatValidationIssues,
13
13
  } from "./observability-validator.js";
14
14
  import type { ValidationIssue } from "./observability-validator.js";
15
+ import { parseUnitId } from "./unit-id.js";
15
16
 
16
17
  export async function collectObservabilityWarnings(
17
18
  ctx: ExtensionContext,
@@ -22,10 +23,7 @@ export async function collectObservabilityWarnings(
22
23
  // Hook units have custom artifacts — skip standard observability checks
23
24
  if (unitType.startsWith("hook/")) return [];
24
25
 
25
- const parts = unitId.split("/");
26
- const mid = parts[0];
27
- const sid = parts[1];
28
- const tid = parts[2];
26
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
29
27
 
30
28
  if (!mid || !sid) return [];
31
29
 
@@ -61,6 +61,7 @@ import {
61
61
  } from "./auto-dashboard.js";
62
62
  import { join } from "node:path";
63
63
  import { STATE_REBUILD_MIN_INTERVAL_MS } from "./auto-constants.js";
64
+ import { parseUnitId } from "./unit-id.js";
64
65
 
65
66
  /**
66
67
  * Initialize a unit dispatch: stamp the current time, set `s.currentUnit`,
@@ -134,8 +135,7 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
134
135
  let taskContext: TaskCommitContext | undefined;
135
136
 
136
137
  if (s.currentUnit.type === "execute-task") {
137
- const parts = s.currentUnit.id.split("/");
138
- const [mid, sid, tid] = parts;
138
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
139
139
  if (mid && sid && tid) {
140
140
  const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY");
141
141
  if (summaryPath) {
@@ -167,8 +167,8 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
167
167
 
168
168
  // Doctor: fix mechanical bookkeeping
169
169
  try {
170
- const scopeParts = s.currentUnit.id.split("/").slice(0, 2);
171
- const doctorScope = scopeParts.join("/");
170
+ const { milestone, slice } = parseUnitId(s.currentUnit.id);
171
+ const doctorScope = slice ? `${milestone}/${slice}` : milestone;
172
172
  const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]);
173
173
  const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const;
174
174
  const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
@@ -348,7 +348,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
348
348
  // instead of dispatching LLM sessions for complete-slice / validate-milestone.
349
349
  if (s.currentUnit?.type === "execute-task" && !s.stepMode) {
350
350
  try {
351
- const [mid, sid] = s.currentUnit.id.split("/");
351
+ const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id);
352
352
  if (mid && sid) {
353
353
  const state = await deriveState(s.basePath);
354
354
  if (state.phase === "summarizing" && state.activeSlice?.id === sid) {
@@ -42,6 +42,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "
42
42
  import { atomicWriteSync } from "./atomic-write.js";
43
43
  import { loadJsonFileOrNull } from "./json-persistence.js";
44
44
  import { dirname, join } from "node:path";
45
+ import { parseUnitId } from "./unit-id.js";
45
46
 
46
47
  // ─── Artifact Resolution & Verification ───────────────────────────────────────
47
48
 
@@ -49,9 +50,7 @@ import { dirname, join } from "node:path";
49
50
  * Resolve the expected artifact for a unit to an absolute path.
50
51
  */
51
52
  export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
52
- const parts = unitId.split("/");
53
- const mid = parts[0]!;
54
- const sid = parts[1];
53
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
55
54
  switch (unitType) {
56
55
  case "research-milestone": {
57
56
  const dir = resolveMilestonePath(base, mid);
@@ -78,7 +77,6 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
78
77
  return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
79
78
  }
80
79
  case "execute-task": {
81
- const tid = parts[2];
82
80
  const dir = resolveSlicePath(base, mid, sid!);
83
81
  return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
84
82
  }
@@ -167,10 +165,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
167
165
 
168
166
  // execute-task must also have its checkbox marked [x] in the slice plan
169
167
  if (unitType === "execute-task") {
170
- const parts = unitId.split("/");
171
- const mid = parts[0];
172
- const sid = parts[1];
173
- const tid = parts[2];
168
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
174
169
  if (mid && sid && tid) {
175
170
  const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
176
171
  if (planAbs && existsSync(planAbs)) {
@@ -187,9 +182,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
187
182
  // but omitted T{tid}-PLAN.md files would be marked complete, causing execute-task
188
183
  // to dispatch with a missing task plan (see issue #739).
189
184
  if (unitType === "plan-slice") {
190
- const parts = unitId.split("/");
191
- const mid = parts[0];
192
- const sid = parts[1];
185
+ const { milestone: mid, slice: sid } = parseUnitId(unitId);
193
186
  if (mid && sid) {
194
187
  try {
195
188
  const planContent = readFileSync(absPath, "utf-8");
@@ -213,9 +206,8 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
213
206
  // state machine keeps returning the same complete-slice unit (roadmap still shows
214
207
  // the slice incomplete), so dispatchNextUnit recurses forever.
215
208
  if (unitType === "complete-slice") {
216
- const parts = unitId.split("/");
217
- const mid = parts[0];
218
- const sid = parts[1];
209
+ const { milestone: mid, slice: sid } = parseUnitId(unitId);
210
+
219
211
  if (mid && sid) {
220
212
  const dir = resolveSlicePath(base, mid, sid);
221
213
  if (dir) {
@@ -268,9 +260,7 @@ export function writeBlockerPlaceholder(unitType: string, unitId: string, base:
268
260
  }
269
261
 
270
262
  export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
271
- const parts = unitId.split("/");
272
- const mid = parts[0];
273
- const sid = parts[1];
263
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
274
264
  switch (unitType) {
275
265
  case "research-milestone":
276
266
  return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`;
@@ -281,7 +271,6 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base:
281
271
  case "plan-slice":
282
272
  return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`;
283
273
  case "execute-task": {
284
- const tid = parts[2];
285
274
  return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`;
286
275
  }
287
276
  case "complete-slice":
@@ -539,10 +528,7 @@ export async function selfHealRuntimeRecords(
539
528
  * These are shown when automatic reconciliation is not possible.
540
529
  */
541
530
  export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null {
542
- const parts = unitId.split("/");
543
- const mid = parts[0];
544
- const sid = parts[1];
545
- const tid = parts[2];
531
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
546
532
  switch (unitType) {
547
533
  case "execute-task": {
548
534
  if (!mid || !sid || !tid) break;
@@ -64,6 +64,7 @@ import type { AutoSession } from "./auto/session.js";
64
64
  import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
65
65
  import { join } from "node:path";
66
66
  import { getErrorMessage } from "./error-utils.js";
67
+ import { parseUnitId } from "./unit-id.js";
67
68
 
68
69
  export interface BootstrapDeps {
69
70
  shouldUseWorktreeIsolation: () => boolean;
@@ -139,7 +140,7 @@ export async function bootstrapAutoSession(
139
140
  if (crashLock && crashLock.pid !== process.pid) {
140
141
  // We already hold the session lock, so no concurrent session is running.
141
142
  // The crash lock is from a dead process — recover context from it.
142
- const recoveredMid = crashLock.unitId.split("/")[0];
143
+ const recoveredMid = parseUnitId(crashLock.unitId).milestone;
143
144
  const milestoneAlreadyComplete = recoveredMid
144
145
  ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY")
145
146
  : false;
@@ -39,6 +39,7 @@ import {
39
39
  import type { AutoSession } from "./auto/session.js";
40
40
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
41
41
  import { join } from "node:path";
42
+ import { parseUnitId } from "./unit-id.js";
42
43
 
43
44
  export interface StuckContext {
44
45
  s: AutoSession;
@@ -99,7 +100,7 @@ export async function checkStuckAndRecover(sctx: StuckContext): Promise<StuckRes
99
100
 
100
101
  // Final reconciliation pass for execute-task
101
102
  if (unitType === "execute-task") {
102
- const [mid, sid, tid] = unitId.split("/");
103
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
103
104
  if (mid && sid && tid) {
104
105
  const status = await inspectExecuteTaskDurability(basePath, unitId);
105
106
  if (status) {
@@ -168,7 +169,7 @@ export async function checkStuckAndRecover(sctx: StuckContext): Promise<StuckRes
168
169
  // Adaptive self-repair: each retry attempts a different remediation step.
169
170
  if (unitType === "execute-task") {
170
171
  const status = await inspectExecuteTaskDurability(basePath, unitId);
171
- const [mid, sid, tid] = unitId.split("/");
172
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
172
173
  if (status && mid && sid && tid) {
173
174
  if (status.summaryExists && !status.taskChecked) {
174
175
  const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Auto-mode Supervisor — SIGTERM handling and working-tree activity detection.
2
+ * Auto-mode Supervisor — signal handling and working-tree activity detection.
3
3
  *
4
4
  * Pure functions — no module-level globals or AutoContext dependency.
5
5
  */
@@ -8,10 +8,10 @@ import { clearLock } from "./crash-recovery.js";
8
8
  import { releaseSessionLock } from "./session-lock.js";
9
9
  import { nativeHasChanges } from "./native-git-bridge.js";
10
10
 
11
- // ─── SIGTERM Handling ─────────────────────────────────────────────────────────
11
+ // ─── Signal Handling ──────────────────────────────────────────────────────────
12
12
 
13
13
  /**
14
- * Register a SIGTERM handler that clears the lock file and exits cleanly.
14
+ * Register SIGTERM and SIGINT handlers that clear lock files and exit cleanly.
15
15
  * Captures the active base path at registration time so the handler
16
16
  * always references the correct path even if the module variable changes.
17
17
  * Removes any previously registered handler before installing the new one.
@@ -22,20 +22,25 @@ export function registerSigtermHandler(
22
22
  currentBasePath: string,
23
23
  previousHandler: (() => void) | null,
24
24
  ): () => void {
25
- if (previousHandler) process.off("SIGTERM", previousHandler);
25
+ if (previousHandler) {
26
+ process.off("SIGTERM", previousHandler);
27
+ process.off("SIGINT", previousHandler);
28
+ }
26
29
  const handler = () => {
27
30
  releaseSessionLock(currentBasePath);
28
31
  clearLock(currentBasePath);
29
32
  process.exit(0);
30
33
  };
31
34
  process.on("SIGTERM", handler);
35
+ process.on("SIGINT", handler);
32
36
  return handler;
33
37
  }
34
38
 
35
- /** Deregister the SIGTERM handler (called on stop/pause). */
39
+ /** Deregister signal handlers (called on stop/pause). */
36
40
  export function deregisterSigtermHandler(handler: (() => void) | null): void {
37
41
  if (handler) {
38
42
  process.off("SIGTERM", handler);
43
+ process.off("SIGINT", handler);
39
44
  }
40
45
  }
41
46
 
@@ -18,6 +18,7 @@ import {
18
18
  writeBlockerPlaceholder,
19
19
  } from "./auto-recovery.js";
20
20
  import { existsSync } from "node:fs";
21
+ import { parseUnitId } from "./unit-id.js";
21
22
 
22
23
  export interface RecoveryContext {
23
24
  basePath: string;
@@ -128,7 +129,7 @@ export async function recoverTimedOutUnit(
128
129
 
129
130
  // Retries exhausted — write missing durable artifacts and advance.
130
131
  const diagnostic = formatExecuteTaskRecoveryStatus(status);
131
- const [mid, sid, tid] = unitId.split("/");
132
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
132
133
  const skipped = mid && sid && tid
133
134
  ? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
134
135
  : false;
@@ -25,6 +25,7 @@ import { removePersistedKey } from "./auto-recovery.js";
25
25
  import type { AutoSession, PendingVerificationRetry } from "./auto/session.js";
26
26
  import { join } from "node:path";
27
27
  import { getErrorMessage } from "./error-utils.js";
28
+ import { parseUnitId } from "./unit-id.js";
28
29
 
29
30
  export interface VerificationContext {
30
31
  s: AutoSession;
@@ -58,10 +59,9 @@ export async function runPostUnitVerification(
58
59
  const prefs = effectivePrefs?.preferences;
59
60
 
60
61
  // Read task plan verify field
61
- const parts = s.currentUnit.id.split("/");
62
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
62
63
  let taskPlanVerify: string | undefined;
63
- if (parts.length >= 3) {
64
- const [mid, sid, tid] = parts;
64
+ if (mid && sid && tid) {
65
65
  const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN");
66
66
  if (planFile) {
67
67
  const planContent = await loadFile(planFile);
@@ -153,9 +153,8 @@ export async function runPostUnitVerification(
153
153
 
154
154
  // Write verification evidence JSON
155
155
  const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0;
156
- if (parts.length >= 3) {
156
+ if (mid && sid && tid) {
157
157
  try {
158
- const [mid, sid, tid] = parts;
159
158
  const sDir = resolveSlicePath(s.basePath, mid, sid);
160
159
  if (sDir) {
161
160
  const tasksDir = join(sDir, "tasks");
@@ -6,7 +6,7 @@
6
6
  * manages create, enter, detect, and teardown for auto-mode worktrees.
7
7
  */
8
8
 
9
- import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync } from "node:fs";
9
+ import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync, readdirSync, cpSync, lstatSync as lstatSyncFn } from "node:fs";
10
10
  import { isAbsolute, join, sep } from "node:path";
11
11
  import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
12
12
  import { execSync, execFileSync } from "node:child_process";
@@ -45,6 +45,122 @@ import { getErrorMessage } from "./error-utils.js";
45
45
  /** Original project root before chdir into auto-worktree. */
46
46
  let originalBase: string | null = null;
47
47
 
48
+ // ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
49
+
50
+ /**
51
+ * Sync .gsd/ state from the main repo into the worktree.
52
+ *
53
+ * When .gsd/ is a symlink to the external state directory, both the main
54
+ * repo and worktree share the same directory — no sync needed.
55
+ *
56
+ * When .gsd/ is a real directory (e.g., git-tracked or manage_gitignore:false),
57
+ * the worktree has its own copy that may be stale. This function copies
58
+ * missing milestones, CONTEXT, ROADMAP, DECISIONS, REQUIREMENTS, and
59
+ * PROJECT files from the main repo's .gsd/ into the worktree's .gsd/.
60
+ *
61
+ * Only adds missing content — never overwrites existing files in the worktree
62
+ * (the worktree's execution state is authoritative for in-progress work).
63
+ */
64
+ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: string): { synced: string[] } {
65
+ const mainGsd = gsdRoot(mainBasePath);
66
+ const wtGsd = gsdRoot(worktreePath_);
67
+ const synced: string[] = [];
68
+
69
+ // If both resolve to the same directory (symlink), no sync needed
70
+ try {
71
+ const mainResolved = realpathSync(mainGsd);
72
+ const wtResolved = realpathSync(wtGsd);
73
+ if (mainResolved === wtResolved) return { synced };
74
+ } catch {
75
+ // Can't resolve — proceed with sync as a safety measure
76
+ }
77
+
78
+ if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
79
+
80
+ // Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE)
81
+ const rootFiles = ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md", "OVERRIDES.md"];
82
+ for (const f of rootFiles) {
83
+ const src = join(mainGsd, f);
84
+ const dst = join(wtGsd, f);
85
+ if (existsSync(src) && !existsSync(dst)) {
86
+ try {
87
+ cpSync(src, dst);
88
+ synced.push(f);
89
+ } catch { /* non-fatal */ }
90
+ }
91
+ }
92
+
93
+ // Sync milestones: copy entire milestone directories that are missing
94
+ const mainMilestonesDir = join(mainGsd, "milestones");
95
+ const wtMilestonesDir = join(wtGsd, "milestones");
96
+ if (existsSync(mainMilestonesDir) && existsSync(wtMilestonesDir)) {
97
+ try {
98
+ const mainMilestones = readdirSync(mainMilestonesDir, { withFileTypes: true })
99
+ .filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
100
+ .map(d => d.name);
101
+
102
+ for (const mid of mainMilestones) {
103
+ const srcDir = join(mainMilestonesDir, mid);
104
+ const dstDir = join(wtMilestonesDir, mid);
105
+
106
+ if (!existsSync(dstDir)) {
107
+ // Entire milestone missing from worktree — copy it
108
+ try {
109
+ cpSync(srcDir, dstDir, { recursive: true });
110
+ synced.push(`milestones/${mid}/`);
111
+ } catch { /* non-fatal */ }
112
+ } else {
113
+ // Milestone directory exists but may be missing files (stale snapshot).
114
+ // Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.)
115
+ try {
116
+ const srcFiles = readdirSync(srcDir).filter(f => f.endsWith(".md") || f.endsWith(".json"));
117
+ for (const f of srcFiles) {
118
+ const srcFile = join(srcDir, f);
119
+ const dstFile = join(dstDir, f);
120
+ if (!existsSync(dstFile)) {
121
+ try {
122
+ const srcStat = lstatSyncFn(srcFile);
123
+ if (srcStat.isFile()) {
124
+ cpSync(srcFile, dstFile);
125
+ synced.push(`milestones/${mid}/${f}`);
126
+ }
127
+ } catch { /* non-fatal */ }
128
+ }
129
+ }
130
+
131
+ // Sync slices directory if it exists in main but not in worktree
132
+ const srcSlicesDir = join(srcDir, "slices");
133
+ const dstSlicesDir = join(dstDir, "slices");
134
+ if (existsSync(srcSlicesDir) && !existsSync(dstSlicesDir)) {
135
+ try {
136
+ cpSync(srcSlicesDir, dstSlicesDir, { recursive: true });
137
+ synced.push(`milestones/${mid}/slices/`);
138
+ } catch { /* non-fatal */ }
139
+ } else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) {
140
+ // Both exist — sync missing slice directories
141
+ const srcSlices = readdirSync(srcSlicesDir, { withFileTypes: true })
142
+ .filter(d => d.isDirectory())
143
+ .map(d => d.name);
144
+ for (const sid of srcSlices) {
145
+ const srcSlice = join(srcSlicesDir, sid);
146
+ const dstSlice = join(dstSlicesDir, sid);
147
+ if (!existsSync(dstSlice)) {
148
+ try {
149
+ cpSync(srcSlice, dstSlice, { recursive: true });
150
+ synced.push(`milestones/${mid}/slices/${sid}/`);
151
+ } catch { /* non-fatal */ }
152
+ }
153
+ }
154
+ }
155
+ } catch { /* non-fatal */ }
156
+ }
157
+ }
158
+ } catch { /* non-fatal */ }
159
+ }
160
+
161
+ return { synced };
162
+ }
163
+
48
164
  // ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
49
165
 
50
166
  /**
@@ -125,6 +241,12 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
125
241
  // Ensure worktree shares external state via symlink
126
242
  ensureGsdSymlink(info.path);
127
243
 
244
+ // Sync .gsd/ state from main repo into the worktree (#1311).
245
+ // Even with the symlink, the worktree may have stale git-tracked files
246
+ // if .gsd/ is not gitignored. And on fresh create, the milestone files
247
+ // created on main since the branch point won't be in the worktree.
248
+ syncGsdStateToWorktree(basePath, info.path);
249
+
128
250
  // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
129
251
  const hookError = runWorktreePostCreateHook(basePath, info.path);
130
252
  if (hookError) {
@@ -267,6 +389,18 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string
267
389
  throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`);
268
390
  }
269
391
 
392
+ // Ensure worktree shares external state via symlink (#1311).
393
+ // On resume (enterAutoWorktree), the symlink may be missing if it was
394
+ // created before ensureGsdSymlink existed, or the .gsd/ directory may be
395
+ // a stale git-tracked copy instead of a symlink. Refreshing here ensures
396
+ // the worktree sees the same milestone state as the main repo.
397
+ ensureGsdSymlink(p);
398
+
399
+ // Sync .gsd/ state from main repo into worktree (#1311).
400
+ // Covers the case where .gsd/ is a real directory (not symlinked) and
401
+ // milestones were created on main after the worktree was last used.
402
+ syncGsdStateToWorktree(basePath, p);
403
+
270
404
  const previousCwd = process.cwd();
271
405
 
272
406
  try {