pi-crew 0.11.49 → 0.11.51

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 (64) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/index.mjs +551 -544
  3. package/package.json +1 -1
  4. package/skills/real-test-pi-crew/SKILL.md +2 -0
  5. package/src/agents/agent-config.ts +58 -1
  6. package/src/config/config-validation.ts +18 -0
  7. package/src/config/env-allowlist-registry.ts +5 -1
  8. package/src/config/env-vars.ts +5 -1
  9. package/src/config/sanitize-project-config.ts +55 -0
  10. package/src/extension/management.ts +9 -0
  11. package/src/extension/registration/lifecycle-handlers.ts +28 -0
  12. package/src/extension/registration/subagent-tools.ts +5 -1
  13. package/src/extension/run-import.ts +40 -6
  14. package/src/extension/team-tool/api/agent-control.ts +49 -0
  15. package/src/extension/team-tool/api/heartbeat.ts +48 -1
  16. package/src/extension/team-tool/api/mailbox.ts +81 -2
  17. package/src/extension/team-tool/api/read.ts +22 -0
  18. package/src/extension/team-tool/api/task-claims.ts +134 -3
  19. package/src/extension/team-tool/destructive-gate.ts +92 -8
  20. package/src/extension/team-tool/inspect.ts +8 -2
  21. package/src/extension/team-tool/lifecycle-actions.ts +9 -2
  22. package/src/extension/team-tool.ts +128 -27
  23. package/src/observability/exporters/otlp-exporter.ts +69 -19
  24. package/src/prompt/message-tool.ts +19 -8
  25. package/src/prompt/prompt-runtime.ts +160 -51
  26. package/src/runtime/broker/crew-broker-child.ts +55 -17
  27. package/src/runtime/broker/crew-broker-tokens.ts +24 -13
  28. package/src/runtime/broker/crew-broker.ts +12 -6
  29. package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
  30. package/src/runtime/child-pi/child-pi-spawn.ts +36 -10
  31. package/src/runtime/child-pi/child-pi-steering.ts +8 -1
  32. package/src/runtime/child-pi/child-pi.ts +2 -1
  33. package/src/runtime/child-pi/inference-broker.ts +41 -7
  34. package/src/runtime/child-pi/pi-version.ts +31 -3
  35. package/src/runtime/child-pi/sandbox.ts +38 -1
  36. package/src/runtime/dwf-state-store.ts +193 -7
  37. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
  38. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
  39. package/src/runtime/live-session/live-session-runtime.ts +78 -3
  40. package/src/runtime/mcp-proxy.ts +65 -6
  41. package/src/runtime/model/pi-args.ts +16 -15
  42. package/src/runtime/plan-replan.ts +4 -1
  43. package/src/runtime/task-runner/child-executor.ts +11 -5
  44. package/src/runtime/task-runner/hybrid-tier.ts +17 -0
  45. package/src/runtime/task-runner/pre-execution.ts +13 -6
  46. package/src/runtime/task-runner.ts +14 -0
  47. package/src/runtime/team-runner.ts +26 -0
  48. package/src/runtime/verification/verification-worktree.ts +46 -29
  49. package/src/runtime/verification/verify-gate-memo.ts +211 -32
  50. package/src/runtime/verification/verify-gate.ts +266 -14
  51. package/src/runtime/workspace-lock.ts +95 -21
  52. package/src/schema/config-schema.ts +12 -2
  53. package/src/state/coordination/locks.ts +86 -19
  54. package/src/state/coordination/mailbox.ts +263 -22
  55. package/src/state/stores/manifest-io.ts +206 -7
  56. package/src/state/stores/state-store.ts +44 -15
  57. package/src/state/types.ts +9 -0
  58. package/src/ui/inline-panel/agent-transcript.ts +151 -11
  59. package/src/ui/settings-overlay.ts +8 -3
  60. package/src/ui/theme-discovery.ts +14 -2
  61. package/src/ui/tool-renderers/index.ts +26 -6
  62. package/src/utils/safe-terminal-text.ts +60 -0
  63. package/src/worktree/git-hardening.ts +78 -1
  64. package/src/worktree/worktree-manager.ts +34 -4
@@ -26,6 +26,7 @@ import * as fs from "node:fs";
26
26
  import * as os from "node:os";
27
27
  import * as path from "node:path";
28
28
  import { getCrewEnv } from "../../config/env-vars.ts";
29
+ import { assertWorktreePointerTrusted, buildGitSafeEnv, hardenGitArgs } from "../../worktree/git-hardening.ts";
29
30
 
30
31
  export interface VerificationWorktree {
31
32
  /** Absolute path to the pristine worktree directory. */
@@ -42,6 +43,36 @@ export function isWorktreeSandboxEnabled(): boolean {
42
43
  return v === "1" || v === "true";
43
44
  }
44
45
 
46
+ /**
47
+ * MED-1 / R-A7 (uniformity, w9-n4): the ONLY execFileSync("git", …) site in
48
+ * this module — every git invocation routes through the same composition
49
+ * worktree-manager.ts applies via its git() helper (worktree-manager.ts:57-67):
50
+ * - hardenGitArgs — `-c core.fsmonitor=false -c core.hooksPath= -c
51
+ * core.attributesFile=` prefix neutralizing config-reachable helper
52
+ * execution (audit PoC: raw `git status --porcelain` at a repo whose
53
+ * config — own or via a hijacked linked-worktree gitdir — sets
54
+ * core.fsmonitor spawns the helper in THIS process; hardened argv does not);
55
+ * - buildGitSafeEnv — allowlist-sanitized env instead of full host-env
56
+ * inheritance (also strips env-injected config: GIT_CONFIG_COUNT etc.);
57
+ * - assertWorktreePointerTrusted (R-A3) — fail-closed gitdir-pointer trust
58
+ * check paid before EVERY command; no-op for main-repo / non-git cwd,
59
+ * throws `worktree corrupted: …` inside a linked worktree whose `.git`
60
+ * pointer no longer matches the git registry. The assert runs OUTSIDE the
61
+ * execFile call so callers keep their existing throw handling: the sandbox
62
+ * precondition check reports `available:false` and cleanup stays best-effort.
63
+ */
64
+ function hardenedVerificationGit(cwd: string, args: string[], timeout: number): string {
65
+ assertWorktreePointerTrusted(cwd);
66
+ return execFileSync("git", hardenGitArgs(args), {
67
+ cwd,
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ encoding: "utf-8",
70
+ timeout,
71
+ env: buildGitSafeEnv("en_US.UTF-8"),
72
+ windowsHide: true,
73
+ }).trim();
74
+ }
75
+
45
76
  /**
46
77
  * Detect whether the worktree sandbox is AVAILABLE at `cwd`:
47
78
  * - opt-in env var set
@@ -62,24 +93,14 @@ export function checkWorktreeSandboxAvailable(cwd: string): { available: true; c
62
93
  try {
63
94
  // Is cwd inside a git repo? `git rev-parse --show-toplevel` errors out
64
95
  // (non-zero exit) when not in a repo. execFileSync throws on non-zero.
65
- const toplevel = execFileSync("git", ["rev-parse", "--show-toplevel"], {
66
- cwd,
67
- stdio: ["ignore", "pipe", "pipe"],
68
- encoding: "utf-8",
69
- timeout: 5000,
70
- }).trim();
96
+ const toplevel = hardenedVerificationGit(cwd, ["rev-parse", "--show-toplevel"], 5000);
71
97
  if (!toplevel)
72
98
  return {
73
99
  available: false,
74
100
  reason: "git rev-parse returned empty toplevel",
75
101
  };
76
102
  // Current commit SHA (this is what T_snap will pin to).
77
- const commitSha = execFileSync("git", ["rev-parse", "HEAD"], {
78
- cwd,
79
- stdio: ["ignore", "pipe", "pipe"],
80
- encoding: "utf-8",
81
- timeout: 5000,
82
- }).trim();
103
+ const commitSha = hardenedVerificationGit(cwd, ["rev-parse", "HEAD"], 5000);
83
104
  if (!commitSha)
84
105
  return {
85
106
  available: false,
@@ -90,12 +111,7 @@ export function checkWorktreeSandboxAvailable(cwd: string): { available: true; c
90
111
  // the worktree would NOT contain the in-progress edits (T_snap would
91
112
  // pin to a stale commit). Better to fall back + warn than silently
92
113
  // verify against the wrong code.
93
- const status = execFileSync("git", ["status", "--porcelain"], {
94
- cwd,
95
- stdio: ["ignore", "pipe", "pipe"],
96
- encoding: "utf-8",
97
- timeout: 5000,
98
- }).trim();
114
+ const status = hardenedVerificationGit(cwd, ["status", "--porcelain"], 5000);
99
115
  if (status.length > 0)
100
116
  return {
101
117
  available: false,
@@ -132,16 +148,15 @@ export function prepareVerificationWorktree(cwd: string, commitSha: string): Ver
132
148
  if (cleaned) return;
133
149
  cleaned = true;
134
150
  // Remove the worktree (force = proceed even if it has untracked files).
151
+ // Runs at the trusted main-repo `cwd` — same placement as worktree-manager
152
+ // (git(repoRoot, ["worktree", "remove", "--force", …])): the R-A3 assert is
153
+ // a no-op there and stays fail-closed if `cwd` itself is a hijacked worktree.
135
154
  try {
136
- execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd, stdio: ["ignore", "pipe", "pipe"], timeout: 5000 });
155
+ hardenedVerificationGit(cwd, ["worktree", "remove", "--force", worktreePath], 5000);
137
156
  } catch {
138
157
  // Fall back to `git worktree prune` if remove fails (already gone).
139
158
  try {
140
- execFileSync("git", ["worktree", "prune"], {
141
- cwd,
142
- stdio: ["ignore", "pipe", "pipe"],
143
- timeout: 5000,
144
- });
159
+ hardenedVerificationGit(cwd, ["worktree", "prune"], 5000);
145
160
  } catch {
146
161
  /* best-effort */
147
162
  }
@@ -154,11 +169,13 @@ export function prepareVerificationWorktree(cwd: string, commitSha: string): Ver
154
169
  }
155
170
  };
156
171
  try {
157
- execFileSync("git", ["worktree", "add", "--detach", worktreePath, commitSha], {
158
- cwd,
159
- stdio: ["ignore", "pipe", "pipe"],
160
- timeout: 30_000,
161
- });
172
+ // `worktree add` CREATES the new worktree: git itself writes its `.git`
173
+ // pointer in this very call, so there is no pre-existing worker-writable
174
+ // pointer at `worktreePath` for assertWorktreePointerTrusted to verify
175
+ // (its precondition does not exist yet). The R-A3 assert therefore applies
176
+ // to `cwd` only — the same placement as worktree-manager's createTaskWorktree
177
+ // (git() helper at the trusted repoRoot cwd). Timeouts/stdio unchanged.
178
+ hardenedVerificationGit(cwd, ["worktree", "add", "--detach", worktreePath, commitSha], 30_000);
162
179
  return { worktreePath, commitSha, cleanup };
163
180
  } catch (error) {
164
181
  cleanup();
@@ -7,33 +7,55 @@
7
7
  * same-tree result. pisub memoizes gate acceptance by workspace tree-hash:
8
8
  * tree unchanged ⇒ same verdict, no re-run.
9
9
  *
10
- * SAFETY MODEL (stronger than the M1-7 manifest bookends):
11
- * - The key embeds the FULL workspace tree OID (`git write-tree` over the
12
- * index — every tracked file, not just manifests) + the exact check list
13
- * (name+command) + the running node version. A memo HIT therefore proves
14
- * the tree is bit-identical to the tree that produced the recorded PASS —
15
- * no manifest re-hash needed (tree equality subsumes it).
10
+ * SAFETY MODEL (rewritten by the W2 fix wave, 2026-10-29 — findings
11
+ * verify-gate-memo.index-tree-key-mismatch + nonmanifest-ground-truth-self-grading):
12
+ * - The ORIGINAL key embedded `git write-tree` over the git INDEX. pi-crew
13
+ * workflows never stage worker edits (edit/write tools) or seed overlays
14
+ * (fs.cpSync), and worktree-mode task worktrees are created from HEAD with
15
+ * fresh HEAD-matching indexes — so that key stayed constant across
16
+ * materially different working trees and across every fresh worktree of the
17
+ * same HEAD, and a memo HIT could certify a tree the checks would FAIL.
18
+ * - The key now embeds (a) the RESOLVED workspace path — a PASS recorded for
19
+ * one workspace is never served to another (no cross-worktree replay), and
20
+ * (b) the FULL WORKING TREE OID, computed through a TEMPORARY index
21
+ * (GIT_INDEX_FILE: read-tree --empty → add -A → write-tree): staged AND
22
+ * unstaged modifications plus untracked non-ignored files all move the key,
23
+ * while the caller's index is never touched. A memo HIT therefore proves
24
+ * the workspace content the checks would evaluate is bit-identical to the
25
+ * content that produced the recorded PASS.
26
+ * - FAIL CLOSED ON A DIRTY TREE: `workingTreeDirty` (any tracked change or
27
+ * untracked file ⇒ true; probe failure ⇒ true) disables the memo entirely
28
+ * (undefined key ⇒ no hit AND no record). Dirt is pi-crew's normal state
29
+ * while work is in flight — exactly when self-grading risk is highest — so
30
+ * only a fully clean tree may ever memoize, and the drift binding in
31
+ * verify-gate.ts (dispatch treeOid) independently fails those gates closed.
16
32
  * - ONLY verdict PASS outcomes are recorded (ran>0). FAILED / INCONCLUSIVE
17
33
  * (incl. every integrity-drift and determinism downgrade) never memoize.
18
- * - The lookup ITSELF re-computes the tree OID — a hit can never serve a
19
- * stale tree; a miss is an ordinary full gate run (fail-open).
20
- * - Not a git repo / git unavailable / write-tree failure ⇒ key undefined ⇒
34
+ * - The lookup ITSELF re-computes the key — a hit can never serve a changed
35
+ * tree; a miss is an ordinary full gate run (fail-open only in the sense
36
+ * that MORE work happens, never less).
37
+ * - Not a git repo / git unavailable / tree-hash failure ⇒ key undefined ⇒
21
38
  * memo silently disabled for that cwd (ordinary gate runs).
22
- * - Typed-JSON gates (structured output, W10-9 candidate) must NEVER
23
- * memoize — a JSON schema consumer reads fresh structuredOutput per run.
24
- * The memo records only exit-code checks; a future structured gate kind
25
- * must bypass this module entirely.
39
+ * - Typed-JSON gates (structured output, W10-9) must NEVER memoize — a JSON
40
+ * schema consumer reads fresh structuredOutput per run. The memo records
41
+ * only exit-code checks; a future structured gate kind must bypass this
42
+ * module entirely.
26
43
  * - KNOWN TRADEOFF (accepted by design, mirrors pisub): tests that are
27
44
  * time-dependent or environment-sensitive can pass once and be skipped on
28
- * an unchanged tree. The kill-switch PI_CREW_VERIFY_GATE_MEMO=0 restores
29
- * pre-W10-2 behavior bit-for-bit (default ON).
45
+ * an unchanged CLEAN tree. The kill-switch PI_CREW_VERIFY_GATE_MEMO=0
46
+ * restores pre-W10-2 behavior bit-for-bit (default ON).
30
47
  *
31
48
  * Scope: host-process in-memory Map. Parallel sibling lanes within one host
32
49
  * process share it; cross-process persistence is deliberately out of scope.
33
50
  */
34
51
  import { execFileSync } from "node:child_process";
52
+ import * as crypto from "node:crypto";
53
+ import * as fs from "node:fs";
54
+ import * as os from "node:os";
55
+ import * as path from "node:path";
35
56
 
36
57
  import { getCrewEnvBool } from "../../config/env-vars.ts";
58
+ import { assertWorktreePointerTrusted, buildGitSafeEnv, hardenGitArgs } from "../../worktree/git-hardening.ts";
37
59
  import type { VerifyGateCheck, VerifyGateOutcome } from "./verify-gate.ts";
38
60
 
39
61
  /** W10-2 kill-switch: PI_CREW_VERIFY_GATE_MEMO beats default ON. */
@@ -43,28 +65,182 @@ export function resolveVerifyGateMemoEnabled(explicit?: boolean): boolean {
43
65
  return explicit ?? true;
44
66
  }
45
67
 
46
- /** Tree OID of `cwd`'s git index, or undefined when git/repo unavailable. */
68
+ /** Tree-hash git budget: temp-index add -A walks the whole (non-ignored) tree,
69
+ * so this is more generous than the status probes. */
70
+ const TREE_HASH_TIMEOUT_MS = 30_000;
71
+ /** `git status --porcelain` probe budget (cheap; a hang must disable, not block). */
72
+ const STATUS_TIMEOUT_MS = 10_000;
73
+
74
+ /**
75
+ * MED-1 / R-A7 (uniformity, w9-n4): the ONLY execFileSync("git", …) site in
76
+ * this module — every git invocation routes through the same composition
77
+ * worktree-manager.ts applies via its git() helper (worktree-manager.ts:57-67):
78
+ * - hardenGitArgs — `-c core.fsmonitor=false -c core.hooksPath= -c
79
+ * core.attributesFile=` prefix neutralizing config-reachable helper
80
+ * execution (audit PoC: raw `git status --porcelain` / `git add -A` at a
81
+ * repo whose config — own, env-injected, or via a hijacked linked-worktree
82
+ * gitdir — sets core.fsmonitor spawns the helper in THIS process);
83
+ * - buildGitSafeEnv — allowlist-sanitized env instead of the full
84
+ * `{ ...process.env }` inheritance (strips env-injected config such as
85
+ * GIT_CONFIG_COUNT/GIT_CONFIG_KEY_/GIT_CONFIG_VALUE_ pairs); the caller's
86
+ * PRIVATE temp index (GIT_INDEX_FILE) is layered on top when hashing;
87
+ * - assertWorktreePointerTrusted (R-A3) — fail-closed gitdir-pointer trust
88
+ * check paid before EVERY command; no-op for main-repo / non-git cwd.
89
+ *
90
+ * DEVIATION from worktree-manager (deliberate, documented): there the assert
91
+ * throws OUTSIDE the execFile try so callers land in "worktree corrupted"
92
+ * handling; this module's pre-existing contract converts EVERY git failure
93
+ * into a fail-closed "measurement unavailable" (undefined OID / dirty=true —
94
+ * see the SAFETY MODEL header), so the assert is invoked INSIDE each caller's
95
+ * try: a corrupted worktree disables the memo / omits the binding instead of
96
+ * throwing into the gate flow. Behavior is unchanged for every trusted cwd.
97
+ */
98
+ function hardenedMemoGit(cwd: string, args: string[], opts: { timeout: number; gitIndexFile?: string }): string {
99
+ assertWorktreePointerTrusted(cwd);
100
+ const safeEnv = buildGitSafeEnv("en_US.UTF-8");
101
+ const env = opts.gitIndexFile === undefined ? safeEnv : { ...safeEnv, GIT_INDEX_FILE: opts.gitIndexFile };
102
+ return execFileSync("git", hardenGitArgs(args), {
103
+ cwd,
104
+ env,
105
+ stdio: ["ignore", "pipe", "ignore"],
106
+ encoding: "utf-8",
107
+ timeout: opts.timeout,
108
+ windowsHide: true,
109
+ });
110
+ }
111
+
112
+ function isValidTreeOid(oid: string): boolean {
113
+ return /^[0-9a-f]{40}$/.test(oid) || /^[0-9a-f]{64}$/.test(oid);
114
+ }
115
+
116
+ /**
117
+ * FULL working-tree OID of `cwd`: staged + unstaged modifications plus
118
+ * untracked non-ignored files (gitignore respected), computed through a
119
+ * TEMPORARY index (GIT_INDEX_FILE) so the caller's index is never touched.
120
+ * W2 fix (index-tree-key-mismatch): the previous implementation used
121
+ * `git write-tree` over the INDEX, which is blind to unstaged edits — the
122
+ * normal state of pi-crew worker work — so the memo key was constant across
123
+ * materially different trees. Undefined when git/repo unavailable or the
124
+ * hash fails (memo silently disabled for that cwd — ordinary gate runs).
125
+ */
47
126
  export function computeWorkspaceTreeOid(cwd: string): string | undefined {
127
+ const tmpIndex = path.join(os.tmpdir(), `pi-crew-vgtree-${process.pid}-${crypto.randomUUID()}`);
128
+ try {
129
+ const idxOpts = { timeout: TREE_HASH_TIMEOUT_MS, gitIndexFile: tmpIndex };
130
+ hardenedMemoGit(cwd, ["read-tree", "--empty"], idxOpts);
131
+ // Stages tracked modifications + untracked files (gitignores respected)
132
+ // into the PRIVATE temp index — the caller's index is never written.
133
+ hardenedMemoGit(cwd, ["add", "-A"], idxOpts);
134
+ const oid = hardenedMemoGit(cwd, ["write-tree"], idxOpts).trim();
135
+ return isValidTreeOid(oid) ? oid : undefined;
136
+ } catch {
137
+ return undefined;
138
+ } finally {
139
+ try {
140
+ fs.rmSync(tmpIndex, { force: true });
141
+ } catch {
142
+ /* best-effort cleanup */
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * TRACKED working-tree OID of `cwd`: the working-tree content of every file
149
+ * known to HEAD (staged + unstaged modifications and deletions), EXCLUDING
150
+ * untracked files. Computed through a temp index seeded from HEAD
151
+ * (read-tree HEAD → add -u → write-tree). This is the dispatch-time
152
+ * ground-truth binding for the verify gate (W2, nonmanifest-ground-truth-
153
+ * self-grading): untracked files legitimately differ between the leader
154
+ * workspace and a fresh task worktree (assertCleanLeader permits them, seeds
155
+ * are selective), so the binding must key on what git tracks — while still
156
+ * moving on ANY tracked edit (the self-grading PoC's tracked-unstaged
157
+ * harness stubs). Undefined when unavailable (binding skipped — manifest
158
+ * bookends still apply).
159
+ */
160
+ export function computeTrackedTreeOid(cwd: string): string | undefined {
161
+ const tmpIndex = path.join(os.tmpdir(), `pi-crew-vgtree-${process.pid}-${crypto.randomUUID()}`);
48
162
  try {
49
- const oid = execFileSync("git", ["write-tree"], {
50
- cwd,
51
- stdio: ["ignore", "pipe", "ignore"],
52
- encoding: "utf-8",
53
- timeout: 10_000,
54
- }).trim();
55
- return /^[0-9a-f]{40}$/.test(oid) ? oid : undefined;
163
+ const idxOpts = { timeout: TREE_HASH_TIMEOUT_MS, gitIndexFile: tmpIndex };
164
+ try {
165
+ hardenedMemoGit(cwd, ["read-tree", "HEAD"], idxOpts);
166
+ } catch {
167
+ // No HEAD yet (fresh repo): the tracked set is empty. (A corrupted
168
+ // worktree also lands here first — the retry asserts again and the
169
+ // OUTER catch converts it to the fail-closed undefined below.)
170
+ hardenedMemoGit(cwd, ["read-tree", "--empty"], idxOpts);
171
+ }
172
+ // Updates tracked files to their WORKING-TREE content (mods + deletions);
173
+ // untracked files stay out by definition of -u.
174
+ hardenedMemoGit(cwd, ["add", "-u"], idxOpts);
175
+ const oid = hardenedMemoGit(cwd, ["write-tree"], idxOpts).trim();
176
+ return isValidTreeOid(oid) ? oid : undefined;
56
177
  } catch {
57
178
  return undefined;
179
+ } finally {
180
+ try {
181
+ fs.rmSync(tmpIndex, { force: true });
182
+ } catch {
183
+ /* best-effort cleanup */
184
+ }
58
185
  }
59
186
  }
60
187
 
61
- /** Deterministic memo key: tree OID + check list + node major.minor.
62
- * Undefined tree OID ⇒ undefined key ⇒ memo disabled for this cwd. */
188
+ /** Fail-closed dirty probe: TRUE when the tree carries ANY change (tracked
189
+ * modification/deletion or untracked file) or when the probe itself fails
190
+ * (git unavailable/hangs — absence of proof of cleanliness is dirt). */
191
+ export function workingTreeDirty(cwd: string): boolean {
192
+ try {
193
+ const st = hardenedMemoGit(cwd, ["status", "--porcelain"], { timeout: STATUS_TIMEOUT_MS });
194
+ return st.trim().length > 0;
195
+ } catch {
196
+ return true;
197
+ }
198
+ }
199
+
200
+ /** Tri-state tracked-clean probe: true = no tracked changes; false = dirty;
201
+ * undefined = probe failed (callers decide their own fail direction). */
202
+ function trackedWorkingTreeClean(cwd: string): boolean | undefined {
203
+ try {
204
+ const st = hardenedMemoGit(cwd, ["status", "--porcelain", "--untracked-files=no"], { timeout: STATUS_TIMEOUT_MS });
205
+ return st.trim().length === 0;
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ }
210
+
211
+ /** Dispatch/gate-time tree binding snapshot (W2): tracked-tree OID + tracked-
212
+ * clean probe, with unavailable measurements OMITTED (never guessed) so the
213
+ * gate only ever acts on positive evidence of divergence or equality. */
214
+ export interface WorkingTreeBinding {
215
+ /** {@link computeTrackedTreeOid} result; absent when it could not be computed. */
216
+ oid?: string;
217
+ /** {@link trackedWorkingTreeClean} result; absent when the probe failed. */
218
+ clean?: boolean;
219
+ }
220
+
221
+ export function captureWorkingTreeBinding(cwd: string): WorkingTreeBinding {
222
+ const binding: WorkingTreeBinding = {};
223
+ const oid = computeTrackedTreeOid(cwd);
224
+ if (oid !== undefined) binding.oid = oid;
225
+ const clean = trackedWorkingTreeClean(cwd);
226
+ if (clean !== undefined) binding.clean = clean;
227
+ return binding;
228
+ }
229
+
230
+ /**
231
+ * Deterministic memo key: resolved workspace path + FULL working-tree OID +
232
+ * check list + node version. Undefined ⇒ memo disabled for this workspace:
233
+ * a dirty tree (fail-closed, see {@link workingTreeDirty}) or an unhashable
234
+ * non-git cwd. The resolved cwd is part of the key so a PASS recorded in one
235
+ * workspace can never be replayed for another (fresh worktrees of the same
236
+ * HEAD previously shared one index-derived key — the W2 replay bug).
237
+ */
63
238
  export function computeVerifyGateMemoKey(cwd: string, checks: VerifyGateCheck[]): string | undefined {
239
+ if (workingTreeDirty(cwd)) return undefined;
64
240
  const oid = computeWorkspaceTreeOid(cwd);
65
241
  if (!oid) return undefined;
66
242
  const checkPart = checks.map((c) => `${c.name}::${c.command}::crit=${c.critical !== false}`).join("|");
67
- return `${oid}::${process.version}::${checkPart}`;
243
+ return `${path.resolve(cwd)}::${oid}::${process.version}::${checkPart}`;
68
244
  }
69
245
 
70
246
  /** In-host memo store (module-level: one per host process). */
@@ -78,7 +254,9 @@ export function resetVerifyGateMemoStoreForTests(): void {
78
254
  /** Build the memo-HIT outcome: verdict PASS with every check marked
79
255
  * skipped-memo — evidence lines make the hit auditable in prompts/metrics. */
80
256
  function memoHitOutcome(key: string, checks: VerifyGateCheck[]): VerifyGateOutcome {
81
- const oid = key.split("::")[0];
257
+ // Key layout: `<resolvedCwd>::<treeOid>::<node>::<checks>` — segment 1 is
258
+ // the tree oid the evidence line names.
259
+ const oid = key.split("::")[1] ?? "";
82
260
  return {
83
261
  verdict: "PASS",
84
262
  checks: checks.map((c) => ({
@@ -99,9 +277,9 @@ function memoHitOutcome(key: string, checks: VerifyGateCheck[]): VerifyGateOutco
99
277
  };
100
278
  }
101
279
 
102
- /** Lookup a recorded PASS for this exact (tree, checks, node) triple.
103
- * Returns undefined on miss / disabled / unkeyable cwd. Typed-JSON gates
104
- * (W10-9, check.output==="json") NEVER hit the memo — structured-output
280
+ /** Lookup a recorded PASS for this exact (workspace, full tree, checks, node)
281
+ * quadruple. Returns undefined on miss / disabled / unkeyable cwd. Typed-JSON
282
+ * gates (W10-9, check.output==="json") NEVER hit the memo — structured-output
105
283
  * consumers read fresh structuredOutput every run. */
106
284
  export function lookupVerifyGateMemo(cwd: string, checks: VerifyGateCheck[]): VerifyGateOutcome | undefined {
107
285
  if (checks.length === 0) return undefined;
@@ -112,8 +290,9 @@ export function lookupVerifyGateMemo(cwd: string, checks: VerifyGateCheck[]): Ve
112
290
  return hit ? memoHitOutcome(key, checks) : undefined;
113
291
  }
114
292
 
115
- /** Record an outcome — ONLY a ran PASS with real (non-memo) evidence.
116
- * Memo-hit outcomes must not re-record (they carry skipped checks). */
293
+ /** Record an outcome — ONLY a ran PASS with real (non-memo) evidence on a
294
+ * CLEAN, hashable workspace. Memo-hit outcomes must not re-record (they
295
+ * carry skipped checks); a dirty tree yields no key (undefined ⇒ no record). */
117
296
  export function recordVerifyGateMemo(cwd: string, checks: VerifyGateCheck[], outcome: VerifyGateOutcome): boolean {
118
297
  if (checks.length === 0) return false;
119
298
  if (checks.some((c) => c.output === "json")) return false;