gsd-pi 2.76.0-dev.97807402 → 2.76.0-dev.97f5583d9

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 (100) hide show
  1. package/dist/resources/extensions/gsd/auto/phases.js +28 -1
  2. package/dist/resources/extensions/gsd/auto/session.js +12 -0
  3. package/dist/resources/extensions/gsd/auto-dispatch.js +16 -3
  4. package/dist/resources/extensions/gsd/auto-post-unit.js +24 -1
  5. package/dist/resources/extensions/gsd/auto-prompts.js +14 -0
  6. package/dist/resources/extensions/gsd/auto-worktree.js +21 -5
  7. package/dist/resources/extensions/gsd/auto.js +42 -10
  8. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +11 -1
  9. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +22 -1
  10. package/dist/resources/extensions/gsd/clean-root-preflight.js +93 -0
  11. package/dist/resources/extensions/gsd/safety/evidence-collector.js +96 -0
  12. package/dist/resources/extensions/gsd/safety/file-change-validator.js +3 -1
  13. package/dist/resources/extensions/gsd/safety/safety-harness.js +1 -1
  14. package/dist/resources/extensions/gsd/uok/plan-v2.js +20 -3
  15. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  16. package/dist/web/standalone/.next/BUILD_ID +1 -1
  17. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  18. package/dist/web/standalone/.next/build-manifest.json +2 -2
  19. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  20. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.html +1 -1
  37. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  44. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  45. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  46. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  47. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  48. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  49. package/package.json +1 -1
  50. package/packages/mcp-server/dist/server.d.ts +7 -0
  51. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  52. package/packages/mcp-server/dist/server.js +23 -3
  53. package/packages/mcp-server/dist/server.js.map +1 -1
  54. package/packages/mcp-server/src/mcp-server.test.ts +30 -0
  55. package/packages/mcp-server/src/server.ts +43 -9
  56. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  57. package/packages/pi-ai/dist/providers/anthropic-auth.test.js +1 -1
  58. package/packages/pi-ai/dist/providers/anthropic-auth.test.js.map +1 -1
  59. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -1
  60. package/packages/pi-ai/dist/providers/anthropic-shared.js +25 -4
  61. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -1
  62. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  63. package/packages/pi-ai/dist/providers/anthropic.js +8 -3
  64. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  65. package/packages/pi-ai/dist/providers/minimax-tool-name.test.d.ts +2 -0
  66. package/packages/pi-ai/dist/providers/minimax-tool-name.test.d.ts.map +1 -0
  67. package/packages/pi-ai/dist/providers/minimax-tool-name.test.js +80 -0
  68. package/packages/pi-ai/dist/providers/minimax-tool-name.test.js.map +1 -0
  69. package/packages/pi-ai/src/providers/anthropic-auth.test.ts +1 -1
  70. package/packages/pi-ai/src/providers/anthropic-shared.ts +23 -4
  71. package/packages/pi-ai/src/providers/anthropic.ts +9 -3
  72. package/packages/pi-ai/src/providers/minimax-tool-name.test.ts +98 -0
  73. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
  74. package/src/resources/extensions/gsd/auto/loop-deps.ts +13 -0
  75. package/src/resources/extensions/gsd/auto/phases.ts +52 -1
  76. package/src/resources/extensions/gsd/auto/session.ts +22 -0
  77. package/src/resources/extensions/gsd/auto-dispatch.ts +16 -3
  78. package/src/resources/extensions/gsd/auto-post-unit.ts +28 -1
  79. package/src/resources/extensions/gsd/auto-prompts.ts +28 -1
  80. package/src/resources/extensions/gsd/auto-worktree.ts +28 -11
  81. package/src/resources/extensions/gsd/auto.ts +46 -10
  82. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +11 -1
  83. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +22 -1
  84. package/src/resources/extensions/gsd/clean-root-preflight.ts +111 -0
  85. package/src/resources/extensions/gsd/safety/evidence-collector.ts +119 -0
  86. package/src/resources/extensions/gsd/safety/file-change-validator.ts +3 -1
  87. package/src/resources/extensions/gsd/safety/safety-harness.ts +3 -0
  88. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +3 -1
  89. package/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts +12 -0
  90. package/src/resources/extensions/gsd/tests/clean-root-preflight.test.ts +186 -0
  91. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +2 -0
  92. package/src/resources/extensions/gsd/tests/double-merge-guard.test.ts +1 -1
  93. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +2 -0
  94. package/src/resources/extensions/gsd/tests/pre-exec-gate-loop.test.ts +272 -0
  95. package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +205 -0
  96. package/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts +23 -0
  97. package/src/resources/extensions/gsd/uok/plan-v2.ts +26 -3
  98. package/src/resources/extensions/gsd/workflow-logger.ts +2 -1
  99. /package/dist/web/standalone/.next/static/{pI48IF3dgfs0CBrYi2bh_ → lLdDRDspgYzfz0bJAmUSz}/_buildManifest.js +0 -0
  100. /package/dist/web/standalone/.next/static/{pI48IF3dgfs0CBrYi2bh_ → lLdDRDspgYzfz0bJAmUSz}/_ssgManifest.js +0 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * clean-root-preflight.ts — Preflight gate for dirty working trees before milestone merges.
3
+ *
4
+ * #2909: Adds a fast-path git status check before milestone completion merges.
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.
8
+ *
9
+ * Design constraints (from Trek-e approval):
10
+ * - Warn the user before stashing (no silent surprises)
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
13
+ * - Fast-path status check — clean trees pay no extra cost
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
18
+ import { logWarning } from "./workflow-logger.js";
19
+ import { nativeHasChanges } from "./native-git-bridge.js";
20
+
21
+ export interface PreflightResult {
22
+ /** true when a stash was pushed and postflightPopStash should be called */
23
+ stashPushed: boolean;
24
+ /** human-readable summary of what happened (empty string for clean trees) */
25
+ summary: string;
26
+ }
27
+
28
+ /**
29
+ * Check the working tree for dirty files before a milestone merge.
30
+ *
31
+ * Clean tree path: O(1) — returns immediately with stashPushed=false.
32
+ *
33
+ * Dirty tree path:
34
+ * 1. Emits a warning notification via the provided `notify` callback.
35
+ * 2. Runs `git stash push --include-untracked -m "gsd-preflight-stash"`.
36
+ * 3. Returns stashPushed=true so the caller knows to call postflightPopStash.
37
+ *
38
+ * Any stash error is logged but does NOT throw — the merge proceeds regardless.
39
+ */
40
+ export function preflightCleanRoot(
41
+ basePath: string,
42
+ milestoneId: string,
43
+ notify: (message: string, level: "info" | "warning" | "error") => void,
44
+ ): PreflightResult {
45
+ // Fast-path: clean tree — nothing to do
46
+ let isDirty = false;
47
+ try {
48
+ isDirty = nativeHasChanges(basePath);
49
+ } catch (err) {
50
+ // If the status check itself fails, treat as clean and let the merge decide
51
+ logWarning("preflight", `clean-root status check failed: ${err instanceof Error ? err.message : String(err)}`);
52
+ return { stashPushed: false, summary: "" };
53
+ }
54
+
55
+ if (!isDirty) {
56
+ return { stashPushed: false, summary: "" };
57
+ }
58
+
59
+ // Warn the user before stashing
60
+ const warnMsg = `Working tree has uncommitted changes before milestone ${milestoneId} merge. Auto-stashing to allow clean merge (stash will be restored after merge).`;
61
+ notify(warnMsg, "warning");
62
+
63
+ // Push the stash
64
+ try {
65
+ execFileSync("git", ["stash", "push", "--include-untracked", "-m", "gsd-preflight-stash"], {
66
+ cwd: basePath,
67
+ stdio: ["ignore", "pipe", "pipe"],
68
+ encoding: "utf-8",
69
+ env: GIT_NO_PROMPT_ENV,
70
+ });
71
+ return {
72
+ stashPushed: true,
73
+ summary: `Stashed uncommitted changes before merge (milestone ${milestoneId}).`,
74
+ };
75
+ } catch (err) {
76
+ // Stash failure is non-fatal — log and let the merge attempt proceed
77
+ const msg = `git stash push failed before merge of milestone ${milestoneId}: ${err instanceof Error ? err.message : String(err)}`;
78
+ logWarning("preflight", msg);
79
+ notify(`Auto-stash failed before milestone ${milestoneId} merge — proceeding anyway. ${msg}`, "warning");
80
+ return { stashPushed: false, summary: `stash-push-failed: ${msg}` };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Restore stashed changes after a milestone merge completes.
86
+ *
87
+ * Only called when preflightCleanRoot returned stashPushed=true.
88
+ * Any pop error (e.g. conflict) is logged and notified but does NOT throw —
89
+ * the merge already completed successfully.
90
+ */
91
+ export function postflightPopStash(
92
+ basePath: string,
93
+ milestoneId: string,
94
+ notify: (message: string, level: "info" | "warning" | "error") => void,
95
+ ): void {
96
+ try {
97
+ execFileSync("git", ["stash", "pop"], {
98
+ cwd: basePath,
99
+ stdio: ["ignore", "pipe", "pipe"],
100
+ encoding: "utf-8",
101
+ env: GIT_NO_PROMPT_ENV,
102
+ });
103
+ notify(`Restored stashed changes after milestone ${milestoneId} merge.`, "info");
104
+ } catch (err) {
105
+ // Pop conflicts mean the merged code collides with the stashed changes.
106
+ // Log a warning — the user needs to resolve manually, but the merge succeeded.
107
+ const msg = `git stash pop failed after merge of milestone ${milestoneId}: ${err instanceof Error ? err.message : String(err)}. Run "git stash pop" manually to restore your changes.`;
108
+ logWarning("preflight", msg);
109
+ notify(msg, "warning");
110
+ }
111
+ }
@@ -3,10 +3,26 @@
3
3
  * Tracks every bash command, file write, and file edit during a unit execution.
4
4
  * Evidence is compared against LLM completion claims in evidence-cross-ref.ts.
5
5
  *
6
+ * Evidence is persisted to .gsd/safety/evidence-<mid>-<sid>-<tid>.json so it
7
+ * survives session restarts (pause/resume, crash recovery). On unit start,
8
+ * call resetEvidence() then loadEvidenceFromDisk(). On every new tool call,
9
+ * saveEvidenceToDisk() is called automatically by recordToolCall/recordToolResult.
10
+ *
6
11
  * Follows the same module-level Map pattern as auto-tool-tracking.ts.
7
12
  * Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
8
13
  */
9
14
 
15
+ import {
16
+ existsSync,
17
+ mkdirSync,
18
+ readFileSync,
19
+ writeFileSync,
20
+ renameSync,
21
+ unlinkSync,
22
+ } from "node:fs";
23
+ import { join, dirname } from "node:path";
24
+ import { randomBytes } from "node:crypto";
25
+
10
26
  // ─── Types ──────────────────────────────────────────────────────────────────
11
27
 
12
28
  export interface BashEvidence {
@@ -62,6 +78,109 @@ export function getFilePaths(): string[] {
62
78
  .map(e => e.path);
63
79
  }
64
80
 
81
+ // ─── Persistence (Bug #4385 — evidence must survive session restarts) ────────
82
+
83
+ /**
84
+ * Build the path for the evidence JSON file for a given unit.
85
+ * Lives under .gsd/safety/ which is gitignored and session-scoped.
86
+ */
87
+ function evidencePath(basePath: string, milestoneId: string, sliceId: string, taskId: string): string {
88
+ return join(basePath, ".gsd", "safety", `evidence-${milestoneId}-${sliceId}-${taskId}.json`);
89
+ }
90
+
91
+ /**
92
+ * Validate that a parsed value is an array of EvidenceEntry objects.
93
+ * Rejects corrupt / schema-mismatch data rather than letting it poison state.
94
+ */
95
+ function isEvidenceArray(data: unknown): data is EvidenceEntry[] {
96
+ if (!Array.isArray(data)) return false;
97
+ return data.every((e) => {
98
+ if (e === null || typeof e !== "object") return false;
99
+ const rec = e as Record<string, unknown>;
100
+ if (typeof rec.toolCallId !== "string") return false;
101
+ if (typeof rec.timestamp !== "number") return false;
102
+ if (rec.kind === "bash") {
103
+ return (
104
+ typeof rec.command === "string" &&
105
+ typeof rec.exitCode === "number" &&
106
+ typeof rec.outputSnippet === "string"
107
+ );
108
+ }
109
+ if (rec.kind === "write" || rec.kind === "edit") {
110
+ return typeof rec.path === "string";
111
+ }
112
+ return false;
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Persist the current in-memory evidence to disk so it survives a session
118
+ * restart. Called from saveEvidenceToDisk after recordToolCall/recordToolResult.
119
+ * Non-fatal — persistence failures must never break unit execution.
120
+ */
121
+ export function saveEvidenceToDisk(
122
+ basePath: string,
123
+ milestoneId: string,
124
+ sliceId: string,
125
+ taskId: string,
126
+ ): void {
127
+ try {
128
+ const path = evidencePath(basePath, milestoneId, sliceId, taskId);
129
+ mkdirSync(dirname(path), { recursive: true });
130
+ const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
131
+ writeFileSync(tmp, JSON.stringify(unitEvidence, null, 2) + "\n", "utf-8");
132
+ renameSync(tmp, path);
133
+ } catch {
134
+ // Non-fatal — don't let persistence failures break unit execution
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Load persisted evidence from disk into the in-memory array.
140
+ * Call after resetEvidence() on session resume to restore context for a
141
+ * partially-executed unit. If the file does not exist (fresh unit), this
142
+ * is a no-op — getEvidence() will return [] which is correct.
143
+ */
144
+ export function loadEvidenceFromDisk(
145
+ basePath: string,
146
+ milestoneId: string,
147
+ sliceId: string,
148
+ taskId: string,
149
+ ): void {
150
+ try {
151
+ const path = evidencePath(basePath, milestoneId, sliceId, taskId);
152
+ if (!existsSync(path)) return;
153
+ const raw = readFileSync(path, "utf-8");
154
+ const parsed = JSON.parse(raw);
155
+ if (isEvidenceArray(parsed)) {
156
+ unitEvidence = parsed;
157
+ }
158
+ } catch {
159
+ // Non-fatal — corrupt / missing file is treated as empty evidence
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Delete the persisted evidence file for a unit after it has been fully
165
+ * processed. Prevents stale evidence from affecting future retries of
166
+ * the same unit ID.
167
+ */
168
+ export function clearEvidenceFromDisk(
169
+ basePath: string,
170
+ milestoneId: string,
171
+ sliceId: string,
172
+ taskId: string,
173
+ ): void {
174
+ try {
175
+ const path = evidencePath(basePath, milestoneId, sliceId, taskId);
176
+ if (existsSync(path)) {
177
+ unlinkSync(path);
178
+ }
179
+ } catch {
180
+ // Non-fatal
181
+ }
182
+ }
183
+
65
184
  // ─── Recording (called from register-hooks.ts) ─────────────────────────────
66
185
 
67
186
  /**
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * Uses tasks.expected_output (DB column, populated from per-task ## Expected Output)
6
6
  * and tasks.files (from slice PLAN.md - Files: subline) as the expected set.
7
- * Compares against git diff HEAD~1 --name-only after auto-commit.
7
+ * Compares against `git diff-tree --root --no-commit-id -r --name-only HEAD` after auto-commit.
8
+ * Using diff-tree --root handles initial commits, shallow clones, and merge commits correctly
9
+ * (Bug #4385 — git diff HEAD~1 failed on initial commits).
8
10
  *
9
11
  * Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
10
12
  */
@@ -92,6 +92,9 @@ export {
92
92
  getFilePaths,
93
93
  recordToolCall,
94
94
  recordToolResult,
95
+ saveEvidenceToDisk,
96
+ loadEvidenceFromDisk,
97
+ clearEvidenceFromDisk,
95
98
  } from "./evidence-collector.js";
96
99
 
97
100
  export type { EvidenceEntry, BashEvidence, FileWriteEvidence, FileEditEvidence } from "./evidence-collector.js";
@@ -525,7 +525,7 @@ test("auto/phases.ts: selectAndApplyModel called exactly once and before updateP
525
525
  // Extract the runUnitPhase function body
526
526
  const fnStart = src.indexOf("export async function runUnitPhase");
527
527
  assert.ok(fnStart > 0, "runUnitPhase should exist in phases.ts");
528
- const fnBody = src.slice(fnStart, fnStart + 12000);
528
+ const fnBody = src.slice(fnStart, fnStart + 16000);
529
529
 
530
530
  // selectAndApplyModel must appear exactly once
531
531
  const allOccurrences = [...fnBody.matchAll(/selectAndApplyModel\(/g)];
@@ -613,6 +613,8 @@ function makeMockDeps(
613
613
  autoWorktreeBranch: () => "auto/M001",
614
614
  resolveMilestoneFile: () => null,
615
615
  reconcileMergeState: () => "clean",
616
+ preflightCleanRoot: () => ({ stashPushed: false, summary: "" }),
617
+ postflightPopStash: () => {},
616
618
  getLedger: () => null,
617
619
  getProjectTotals: () => ({ cost: 0 }),
618
620
  formatCost: (c: number) => `$${c.toFixed(2)}`,
@@ -39,6 +39,18 @@ test("auto.ts validates milestone before restoring paused session (#1664)", () =
39
39
  source.includes('resolveMilestoneFile(base, meta.milestoneId, "SUMMARY")'),
40
40
  "auto.ts must check for SUMMARY file to detect completed milestones",
41
41
  );
42
+
43
+ // Resume path must sanitize paused session file metadata before unlink/recovery.
44
+ assert.ok(
45
+ source.includes("normalizeSessionFilePath(meta.sessionFile ?? null)"),
46
+ "auto.ts must sanitize paused-session metadata sessionFile before using it",
47
+ );
48
+
49
+ // Pause path must sanitize live session file path before persisting metadata.
50
+ assert.ok(
51
+ source.includes("normalizeSessionFilePath(ctx?.sessionManager?.getSessionFile() ?? null)"),
52
+ "auto.ts must sanitize sessionManager getSessionFile output before persisting",
53
+ );
42
54
  });
43
55
 
44
56
  // ─── Filesystem validation unit tests ───────────────────────────────────────
@@ -0,0 +1,186 @@
1
+ /**
2
+ * clean-root-preflight.test.ts — Regression tests for #2909.
3
+ *
4
+ * Tests that preflightCleanRoot warns + stashes on dirty trees,
5
+ * is a no-op on clean trees, and that postflightPopStash restores
6
+ * stashed changes after a merge.
7
+ */
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, realpathSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+ import { execSync } from "node:child_process";
15
+
16
+ import { preflightCleanRoot, postflightPopStash } from "../clean-root-preflight.ts";
17
+
18
+ function run(cmd: string, cwd: string): string {
19
+ return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
20
+ }
21
+
22
+ function createTempRepo(): string {
23
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-preflight-test-")));
24
+ run("git init", dir);
25
+ run("git config user.email test@example.com", dir);
26
+ run("git config user.name Test", dir);
27
+ writeFileSync(join(dir, "README.md"), "# test\n");
28
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
29
+ writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n");
30
+ run("git add .", dir);
31
+ run("git commit -m init", dir);
32
+ run("git branch -M main", dir);
33
+ return dir;
34
+ }
35
+
36
+ // ── Clean tree: fast-path returns immediately without stashing ─────────────
37
+
38
+ test("preflightCleanRoot — clean tree returns stashPushed=false and emits no notifications", () => {
39
+ const repo = createTempRepo();
40
+ try {
41
+ const notifications: Array<{ msg: string; level: string }> = [];
42
+ const result = preflightCleanRoot(repo, "M001", (msg, level) => {
43
+ notifications.push({ msg, level });
44
+ });
45
+
46
+ assert.equal(result.stashPushed, false, "stashPushed must be false for clean tree");
47
+ assert.equal(result.summary, "", "summary must be empty for clean tree");
48
+ assert.equal(notifications.length, 0, "no notifications on clean tree");
49
+
50
+ // Verify no stash was created
51
+ const stashList = run("git stash list", repo);
52
+ assert.equal(stashList, "", "no stash entry on clean tree");
53
+ } finally {
54
+ try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* ignore */ }
55
+ }
56
+ });
57
+
58
+ // ── Dirty tree: warns, stashes, returns stashPushed=true ──────────────────
59
+
60
+ test("preflightCleanRoot — dirty tree warns user and auto-stashes", () => {
61
+ const repo = createTempRepo();
62
+ try {
63
+ // Dirty an existing tracked file
64
+ writeFileSync(join(repo, "README.md"), "# locally modified\n");
65
+
66
+ const notifications: Array<{ msg: string; level: string }> = [];
67
+ const result = preflightCleanRoot(repo, "M002", (msg, level) => {
68
+ notifications.push({ msg, level });
69
+ });
70
+
71
+ assert.equal(result.stashPushed, true, "stashPushed must be true when tree was dirty");
72
+ assert.ok(result.summary.length > 0, "summary must be non-empty when stash was pushed");
73
+
74
+ // A warning notification must have been emitted before stashing
75
+ assert.ok(
76
+ notifications.some(n => n.level === "warning" && n.msg.includes("M002")),
77
+ "warning notification must mention the milestone ID",
78
+ );
79
+
80
+ // Working tree must now be clean (stash pushed)
81
+ const status = run("git status --porcelain", repo);
82
+ assert.equal(status, "", "working tree must be clean after stash push");
83
+
84
+ // The stash entry must exist
85
+ const stashList = run("git stash list", repo);
86
+ assert.ok(stashList.includes("gsd-preflight-stash"), "stash entry must be named gsd-preflight-stash");
87
+ } finally {
88
+ try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* ignore */ }
89
+ }
90
+ });
91
+
92
+ // ── Untracked files are also stashed ─────────────────────────────────────
93
+
94
+ test("preflightCleanRoot — untracked file triggers stash with --include-untracked", () => {
95
+ const repo = createTempRepo();
96
+ try {
97
+ // Add an untracked file
98
+ writeFileSync(join(repo, "untracked.ts"), "export const x = 1;\n");
99
+
100
+ const notifications: Array<{ msg: string; level: string }> = [];
101
+ const result = preflightCleanRoot(repo, "M003", (msg, level) => {
102
+ notifications.push({ msg, level });
103
+ });
104
+
105
+ assert.equal(result.stashPushed, true, "stashPushed must be true for untracked file");
106
+
107
+ const status = run("git status --porcelain", repo);
108
+ assert.equal(status, "", "working tree must be clean after stash push");
109
+ } finally {
110
+ try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* ignore */ }
111
+ }
112
+ });
113
+
114
+ // ── postflightPopStash: restores stashed changes ──────────────────────────
115
+
116
+ test("postflightPopStash — restores stashed changes and emits info notification", () => {
117
+ const repo = createTempRepo();
118
+ try {
119
+ // Dirty the working tree
120
+ writeFileSync(join(repo, "README.md"), "# stash me\n");
121
+
122
+ const preNotifications: Array<{ msg: string; level: string }> = [];
123
+ const preflight = preflightCleanRoot(repo, "M004", (msg, level) => {
124
+ preNotifications.push({ msg, level });
125
+ });
126
+ assert.equal(preflight.stashPushed, true, "preflight must have stashed");
127
+
128
+ // Simulate the merge (just a no-op commit here)
129
+ writeFileSync(join(repo, "merged.ts"), "export const merged = true;\n");
130
+ run("git add .", repo);
131
+ run('git commit -m "simulate merge"', repo);
132
+
133
+ const postNotifications: Array<{ msg: string; level: string }> = [];
134
+ postflightPopStash(repo, "M004", (msg, level) => {
135
+ postNotifications.push({ msg, level });
136
+ });
137
+
138
+ // The stashed README.md change must be restored
139
+ const content = readFileSync(join(repo, "README.md"), "utf-8");
140
+ assert.equal(content.replace(/\r\n/g, "\n"), "# stash me\n", "stashed file must be restored");
141
+
142
+ // An info notification must have been emitted
143
+ assert.ok(
144
+ postNotifications.some(n => n.level === "info" && n.msg.includes("M004")),
145
+ "info notification must mention milestone ID after pop",
146
+ );
147
+
148
+ // Stash list must be empty
149
+ const stashList = run("git stash list", repo);
150
+ assert.equal(stashList, "", "stash list must be empty after pop");
151
+ } finally {
152
+ try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* ignore */ }
153
+ }
154
+ });
155
+
156
+ // ── Round-trip: preflight + merge + postflight preserves changes ──────────
157
+
158
+ test("preflight + merge + postflight round-trip preserves uncommitted changes", () => {
159
+ const repo = createTempRepo();
160
+ try {
161
+ const originalContent = "# my local work\n";
162
+ writeFileSync(join(repo, "README.md"), originalContent);
163
+
164
+ // Preflight: stash
165
+ const preflight = preflightCleanRoot(repo, "M005", () => {});
166
+ assert.equal(preflight.stashPushed, true, "must have stashed");
167
+
168
+ // Merge: introduce a new file (no overlap with README.md)
169
+ writeFileSync(join(repo, "feature.ts"), "export const feature = true;\n");
170
+ run("git add feature.ts", repo);
171
+ run('git commit -m "feat: add feature"', repo);
172
+
173
+ // Postflight: pop stash
174
+ postflightPopStash(repo, "M005", () => {});
175
+
176
+ // README.md must still have our local content
177
+ const restored = readFileSync(join(repo, "README.md"), "utf-8");
178
+ assert.equal(restored.replace(/\r\n/g, "\n"), originalContent, "local changes must survive merge");
179
+
180
+ // feature.ts must also exist (the merge commit landed)
181
+ const featureContent = readFileSync(join(repo, "feature.ts"), "utf-8");
182
+ assert.ok(featureContent.includes("feature"), "merged feature must be present");
183
+ } finally {
184
+ try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* ignore */ }
185
+ }
186
+ });
@@ -179,6 +179,8 @@ function makeMockDeps(overrides?: Partial<LoopDeps>): LoopDeps & { callLog: stri
179
179
  autoWorktreeBranch: () => "auto/M001",
180
180
  resolveMilestoneFile: () => null,
181
181
  reconcileMergeState: () => "clean",
182
+ preflightCleanRoot: () => ({ stashPushed: false, summary: "" }),
183
+ postflightPopStash: () => {},
182
184
  getLedger: () => null,
183
185
  getProjectTotals: () => ({ cost: 0 }),
184
186
  formatCost: (c: number) => `$${c.toFixed(2)}`,
@@ -42,7 +42,7 @@ describe("double mergeAndExit guard (#2645)", () => {
42
42
  const allCompleteIdx = phasesSrc.indexOf("incomplete.length === 0");
43
43
  assert.ok(allCompleteIdx > 0, "phases.ts should have an all-milestones-complete check");
44
44
 
45
- const afterAllComplete = phasesSrc.slice(allCompleteIdx, allCompleteIdx + 600);
45
+ const afterAllComplete = phasesSrc.slice(allCompleteIdx, allCompleteIdx + 800);
46
46
  const mergeIdx = afterAllComplete.indexOf("deps.resolver.mergeAndExit");
47
47
  const flagIdx = afterAllComplete.indexOf("s.milestoneMergedInPhases = true");
48
48
 
@@ -77,6 +77,8 @@ function makeMockDeps(
77
77
  autoWorktreeBranch: () => "auto/M001",
78
78
  resolveMilestoneFile: () => null,
79
79
  reconcileMergeState: () => "clean",
80
+ preflightCleanRoot: () => ({ stashPushed: false, summary: "" }),
81
+ postflightPopStash: () => {},
80
82
  getLedger: () => ({ units: [] }),
81
83
  getProjectTotals: () => ({ cost: 0 }),
82
84
  formatCost: (c: number) => `$${c.toFixed(2)}`,