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.
- package/README.md +13 -18
- package/dist/resources/extensions/gsd/auto-dashboard.ts +3 -1
- package/dist/resources/extensions/gsd/auto-dispatch.ts +40 -12
- package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
- package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
- package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
- package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
- package/dist/resources/extensions/gsd/auto-start.ts +2 -1
- package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
- package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
- package/dist/resources/extensions/gsd/auto-verification.ts +4 -5
- package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/dist/resources/extensions/gsd/auto.ts +89 -164
- package/dist/resources/extensions/gsd/commands.ts +14 -2
- package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
- package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
- package/dist/resources/extensions/gsd/metrics.ts +3 -3
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
- package/dist/resources/extensions/gsd/session-lock.ts +80 -16
- package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
- package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
- package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/dist/resources/extensions/gsd/undo.ts +5 -7
- package/dist/resources/extensions/gsd/unit-id.ts +14 -0
- package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
- package/package.json +3 -2
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
- package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
- package/src/resources/extensions/gsd/auto-observability.ts +2 -4
- package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
- package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
- package/src/resources/extensions/gsd/auto-start.ts +2 -1
- package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
- package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
- package/src/resources/extensions/gsd/auto-verification.ts +4 -5
- package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/src/resources/extensions/gsd/auto.ts +89 -164
- package/src/resources/extensions/gsd/commands.ts +14 -2
- package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
- package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
- package/src/resources/extensions/gsd/metrics.ts +3 -3
- package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
- package/src/resources/extensions/gsd/session-lock.ts +80 -16
- package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
- package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/src/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
- package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/src/resources/extensions/gsd/undo.ts +5 -7
- package/src/resources/extensions/gsd/unit-id.ts +14 -0
- package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
- package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
- 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.
|
|
28
|
-
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
-
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
39
|
-
- **
|
|
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
|
-
? (
|
|
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
|
|
97
|
-
|
|
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
|
|
226
|
-
|
|
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
|
|
246
|
-
|
|
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
|
|
260
|
-
|
|
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
|
|
274
|
-
|
|
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
|
|
300
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
171
|
-
const doctorScope =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
217
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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 —
|
|
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
|
-
// ───
|
|
11
|
+
// ─── Signal Handling ──────────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Register
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
62
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
|
|
62
63
|
let taskPlanVerify: string | undefined;
|
|
63
|
-
if (
|
|
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 (
|
|
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 {
|