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.
- package/dist/resources/extensions/gsd/auto/phases.js +28 -1
- package/dist/resources/extensions/gsd/auto/session.js +12 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +16 -3
- package/dist/resources/extensions/gsd/auto-post-unit.js +24 -1
- package/dist/resources/extensions/gsd/auto-prompts.js +14 -0
- package/dist/resources/extensions/gsd/auto-worktree.js +21 -5
- package/dist/resources/extensions/gsd/auto.js +42 -10
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +11 -1
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +22 -1
- package/dist/resources/extensions/gsd/clean-root-preflight.js +93 -0
- package/dist/resources/extensions/gsd/safety/evidence-collector.js +96 -0
- package/dist/resources/extensions/gsd/safety/file-change-validator.js +3 -1
- package/dist/resources/extensions/gsd/safety/safety-harness.js +1 -1
- package/dist/resources/extensions/gsd/uok/plan-v2.js +20 -3
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/mcp-server/dist/server.d.ts +7 -0
- package/packages/mcp-server/dist/server.d.ts.map +1 -1
- package/packages/mcp-server/dist/server.js +23 -3
- package/packages/mcp-server/dist/server.js.map +1 -1
- package/packages/mcp-server/src/mcp-server.test.ts +30 -0
- package/packages/mcp-server/src/server.ts +43 -9
- package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
- package/packages/pi-ai/dist/providers/anthropic-auth.test.js +1 -1
- package/packages/pi-ai/dist/providers/anthropic-auth.test.js.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic-shared.js +25 -4
- package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.js +8 -3
- package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
- package/packages/pi-ai/dist/providers/minimax-tool-name.test.d.ts +2 -0
- package/packages/pi-ai/dist/providers/minimax-tool-name.test.d.ts.map +1 -0
- package/packages/pi-ai/dist/providers/minimax-tool-name.test.js +80 -0
- package/packages/pi-ai/dist/providers/minimax-tool-name.test.js.map +1 -0
- package/packages/pi-ai/src/providers/anthropic-auth.test.ts +1 -1
- package/packages/pi-ai/src/providers/anthropic-shared.ts +23 -4
- package/packages/pi-ai/src/providers/anthropic.ts +9 -3
- package/packages/pi-ai/src/providers/minimax-tool-name.test.ts +98 -0
- package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
- package/src/resources/extensions/gsd/auto/loop-deps.ts +13 -0
- package/src/resources/extensions/gsd/auto/phases.ts +52 -1
- package/src/resources/extensions/gsd/auto/session.ts +22 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +16 -3
- package/src/resources/extensions/gsd/auto-post-unit.ts +28 -1
- package/src/resources/extensions/gsd/auto-prompts.ts +28 -1
- package/src/resources/extensions/gsd/auto-worktree.ts +28 -11
- package/src/resources/extensions/gsd/auto.ts +46 -10
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +11 -1
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +22 -1
- package/src/resources/extensions/gsd/clean-root-preflight.ts +111 -0
- package/src/resources/extensions/gsd/safety/evidence-collector.ts +119 -0
- package/src/resources/extensions/gsd/safety/file-change-validator.ts +3 -1
- package/src/resources/extensions/gsd/safety/safety-harness.ts +3 -0
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +3 -1
- package/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts +12 -0
- package/src/resources/extensions/gsd/tests/clean-root-preflight.test.ts +186 -0
- package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +2 -0
- package/src/resources/extensions/gsd/tests/double-merge-guard.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/journal-integration.test.ts +2 -0
- package/src/resources/extensions/gsd/tests/pre-exec-gate-loop.test.ts +272 -0
- package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +205 -0
- package/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts +23 -0
- package/src/resources/extensions/gsd/uok/plan-v2.ts +26 -3
- package/src/resources/extensions/gsd/workflow-logger.ts +2 -1
- /package/dist/web/standalone/.next/static/{pI48IF3dgfs0CBrYi2bh_ → lLdDRDspgYzfz0bJAmUSz}/_buildManifest.js +0 -0
- /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
|
|
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 +
|
|
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 +
|
|
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)}`,
|