gsd-pi 2.80.0-dev.e51d2c88c → 2.80.0-dev.e6c48c3af

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 (83) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/phases.js +30 -6
  3. package/dist/resources/extensions/gsd/auto/run-unit.js +4 -1
  4. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +1 -1
  5. package/dist/resources/extensions/gsd/auto.js +18 -1
  6. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +54 -4
  7. package/dist/resources/extensions/gsd/clean-root-preflight.js +24 -6
  8. package/dist/resources/extensions/gsd/git-service.js +36 -4
  9. package/dist/resources/extensions/gsd/pre-execution-checks.js +7 -0
  10. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
  11. package/dist/resources/extensions/gsd/worktree-resolver.js +33 -17
  12. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  13. package/dist/web/standalone/.next/BUILD_ID +1 -1
  14. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  15. package/dist/web/standalone/.next/build-manifest.json +2 -2
  16. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  17. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.html +1 -1
  34. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  41. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  42. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  43. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  44. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  45. package/package.json +1 -1
  46. package/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js +15 -0
  47. package/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js.map +1 -1
  48. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +2 -0
  49. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  50. package/packages/pi-coding-agent/dist/core/agent-session.js +4 -3
  51. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  52. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +2 -0
  53. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  55. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
  56. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  57. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  58. package/packages/pi-coding-agent/src/core/agent-session-tool-refresh.test.ts +18 -0
  59. package/packages/pi-coding-agent/src/core/agent-session.ts +6 -3
  60. package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -0
  61. package/packages/pi-coding-agent/src/core/extensions/types.ts +5 -0
  62. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  63. package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -2
  64. package/src/resources/extensions/gsd/auto/phases.ts +50 -15
  65. package/src/resources/extensions/gsd/auto/run-unit.ts +4 -1
  66. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +1 -1
  67. package/src/resources/extensions/gsd/auto.ts +18 -1
  68. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +66 -4
  69. package/src/resources/extensions/gsd/clean-root-preflight.ts +32 -7
  70. package/src/resources/extensions/gsd/git-service.ts +46 -8
  71. package/src/resources/extensions/gsd/pre-execution-checks.ts +7 -0
  72. package/src/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
  73. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +144 -1
  74. package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +166 -4
  75. package/src/resources/extensions/gsd/tests/clean-root-preflight.test.ts +15 -6
  76. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +5 -1
  77. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +54 -0
  78. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +5 -1
  79. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +38 -0
  80. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +63 -1
  81. package/src/resources/extensions/gsd/worktree-resolver.ts +36 -15
  82. /package/dist/web/standalone/.next/static/{8F5YpnZNBaooIWGF4GBV3 → 4dQ9NTZJ8pEvFwBgDUX93}/_buildManifest.js +0 -0
  83. /package/dist/web/standalone/.next/static/{8F5YpnZNBaooIWGF4GBV3 → 4dQ9NTZJ8pEvFwBgDUX93}/_ssgManifest.js +0 -0
@@ -23,7 +23,7 @@ import type { CmuxLogLevel } from "../../shared/cmux-events.js";
23
23
  import type { JournalEntry } from "../journal.js";
24
24
  import type { MergeReconcileResult } from "../auto-recovery.js";
25
25
  import type { UokTurnObserver } from "../uok/contracts.js";
26
- import type { PreflightResult } from "../clean-root-preflight.js";
26
+ import type { PostflightResult, PreflightResult } from "../clean-root-preflight.js";
27
27
 
28
28
  type PauseAutoFn = (
29
29
  ctx?: ExtensionContext,
@@ -141,7 +141,7 @@ export interface LoopDeps {
141
141
  milestoneId: string,
142
142
  stashMarker: string | undefined,
143
143
  notify: (message: string, level: "info" | "warning" | "error") => void,
144
- ) => void;
144
+ ) => PostflightResult;
145
145
 
146
146
  // Budget/context/secrets
147
147
  getLedger: () => unknown;
@@ -57,6 +57,7 @@ import { getEligibleSlices } from "../slice-parallel-eligibility.js";
57
57
  import { startSliceParallel } from "../slice-parallel-orchestrator.js";
58
58
  import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js";
59
59
  import type { MinimalModelRegistry } from "../context-budget.js";
60
+ import type { PostflightResult, PreflightResult } from "../clean-root-preflight.js";
60
61
  import { ensurePlanV2Graph, isEmptyPlanV2GraphResult, isMissingFinalizedContextResult } from "../uok/plan-v2.js";
61
62
  import { resolveUokFlags } from "../uok/flags.js";
62
63
  import { UokGateRunner } from "../uok/gate-runner.js";
@@ -209,6 +210,38 @@ async function closeoutAndStop(
209
210
  await deps.stopAuto(ctx, pi, reason);
210
211
  }
211
212
 
213
+ async function stopOnPostflightRecoveryNeeded(
214
+ ic: IterationContext,
215
+ result: PostflightResult,
216
+ milestoneId: string,
217
+ ): Promise<{ action: "break"; reason: string } | null> {
218
+ if (!result.needsManualRecovery) return null;
219
+ const { ctx, pi, deps } = ic;
220
+ const reason = `Post-merge stash restore failed for milestone ${milestoneId}`;
221
+ ctx.ui.notify(
222
+ `${reason}. Resolve the working tree before resuming auto-mode. ${result.message}`,
223
+ "error",
224
+ );
225
+ await deps.stopAuto(ctx, pi, reason);
226
+ return { action: "break", reason: "postflight-stash-restore-failed" };
227
+ }
228
+
229
+ async function restorePreflightStashOrStop(
230
+ ic: IterationContext,
231
+ preflight: PreflightResult,
232
+ milestoneId: string,
233
+ ): Promise<{ action: "break"; reason: string } | null> {
234
+ if (!preflight.stashPushed) return null;
235
+ const { ctx, s, deps } = ic;
236
+ const result = deps.postflightPopStash(
237
+ s.originalBasePath || s.basePath,
238
+ milestoneId,
239
+ preflight.stashMarker,
240
+ ctx.ui.notify.bind(ctx.ui),
241
+ );
242
+ return stopOnPostflightRecoveryNeeded(ic, result, milestoneId);
243
+ }
244
+
212
245
  async function emitCancelledUnitEnd(
213
246
  ic: IterationContext,
214
247
  unitType: string,
@@ -654,6 +687,8 @@ export async function runPreDispatch(
654
687
  );
655
688
  try {
656
689
  deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
690
+ // Prevent stopAuto() from attempting the same merge again if postflight recovery stops here.
691
+ s.milestoneMergedInPhases = true;
657
692
  } catch (mergeErr) {
658
693
  if (mergeErr instanceof MergeConflictError) {
659
694
  // Real code conflicts — stop the loop instead of retrying forever (#2330)
@@ -674,13 +709,13 @@ export async function runPreDispatch(
674
709
  return { action: "break", reason: "merge-failed" };
675
710
  }
676
711
  // #2909: postflight — restore stashed changes after successful merge
677
- if (preflightTransition.stashPushed) {
678
- deps.postflightPopStash(
679
- s.originalBasePath || s.basePath,
712
+ {
713
+ const postflightStop = await restorePreflightStashOrStop(
714
+ ic,
715
+ preflightTransition,
680
716
  s.currentMilestoneId!,
681
- preflightTransition.stashMarker,
682
- ctx.ui.notify.bind(ctx.ui),
683
717
  );
718
+ if (postflightStop) return postflightStop;
684
719
  }
685
720
 
686
721
  // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
@@ -788,13 +823,13 @@ export async function runPreDispatch(
788
823
  return { action: "break", reason: "merge-failed" };
789
824
  }
790
825
  // #2909: postflight — restore stashed changes after successful merge
791
- if (preflightAllComplete.stashPushed) {
792
- deps.postflightPopStash(
793
- s.originalBasePath || s.basePath,
826
+ {
827
+ const postflightStop = await restorePreflightStashOrStop(
828
+ ic,
829
+ preflightAllComplete,
794
830
  s.currentMilestoneId,
795
- preflightAllComplete.stashMarker,
796
- ctx.ui.notify.bind(ctx.ui),
797
831
  );
832
+ if (postflightStop) return postflightStop;
798
833
  }
799
834
 
800
835
  // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
@@ -917,13 +952,13 @@ export async function runPreDispatch(
917
952
  return { action: "break", reason: "merge-failed" };
918
953
  }
919
954
  // #2909: postflight — restore stashed changes after successful merge
920
- if (preflightComplete.stashPushed) {
921
- deps.postflightPopStash(
922
- s.originalBasePath || s.basePath,
955
+ {
956
+ const postflightStop = await restorePreflightStashOrStop(
957
+ ic,
958
+ preflightComplete,
923
959
  s.currentMilestoneId,
924
- preflightComplete.stashMarker,
925
- ctx.ui.notify.bind(ctx.ui),
926
960
  );
961
+ if (postflightStop) return postflightStop;
927
962
  }
928
963
 
929
964
  // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
@@ -85,7 +85,10 @@ export async function runUnit(
85
85
  const sessionAbortController = new AbortController();
86
86
  _setSessionSwitchInFlight(true);
87
87
  try {
88
- const sessionPromise = s.cmdCtx!.newSession({ abortSignal: sessionAbortController.signal }).finally(() => {
88
+ const sessionPromise = s.cmdCtx!.newSession({
89
+ abortSignal: sessionAbortController.signal,
90
+ cwd: s.basePath,
91
+ }).finally(() => {
89
92
  if (sessionSwitchGeneration === mySessionSwitchGeneration) {
90
93
  _setSessionSwitchInFlight(false);
91
94
  }
@@ -306,7 +306,7 @@ export async function dispatchDirectPhase(
306
306
  return;
307
307
  }
308
308
 
309
- const result = await ctx.newSession();
309
+ const result = await ctx.newSession({ cwd: dispatchBase });
310
310
  if (result.cancelled) {
311
311
  ctx.ui.notify("Session creation cancelled.", "warning");
312
312
  return;
@@ -1034,6 +1034,8 @@ export async function stopAuto(
1034
1034
  if (s.workerId) {
1035
1035
  markWorkerStopping(s.workerId);
1036
1036
  }
1037
+ s.workerId = null;
1038
+ s.milestoneLeaseToken = null;
1037
1039
  } catch (e) {
1038
1040
  debugLog("stop-cleanup-coordination", { error: e instanceof Error ? e.message : String(e) });
1039
1041
  }
@@ -1171,6 +1173,21 @@ export async function stopAuto(
1171
1173
  debugLog("stop-cleanup-basepath", { error: e instanceof Error ? e.message : String(e) });
1172
1174
  }
1173
1175
 
1176
+ // Re-root the active command session/tool runtime after worktree teardown.
1177
+ // mergeAndExit restores process.cwd(), but AgentSession has already captured
1178
+ // its own cwd for tools and system prompt; refresh it before returning to the
1179
+ // user so follow-up commands do not target a removed milestone worktree.
1180
+ if (s.originalBasePath && ctx && s.cmdCtx) {
1181
+ try {
1182
+ const result = await s.cmdCtx.newSession({ cwd: s.basePath });
1183
+ if (result.cancelled) {
1184
+ logWarning("engine", "post-stop session re-root was cancelled", { file: "auto.ts", basePath: s.basePath });
1185
+ }
1186
+ } catch (err) {
1187
+ logWarning("engine", `post-stop session re-root failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts", basePath: s.basePath });
1188
+ }
1189
+ }
1190
+
1174
1191
  // ── Step 8: Ledger notification ──
1175
1192
  try {
1176
1193
  const ledger = getLedger();
@@ -2316,7 +2333,7 @@ export async function dispatchHookUnit(
2316
2333
  return false;
2317
2334
  }
2318
2335
 
2319
- const result = await s.cmdCtx!.newSession();
2336
+ const result = await s.cmdCtx!.newSession({ cwd: s.basePath });
2320
2337
  if (result.cancelled) {
2321
2338
  await stopAuto(ctx, pi);
2322
2339
  return false;
@@ -31,6 +31,13 @@ import { approvalGateIdForUnit, isExplicitApprovalResponse, shouldPauseForUserAp
31
31
  let isFirstSession = true;
32
32
  let approvalQuestionAbortInFlight = false;
33
33
 
34
+ interface DeferredApprovalGate {
35
+ gateId: string;
36
+ basePath: string;
37
+ }
38
+
39
+ let deferredApprovalGate: DeferredApprovalGate | null = null;
40
+
34
41
  async function deriveGsdState(basePath: string) {
35
42
  const { deriveState } = await import("../state.js");
36
43
  return deriveState(basePath);
@@ -85,6 +92,46 @@ async function applyCompactionThresholdOverride(ctx: ExtensionContext): Promise<
85
92
  }
86
93
  }
87
94
 
95
+ function clearDeferredApprovalGate(basePath?: string): void {
96
+ if (!basePath || deferredApprovalGate?.basePath === basePath) {
97
+ deferredApprovalGate = null;
98
+ }
99
+ }
100
+
101
+ function deferApprovalGate(gateId: string, basePath: string): void {
102
+ deferredApprovalGate = { gateId, basePath };
103
+ }
104
+
105
+ function activateDeferredApprovalGate(basePath: string): void {
106
+ if (deferredApprovalGate?.basePath !== basePath) return;
107
+ setPendingGate(deferredApprovalGate.gateId, basePath);
108
+ deferredApprovalGate = null;
109
+ }
110
+
111
+ function isContextDraftSummarySave(toolName: string, input: unknown): boolean {
112
+ if (toolName !== "gsd_summary_save" && toolName !== "summary_save") return false;
113
+ if (!input || typeof input !== "object") return false;
114
+ return (input as { artifact_type?: unknown }).artifact_type === "CONTEXT-DRAFT";
115
+ }
116
+
117
+ function shouldBlockDeferredApprovalTool(
118
+ toolName: string,
119
+ input: unknown,
120
+ basePath: string,
121
+ ): { block: boolean; reason?: string } {
122
+ if (deferredApprovalGate?.basePath !== basePath) return { block: false };
123
+ if (toolName === "ask_user_questions") return { block: false };
124
+ if (isContextDraftSummarySave(toolName, input)) return { block: false };
125
+ return {
126
+ block: true,
127
+ reason: [
128
+ `HARD BLOCK: Approval question "${deferredApprovalGate.gateId}" has been shown to the user.`,
129
+ `Only CONTEXT-DRAFT persistence may finish in this same assistant turn.`,
130
+ `Wait for the user's answer before calling additional tools.`,
131
+ ].join(" "),
132
+ };
133
+ }
134
+
88
135
  export function resolveNotificationStoreBasePath(cwd: string = process.cwd()): string {
89
136
  return resolveWorktreeProjectRoot(cwd);
90
137
  }
@@ -140,6 +187,7 @@ export function registerHooks(
140
187
  resetWriteGateState(process.cwd());
141
188
  resetToolCallLoopGuard();
142
189
  approvalQuestionAbortInFlight = false;
190
+ clearDeferredApprovalGate();
143
191
  await resetAskUserQuestionsTurnCache();
144
192
  await syncServiceTierStatus(ctx);
145
193
  await applyDisabledModelProviderPolicy(ctx);
@@ -189,6 +237,7 @@ export function registerHooks(
189
237
  initSessionNotifications(ctx);
190
238
  resetWriteGateState(process.cwd());
191
239
  resetToolCallLoopGuard();
240
+ clearDeferredApprovalGate();
192
241
  await resetAskUserQuestionsTurnCache();
193
242
  clearDiscussionFlowState(process.cwd());
194
243
  await syncServiceTierStatus(ctx);
@@ -225,6 +274,7 @@ export function registerHooks(
225
274
  if (milestoneId) markDepthVerified(milestoneId, beforeAgentBasePath);
226
275
  clearPendingGate(beforeAgentBasePath);
227
276
  }
277
+ clearDeferredApprovalGate(beforeAgentBasePath);
228
278
 
229
279
  // GSD's own context injection (existing behavior — unchanged).
230
280
  const { buildBeforeAgentStartResult } = await import("./system-context.js");
@@ -275,7 +325,11 @@ export function registerHooks(
275
325
  resetToolCallLoopGuard();
276
326
  await resetAskUserQuestionsTurnCache();
277
327
  const { handleAgentEnd } = await import("./agent-end-recovery.js");
278
- await handleAgentEnd(pi, event, ctx);
328
+ try {
329
+ await handleAgentEnd(pi, event, ctx);
330
+ } finally {
331
+ activateDeferredApprovalGate(process.cwd());
332
+ }
279
333
  });
280
334
 
281
335
  // Squash-merge quick-task branch back to the original branch after the
@@ -377,15 +431,16 @@ export function registerHooks(
377
431
  if (!shouldPauseForUserApprovalQuestion(unitType, [event.message])) return;
378
432
 
379
433
  const gateId = approvalGateIdForUnit(unitType, unitId);
380
- if (gateId) setPendingGate(gateId, process.cwd());
434
+ if (gateId) deferApprovalGate(gateId, process.cwd());
381
435
 
382
436
  approvalQuestionAbortInFlight = true;
383
437
  ctx.ui.notify(
384
438
  `${unitType}${unitId ? ` ${unitId}` : ""} is waiting for your approval - pausing before more tool calls run.`,
385
439
  "info",
386
440
  );
387
- // The pending gate set above blocks subsequent non-read-only tool calls
388
- // via the tool_call hook below, so we do not abort the in-flight stream.
441
+ // The durable pending gate is activated at agent_end so same-turn
442
+ // CONTEXT-DRAFT persistence can finish after the text boundary streams.
443
+ // The tool_call hook below still blocks non-draft tools in this turn.
389
444
  // Aborting mid-stream eats the model's question text on external CLI
390
445
  // providers (Claude Code SDK) because lastTextContent isn't populated
391
446
  // from in-flight builder state — the user only ever sees "Claude Code
@@ -417,6 +472,13 @@ export function registerHooks(
417
472
  return { block: true, reason: loopCheck.reason };
418
473
  }
419
474
 
475
+ const deferredGateGuard = shouldBlockDeferredApprovalTool(
476
+ toolName,
477
+ event.input,
478
+ discussionBasePath,
479
+ );
480
+ if (deferredGateGuard.block) return deferredGateGuard;
481
+
420
482
  // ── Discussion gate enforcement: track pending gate questions ─────────
421
483
  // Only gate-shaped ask_user_questions calls should block execution.
422
484
  // The gate stays pending until the user selects the approval option.
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * #2909: Adds a fast-path git status check before milestone completion merges.
5
5
  * When the working tree is dirty the user is warned and changes are auto-stashed
6
- * so the merge can proceed cleanly. After the merge completes, postflightPopStash
7
- * restores the stashed changes.
6
+ * so the merge can proceed cleanly. After the merge completes, postflightPopStash
7
+ * restores the stashed changes and reports whether manual recovery is needed.
8
8
  *
9
9
  * Design constraints (from Trek-e approval):
10
10
  * - Warn the user before stashing (no silent surprises)
11
11
  * - git stash push / git stash pop only — no custom stash management layer
12
- * - Stash/pop errors are logged but MUST NOT block the merge
12
+ * - Stash/pop errors are logged but MUST NOT block the merge itself
13
13
  * - Fast-path status check — clean trees pay no extra cost
14
14
  */
15
15
 
@@ -27,6 +27,13 @@ export interface PreflightResult {
27
27
  summary: string;
28
28
  }
29
29
 
30
+ export interface PostflightResult {
31
+ restored: boolean;
32
+ needsManualRecovery: boolean;
33
+ message: string;
34
+ stashRef?: string;
35
+ }
36
+
30
37
  function findPreflightStashRef(basePath: string, milestoneId: string, stashMarker?: string): string | null {
31
38
  const markerPrefix = `gsd-preflight-stash:${milestoneId}:`;
32
39
  let fallbackRef: string | null = null;
@@ -112,14 +119,15 @@ export function preflightCleanRoot(
112
119
  *
113
120
  * Only called when preflightCleanRoot returned stashPushed=true.
114
121
  * Any pop error (e.g. conflict) is logged and notified but does NOT throw —
115
- * the merge already completed successfully.
122
+ * the merge already completed successfully. Callers must treat
123
+ * needsManualRecovery=true as a dirty workspace stop, not a clean completion.
116
124
  */
117
125
  export function postflightPopStash(
118
126
  basePath: string,
119
127
  milestoneId: string,
120
128
  stashMarker: string | undefined,
121
129
  notify: (message: string, level: "info" | "warning" | "error") => void,
122
- ): void {
130
+ ): PostflightResult {
123
131
  let stashRef: string | null = null;
124
132
  try {
125
133
  stashRef = findPreflightStashRef(basePath, milestoneId, stashMarker);
@@ -127,7 +135,11 @@ export function postflightPopStash(
127
135
  const msg = `No matching GSD preflight stash found for milestone ${milestoneId}; leaving stash list untouched.`;
128
136
  logWarning("preflight", msg);
129
137
  notify(msg, "warning");
130
- return;
138
+ return {
139
+ restored: false,
140
+ needsManualRecovery: true,
141
+ message: msg,
142
+ };
131
143
  }
132
144
  execFileSync("git", ["stash", "pop", stashRef], {
133
145
  cwd: basePath,
@@ -135,7 +147,14 @@ export function postflightPopStash(
135
147
  encoding: "utf-8",
136
148
  env: GIT_NO_PROMPT_ENV,
137
149
  });
138
- notify(`Restored stashed changes after milestone ${milestoneId} merge.`, "info");
150
+ const msg = `Restored stashed changes after milestone ${milestoneId} merge.`;
151
+ notify(msg, "info");
152
+ return {
153
+ restored: true,
154
+ needsManualRecovery: false,
155
+ message: msg,
156
+ stashRef,
157
+ };
139
158
  } catch (err) {
140
159
  // Pop conflicts mean the merged code collides with the stashed changes.
141
160
  // Log a warning — the user needs to resolve manually, but the merge succeeded.
@@ -145,5 +164,11 @@ export function postflightPopStash(
145
164
  const msg = `git stash pop ${stashRef ?? ""}`.trim() + ` failed after merge of milestone ${milestoneId}: ${err instanceof Error ? err.message : String(err)}. ${restoreHint}`;
146
165
  logWarning("preflight", msg);
147
166
  notify(msg, "warning");
167
+ return {
168
+ restored: false,
169
+ needsManualRecovery: true,
170
+ message: msg,
171
+ ...(stashRef ? { stashRef } : {}),
172
+ };
148
173
  }
149
174
  }
@@ -14,6 +14,7 @@ import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
14
14
  import { gsdRoot } from "./paths.js";
15
15
  import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
16
16
  import { loadEffectiveGSDPreferences } from "./preferences.js";
17
+ import { logWarning } from "./workflow-logger.js";
17
18
 
18
19
 
19
20
  import {
@@ -722,16 +723,53 @@ export class GitServiceImpl {
722
723
  if (keyFiles.length === 0) return false;
723
724
 
724
725
  const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
725
- const paths = Array.from(new Set(
726
- keyFiles
727
- .map(file => normalizeRepoRelativePath(this.basePath, file))
728
- .filter((file): file is string => file !== null)
729
- .filter(file => !isExcludedScopedPath(file, allExclusions)),
730
- ));
726
+ const normalized = keyFiles
727
+ .map(file => normalizeRepoRelativePath(this.basePath, file))
728
+ .filter((file): file is string => file !== null)
729
+ .filter(file => !isExcludedScopedPath(file, allExclusions));
730
+
731
+ // Drop entries that don't exist on disk. The LLM occasionally lists files
732
+ // it intended to write but didn't (or names them with wrong casing/path).
733
+ // Pre-`b304f738b` `git add -A` swallowed these silently; the scoped
734
+ // pathspec form passes each path explicitly, so a single bad entry made
735
+ // the whole commit fail (see #5500). Filter so valid paths still commit.
736
+ const missing: string[] = [];
737
+ const existing: string[] = [];
738
+ for (const path of normalized) {
739
+ if (existsSync(join(this.basePath, path))) {
740
+ existing.push(path);
741
+ } else {
742
+ missing.push(path);
743
+ }
744
+ }
745
+ if (missing.length > 0) {
746
+ logWarning(
747
+ "engine",
748
+ `scoped stage: dropping ${missing.length} non-existent keyFile(s) from task commit: ${missing.join(", ")}`,
749
+ { file: "git-service.ts" },
750
+ );
751
+ }
752
+
753
+ const paths = Array.from(new Set(existing));
731
754
  if (paths.length === 0) return false;
732
755
 
733
- nativeAddPaths(this.basePath, paths);
734
- return true;
756
+ try {
757
+ nativeAddPaths(this.basePath, paths);
758
+ return true;
759
+ } catch (err) {
760
+ // Defense-in-depth: even after existence filtering, libgit2/git can
761
+ // still reject paths (gitignore matches, case-only differences on
762
+ // case-insensitive FS, submodule boundaries). Returning false lets
763
+ // autoCommit fall through to smartStage so the commit still goes out
764
+ // — restoring the resilience the unscoped path used to provide.
765
+ const msg = err instanceof Error ? err.message : String(err);
766
+ logWarning(
767
+ "engine",
768
+ `scoped stage failed (${msg}); falling back to smartStage`,
769
+ { file: "git-service.ts" },
770
+ );
771
+ return false;
772
+ }
735
773
  }
736
774
 
737
775
  /** Tracks whether runtime file cleanup has run this session. */
@@ -380,6 +380,8 @@ function shouldValidateInputAsPath(raw: string): boolean {
380
380
  const trimmed = raw.trim();
381
381
  if (!trimmed) return false;
382
382
 
383
+ if (isRuntimeOnlyInput(trimmed)) return false;
384
+
383
385
  const candidate = extractPathFromAnnotation(trimmed);
384
386
  if (!candidate) return false;
385
387
 
@@ -405,6 +407,10 @@ function shouldValidateInputAsPath(raw: string): boolean {
405
407
  );
406
408
  }
407
409
 
410
+ function isRuntimeOnlyInput(raw: string): boolean {
411
+ return /\(\s*runtime\s*\)/i.test(raw);
412
+ }
413
+
408
414
  function containsGlobPattern(candidate: string): boolean {
409
415
  return ["*", "?", "[", "]", "{", "}"].some((char) => candidate.includes(char));
410
416
  }
@@ -535,6 +541,7 @@ export function checkTaskOrdering(
535
541
  const filesToCheck = [...task.inputs];
536
542
 
537
543
  for (const file of filesToCheck) {
544
+ if (isRuntimeOnlyInput(file)) continue;
538
545
  if (!shouldValidateInputAsPath(file)) continue;
539
546
 
540
547
  const normalizedFile = normalizeFilePath(file);
@@ -6,6 +6,8 @@ You are executing GSD auto-mode.
6
6
 
7
7
  Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory.
8
8
 
9
+ If any inlined plan, summary, verification command, or prior artifact names an absolute path outside `{{workingDirectory}}`, treat that path as stale context. Convert it to the equivalent relative path under `{{workingDirectory}}` before reading, writing, or executing. If no equivalent path exists under `{{workingDirectory}}`, record a verification failure and stop; do not edit or run commands in another checkout.
10
+
9
11
  ## Mission
10
12
 
11
13
  All slices are complete. Verify the integrated work, persist milestone completion, refresh project state, and write the final record future milestones will rely on.
@@ -689,7 +689,11 @@ function makeMockDeps(
689
689
  resolveMilestoneFile: () => null,
690
690
  reconcileMergeState: () => "clean",
691
691
  preflightCleanRoot: () => ({ stashPushed: false, summary: "" }),
692
- postflightPopStash: () => {},
692
+ postflightPopStash: () => ({
693
+ restored: true,
694
+ needsManualRecovery: false,
695
+ message: "restored",
696
+ }),
693
697
  getLedger: () => null,
694
698
  getProjectTotals: () => ({ cost: 0 }),
695
699
  formatCost: (c: number) => `$${c.toFixed(2)}`,
@@ -857,6 +861,145 @@ test("autoLoop exits on terminal complete state", async (t) => {
857
861
  );
858
862
  });
859
863
 
864
+ test("autoLoop stops before success notification when postflight stash restore needs recovery", async () => {
865
+ _resetPendingResolve();
866
+
867
+ const notifications: Array<{ msg: string; level: string }> = [];
868
+ const ctx = makeMockCtx();
869
+ ctx.ui.setStatus = () => {};
870
+ ctx.ui.notify = (msg: string, level: string) => {
871
+ notifications.push({ msg, level });
872
+ };
873
+ const pi = makeMockPi();
874
+ const s = makeLoopSession();
875
+ let stopReason = "";
876
+
877
+ const deps = makeMockDeps({
878
+ deriveState: async () => {
879
+ deps.callLog.push("deriveState");
880
+ return {
881
+ phase: "complete",
882
+ activeMilestone: { id: "M001", title: "Test", status: "complete" },
883
+ activeSlice: null,
884
+ activeTask: null,
885
+ registry: [{ id: "M001", status: "complete" }],
886
+ blockers: [],
887
+ } as any;
888
+ },
889
+ preflightCleanRoot: () => ({
890
+ stashPushed: true,
891
+ stashMarker: "gsd-preflight-stash:M001:test",
892
+ summary: "stashed",
893
+ }),
894
+ postflightPopStash: () => ({
895
+ restored: false,
896
+ needsManualRecovery: true,
897
+ message: "git stash pop stash@{0} failed after merge of milestone M001",
898
+ stashRef: "stash@{0}",
899
+ }),
900
+ sendDesktopNotification: () => {
901
+ deps.callLog.push("sendDesktopNotification");
902
+ },
903
+ logCmuxEvent: () => {
904
+ deps.callLog.push("logCmuxEvent");
905
+ },
906
+ stopAuto: async (_ctx, _pi, reason) => {
907
+ deps.callLog.push("stopAuto");
908
+ stopReason = reason ?? "";
909
+ },
910
+ });
911
+
912
+ await autoLoop(ctx, pi, s, deps);
913
+
914
+ assert.equal(stopReason, "Post-merge stash restore failed for milestone M001");
915
+ assert.ok(
916
+ notifications.some(
917
+ (n) => n.level === "error" && n.msg.includes("Post-merge stash restore failed for milestone M001"),
918
+ ),
919
+ "failed postflight restore must be surfaced as an error",
920
+ );
921
+ assert.ok(
922
+ !deps.callLog.includes("sendDesktopNotification"),
923
+ "must not emit milestone success desktop notification after stash restore failure",
924
+ );
925
+ assert.ok(
926
+ !deps.callLog.includes("logCmuxEvent"),
927
+ "must not emit milestone success cmux event after stash restore failure",
928
+ );
929
+ });
930
+
931
+ test("autoLoop marks transition merge complete before postflight recovery stop", async () => {
932
+ _resetPendingResolve();
933
+
934
+ const ctx = makeMockCtx();
935
+ ctx.ui.setStatus = () => {};
936
+ ctx.ui.notify = () => {};
937
+ const pi = makeMockPi();
938
+ const s = makeLoopSession();
939
+ let mergeCalls = 0;
940
+ let stopReason = "";
941
+
942
+ const deps = makeMockDeps({
943
+ deriveState: async () => {
944
+ deps.callLog.push("deriveState");
945
+ return {
946
+ phase: "executing",
947
+ activeMilestone: { id: "M002", title: "Next", status: "active" },
948
+ activeSlice: null,
949
+ activeTask: null,
950
+ registry: [
951
+ { id: "M001", title: "Done", status: "complete" },
952
+ { id: "M002", title: "Next", status: "active" },
953
+ ],
954
+ blockers: [],
955
+ } as any;
956
+ },
957
+ preflightCleanRoot: () => ({
958
+ stashPushed: true,
959
+ stashMarker: "gsd-preflight-stash:M001:test",
960
+ summary: "stashed",
961
+ }),
962
+ postflightPopStash: () => ({
963
+ restored: false,
964
+ needsManualRecovery: true,
965
+ message: "git stash pop stash@{0} failed after merge of milestone M001",
966
+ stashRef: "stash@{0}",
967
+ }),
968
+ resolver: {
969
+ get workPath() {
970
+ return "/tmp/project";
971
+ },
972
+ get projectRoot() {
973
+ return "/tmp/project";
974
+ },
975
+ get lockPath() {
976
+ return "/tmp/project";
977
+ },
978
+ enterMilestone: () => {
979
+ assert.fail("must not enter the next milestone after postflight recovery fails");
980
+ },
981
+ exitMilestone: () => {},
982
+ mergeAndExit: () => {
983
+ mergeCalls += 1;
984
+ },
985
+ mergeAndEnterNext: () => {},
986
+ } as any,
987
+ stopAuto: async (_ctx, _pi, reason) => {
988
+ deps.callLog.push("stopAuto");
989
+ stopReason = reason ?? "";
990
+ if (!s.milestoneMergedInPhases) {
991
+ deps.resolver.mergeAndExit("M001", ctx.ui);
992
+ }
993
+ },
994
+ });
995
+
996
+ await autoLoop(ctx, pi, s, deps);
997
+
998
+ assert.equal(stopReason, "Post-merge stash restore failed for milestone M001");
999
+ assert.equal(s.milestoneMergedInPhases, true);
1000
+ assert.equal(mergeCalls, 1, "postflight recovery stop must not re-run an already completed transition merge");
1001
+ });
1002
+
860
1003
  test("autoLoop pauses when provider readiness cancels before dispatch", async () => {
861
1004
  _resetPendingResolve();
862
1005