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.
- package/CHANGELOG.md +116 -0
- package/dist/index.mjs +551 -544
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +2 -0
- package/src/agents/agent-config.ts +58 -1
- package/src/config/config-validation.ts +18 -0
- package/src/config/env-allowlist-registry.ts +5 -1
- package/src/config/env-vars.ts +5 -1
- package/src/config/sanitize-project-config.ts +55 -0
- package/src/extension/management.ts +9 -0
- package/src/extension/registration/lifecycle-handlers.ts +28 -0
- package/src/extension/registration/subagent-tools.ts +5 -1
- package/src/extension/run-import.ts +40 -6
- package/src/extension/team-tool/api/agent-control.ts +49 -0
- package/src/extension/team-tool/api/heartbeat.ts +48 -1
- package/src/extension/team-tool/api/mailbox.ts +81 -2
- package/src/extension/team-tool/api/read.ts +22 -0
- package/src/extension/team-tool/api/task-claims.ts +134 -3
- package/src/extension/team-tool/destructive-gate.ts +92 -8
- package/src/extension/team-tool/inspect.ts +8 -2
- package/src/extension/team-tool/lifecycle-actions.ts +9 -2
- package/src/extension/team-tool.ts +128 -27
- package/src/observability/exporters/otlp-exporter.ts +69 -19
- package/src/prompt/message-tool.ts +19 -8
- package/src/prompt/prompt-runtime.ts +160 -51
- package/src/runtime/broker/crew-broker-child.ts +55 -17
- package/src/runtime/broker/crew-broker-tokens.ts +24 -13
- package/src/runtime/broker/crew-broker.ts +12 -6
- package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
- package/src/runtime/child-pi/child-pi-spawn.ts +36 -10
- package/src/runtime/child-pi/child-pi-steering.ts +8 -1
- package/src/runtime/child-pi/child-pi.ts +2 -1
- package/src/runtime/child-pi/inference-broker.ts +41 -7
- package/src/runtime/child-pi/pi-version.ts +31 -3
- package/src/runtime/child-pi/sandbox.ts +38 -1
- package/src/runtime/dwf-state-store.ts +193 -7
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
- package/src/runtime/live-session/live-session-runtime.ts +78 -3
- package/src/runtime/mcp-proxy.ts +65 -6
- package/src/runtime/model/pi-args.ts +16 -15
- package/src/runtime/plan-replan.ts +4 -1
- package/src/runtime/task-runner/child-executor.ts +11 -5
- package/src/runtime/task-runner/hybrid-tier.ts +17 -0
- package/src/runtime/task-runner/pre-execution.ts +13 -6
- package/src/runtime/task-runner.ts +14 -0
- package/src/runtime/team-runner.ts +26 -0
- package/src/runtime/verification/verification-worktree.ts +46 -29
- package/src/runtime/verification/verify-gate-memo.ts +211 -32
- package/src/runtime/verification/verify-gate.ts +266 -14
- package/src/runtime/workspace-lock.ts +95 -21
- package/src/schema/config-schema.ts +12 -2
- package/src/state/coordination/locks.ts +86 -19
- package/src/state/coordination/mailbox.ts +263 -22
- package/src/state/stores/manifest-io.ts +206 -7
- package/src/state/stores/state-store.ts +44 -15
- package/src/state/types.ts +9 -0
- package/src/ui/inline-panel/agent-transcript.ts +151 -11
- package/src/ui/settings-overlay.ts +8 -3
- package/src/ui/theme-discovery.ts +14 -2
- package/src/ui/tool-renderers/index.ts +26 -6
- package/src/utils/safe-terminal-text.ts +60 -0
- package/src/worktree/git-hardening.ts +78 -1
- 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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 (
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
/**
|
|
62
|
-
*
|
|
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
|
-
|
|
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)
|
|
103
|
-
* Returns undefined on miss / disabled / unkeyable cwd. Typed-JSON
|
|
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
|
|
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;
|