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
|
@@ -54,6 +54,32 @@
|
|
|
54
54
|
* no spec + no explicit checks ⇒ zero checks ⇒ INCONCLUSIVE ⇒ the LLM
|
|
55
55
|
* verifier spawns — absence can never produce a skip.
|
|
56
56
|
*
|
|
57
|
+
* WORKTREE GROUND TRUTH (W2 fix wave, 2026-10-29 — findings
|
|
58
|
+
* nonmanifest-ground-truth-self-grading + worktree-verifier-gate-excludes-work
|
|
59
|
+
* + index-tree-key-mismatch): the M1-7 binding pinned only the 8 manifest
|
|
60
|
+
* hashes, so a worker could tamper NON-manifest harness files (tracked-
|
|
61
|
+
* unstaged runners, node_modules shims) and have the pinned checks execute
|
|
62
|
+
* its stubs — grading its own work. Closures, layered:
|
|
63
|
+
* - hostSpec now also pins a dispatch-time TRACKED-working-tree binding
|
|
64
|
+
* (treeOid + cleanTree, captured host-side by resolveHostVerifyGateSpec).
|
|
65
|
+
* At gate time the task cwd's tracked tree is re-derived and ANY divergence
|
|
66
|
+
* — or an unmeasurable tree while a binding was pinned — fails the gate to
|
|
67
|
+
* the LLM verifier spawn (never a skip) BEFORE any command runs, mirroring
|
|
68
|
+
* the manifest bookend. The binding keys on TRACKED content only:
|
|
69
|
+
* untracked files legitimately differ between the leader workspace and
|
|
70
|
+
* fresh task worktrees (assertCleanLeader permits them; seeds are
|
|
71
|
+
* selective), while the self-grading PoC's harness stubs were tracked-
|
|
72
|
+
* unstaged and always move it.
|
|
73
|
+
* - the memo key binds the FULL working tree + the workspace path and the
|
|
74
|
+
* memo is disabled on any dirty tree (verify-gate-memo.ts) — a recorded
|
|
75
|
+
* PASS can no longer be replayed for a different or merely re-dirtied
|
|
76
|
+
* tree, nor across worktrees.
|
|
77
|
+
* - a gate PASS never certifies work it cannot see: when the verified
|
|
78
|
+
* dependency tasks carry uncommitted changes in their OWN worktrees
|
|
79
|
+
* (worktree mode — the gate's pristine tree structurally excludes that
|
|
80
|
+
* work), the skip is refused and the LLM verifier spawns with the guard
|
|
81
|
+
* note (dependencyWorktreeProbes → reason "unverified_worktree_work").
|
|
82
|
+
*
|
|
57
83
|
* SAFETY: a gate that cannot run (disabled by env, no checks resolved,
|
|
58
84
|
* executor failure) degrades to "spawn the LLM verifier as today" — the
|
|
59
85
|
* pre-gate can only SAVE a spawn on positive deterministic evidence, never
|
|
@@ -66,16 +92,18 @@
|
|
|
66
92
|
* - crew.verification.verifier_spawns_total{reason} — spawns that still
|
|
67
93
|
* happened, labeled by which layer let them through.
|
|
68
94
|
*/
|
|
95
|
+
import { execFileSync } from "node:child_process";
|
|
69
96
|
import * as fs from "node:fs";
|
|
70
97
|
import { getCrewEnvBool, getCrewEnvInt } from "../../config/env-vars.ts";
|
|
71
98
|
import type { MetricRegistry } from "../../observability/metric-registry.ts";
|
|
99
|
+
import { assertWorktreePointerTrusted, buildGitSafeEnv, hardenGitArgs } from "../../worktree/git-hardening.ts";
|
|
72
100
|
import type { VerifierPreGateResult } from "./classifier-pre-gate.ts";
|
|
73
101
|
import { executeCommand } from "./verification-gates.ts";
|
|
74
102
|
// Static edge is graph-free: verification-integrity.ts is already in the
|
|
75
103
|
// index.bundle value-import graph (goal.ts/goal-wrap.ts/goal-loop-runner.ts
|
|
76
104
|
// import it), so this adds no bundle-graph file (check-lazy-imports budget).
|
|
77
105
|
import { compareSnapshot, snapshotManifests } from "./verification-integrity.ts";
|
|
78
|
-
import { lookupVerifyGateMemo, recordVerifyGateMemo, resolveVerifyGateMemoEnabled } from "./verify-gate-memo.ts";
|
|
106
|
+
import { captureWorkingTreeBinding, lookupVerifyGateMemo, recordVerifyGateMemo, resolveVerifyGateMemoEnabled } from "./verify-gate-memo.ts";
|
|
79
107
|
|
|
80
108
|
/** One deterministic check to run. Mirrors the phase-gate shape
|
|
81
109
|
* (verification-gates.ts) but kept local so the tool schema can reuse it. */
|
|
@@ -132,6 +160,11 @@ export interface VerifyGateOutcome {
|
|
|
132
160
|
* the note is also appended to evidenceLines and switches the rendered
|
|
133
161
|
* context trailer to "results UNTRUSTED". */
|
|
134
162
|
integrityNote?: string;
|
|
163
|
+
/** W2 (2026-10-29): set when the tracked WORKING TREE diverged from the
|
|
164
|
+
* dispatch-pinned binding (or could not be proven equal) — like
|
|
165
|
+
* integrityNote it is appended to evidenceLines and switches the rendered
|
|
166
|
+
* context trailer to "results UNTRUSTED". */
|
|
167
|
+
worktreeIntegrityNote?: string;
|
|
135
168
|
}
|
|
136
169
|
|
|
137
170
|
/** Injected command executor. The TOOL path wraps ctx.executeTool("bash");
|
|
@@ -228,6 +261,18 @@ export function defaultVerifyGateChecks(dir: string): VerifyGateCheck[] {
|
|
|
228
261
|
export interface VerifyGateHostSpec {
|
|
229
262
|
snapshot: Record<string, string>;
|
|
230
263
|
checks: VerifyGateCheck[];
|
|
264
|
+
/** W2 (2026-10-29, nonmanifest-ground-truth-self-grading): dispatch-time
|
|
265
|
+
* TRACKED-working-tree binding — the full tracked content (staged +
|
|
266
|
+
* unstaged) the host saw before any worker ran (temp-index
|
|
267
|
+
* read-tree HEAD → add -u → write-tree). Absent when it could not be
|
|
268
|
+
* computed (non-git cwd, git failure) — the gate then falls back to the
|
|
269
|
+
* manifest bookends alone (structural shape on run manifests predates
|
|
270
|
+
* this field; old pinned specs simply carry no binding). */
|
|
271
|
+
treeOid?: string;
|
|
272
|
+
/** W2: tracked-clean probe at dispatch (status --porcelain -uno empty).
|
|
273
|
+
* Fallback binding when no treeOid is available: a clean-at-dispatch tree
|
|
274
|
+
* that is dirty at gate time is drift. Absent when the probe failed. */
|
|
275
|
+
cleanTree?: boolean;
|
|
231
276
|
}
|
|
232
277
|
|
|
233
278
|
/** HOST-SIDE (dispatch) — build the verify spec for `cwd` (the project root
|
|
@@ -238,7 +283,17 @@ export function resolveHostVerifyGateSpec(cwd: string): VerifyGateHostSpec | und
|
|
|
238
283
|
const snapshot = snapshotManifests(cwd);
|
|
239
284
|
const checks = defaultVerifyGateChecks(cwd);
|
|
240
285
|
if (Object.keys(snapshot).length === 0 && checks.length === 0) return undefined;
|
|
241
|
-
|
|
286
|
+
// W2: bind the TRACKED working tree AT DISPATCH (host process, pre-worker)
|
|
287
|
+
// so the gate can prove the task cwd still contains what the host pinned —
|
|
288
|
+
// non-manifest tracked files included. Unavailable measurements are omitted
|
|
289
|
+
// (never guessed): the gate then acts on the manifest bookends alone.
|
|
290
|
+
const binding = captureWorkingTreeBinding(cwd);
|
|
291
|
+
return {
|
|
292
|
+
snapshot,
|
|
293
|
+
checks,
|
|
294
|
+
...(binding.oid !== undefined ? { treeOid: binding.oid } : {}),
|
|
295
|
+
...(binding.clean !== undefined ? { cleanTree: binding.clean } : {}),
|
|
296
|
+
};
|
|
242
297
|
}
|
|
243
298
|
|
|
244
299
|
/** Gate-time integrity probe (M1-7): re-hash the manifests in `cwd` and diff
|
|
@@ -250,6 +305,38 @@ export function detectHostSpecDrift(cwd: string, spec: VerifyGateHostSpec): stri
|
|
|
250
305
|
return compareSnapshot(spec.snapshot, snapshotManifests(cwd));
|
|
251
306
|
}
|
|
252
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Gate-time tree probe (W2, nonmanifest-ground-truth-self-grading): re-derive
|
|
310
|
+
* the tracked working tree of `cwd` and compare it against the dispatch
|
|
311
|
+
* binding. Returns a drift note when the gate cwd can NOT be proven
|
|
312
|
+
* bit-identical to the tree the host pinned — changed tracked content, or an
|
|
313
|
+
* unmeasurable tree while a binding was pinned (fail closed on missing
|
|
314
|
+
* evidence) — and undefined when no binding was pinned or equality holds.
|
|
315
|
+
* Untracked files are deliberately NOT part of the binding (they legitimately
|
|
316
|
+
* differ between workspaces; the memo separately refuses dirty trees).
|
|
317
|
+
*/
|
|
318
|
+
export function detectWorkingTreeDrift(cwd: string, spec: VerifyGateHostSpec): string | undefined {
|
|
319
|
+
const binding = captureWorkingTreeBinding(cwd);
|
|
320
|
+
if (spec.treeOid !== undefined) {
|
|
321
|
+
if (binding.oid === undefined) {
|
|
322
|
+
return "working-tree-integrity: the tracked working tree could not be measured (git unavailable or failed) — the dispatch-pinned tree cannot be proven intact";
|
|
323
|
+
}
|
|
324
|
+
if (binding.oid !== spec.treeOid) {
|
|
325
|
+
return "working-tree-integrity: tracked files changed since the host snapshot taken at dispatch (inspect with git status / git diff)";
|
|
326
|
+
}
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
if (spec.cleanTree === true) {
|
|
330
|
+
if (binding.clean === undefined) {
|
|
331
|
+
return "working-tree-integrity: the working tree could not be probed for changes (git unavailable or failed) — the dispatch-time clean state cannot be proven intact";
|
|
332
|
+
}
|
|
333
|
+
if (binding.clean === false) {
|
|
334
|
+
return "working-tree-integrity: tracked files are modified in the gate's workspace but the tree was clean at dispatch";
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
253
340
|
function tailOutput(output: string): string {
|
|
254
341
|
if (output.length <= OUTPUT_TAIL_MAX_CHARS) return output;
|
|
255
342
|
return `…${output.slice(-OUTPUT_TAIL_MAX_CHARS)}`;
|
|
@@ -554,11 +641,13 @@ export function renderVerifyGateContext(outcome: VerifyGateOutcome): string {
|
|
|
554
641
|
const executorDeath = outcome.checks.some((c) => !c.skipped && c.exitCode === null);
|
|
555
642
|
const trailer = outcome.integrityNote
|
|
556
643
|
? "Manifest integrity check FAILED: project manifest files changed since the host snapshot taken at dispatch. These gate results and any transcript claims about build/test state are UNTRUSTED — inspect what changed and re-run the verification commands yourself before concluding."
|
|
557
|
-
:
|
|
558
|
-
? "
|
|
559
|
-
:
|
|
560
|
-
? "
|
|
561
|
-
:
|
|
644
|
+
: outcome.worktreeIntegrityNote
|
|
645
|
+
? "Working-tree integrity check FAILED: tracked files in the gate's workspace changed since the host snapshot taken at dispatch (or the tree could not be proven identical to it). These gate results and any transcript claims about build/test state are UNTRUSTED — inspect what changed (git status / git diff) and re-run the verification commands yourself before concluding."
|
|
646
|
+
: executorDeath
|
|
647
|
+
? "A verification command could not run (executor error — exit=err). Treat the build/test state as UNKNOWN; run the build/tests yourself before concluding."
|
|
648
|
+
: outcome.determinismNote
|
|
649
|
+
? "The gate rerun detected NONDETERMINISM (a check passed once and failed on rerun). These gate results are NOT reliable — re-run the affected command yourself and treat the build/test state as UNKNOWN."
|
|
650
|
+
: "The deterministic gate already ran these commands — do NOT re-run them; verify the WORK (diff, files, acceptance), using these results as ground truth for build/test state.";
|
|
562
651
|
lines.push("", trailer);
|
|
563
652
|
return lines.join("\n");
|
|
564
653
|
}
|
|
@@ -568,6 +657,81 @@ export function renderVerifyGateSummary(outcome: VerifyGateOutcome, reason: stri
|
|
|
568
657
|
return [`verify_gate: ${outcome.verdict} (skip reason: ${reason})`, ...outcome.evidenceLines].join("\n");
|
|
569
658
|
}
|
|
570
659
|
|
|
660
|
+
// ─── W2 finding worktree-verifier-gate-excludes-work: guard probes ──────────
|
|
661
|
+
|
|
662
|
+
/** One probe per dependency task whose work the verifier is meant to certify.
|
|
663
|
+
* `worktreePath` absent ⇒ shared-cwd workspace — that task's work lives IN
|
|
664
|
+
* the gate's own cwd and needs no probe. */
|
|
665
|
+
export interface VerifierGateWorkProbe {
|
|
666
|
+
taskId: string;
|
|
667
|
+
worktreePath?: string;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Dirty probe for a task worktree: TRUE when `git status --porcelain` is
|
|
671
|
+
* non-empty (tracked modifications AND untracked files — an executor's
|
|
672
|
+
* typical output is new untracked files; build outputs stay gitignored) or
|
|
673
|
+
* when the probe FAILS (missing/corrupted worktree — fail closed:
|
|
674
|
+
* unverifiable work must reach the LLM verifier, never a silent skip).
|
|
675
|
+
* w9-n4 follow-up: routed through the MED-1 composition (hardenGitArgs +
|
|
676
|
+
* buildGitSafeEnv + assertWorktreePointerTrusted) with the same deliberate
|
|
677
|
+
* deviation as verify-gate-memo.ts — the pointer-trust assert sits INSIDE
|
|
678
|
+
* the try so a corrupted worktree converts to dirty=true (fail closed)
|
|
679
|
+
* instead of throwing into the gate flow. */
|
|
680
|
+
export function worktreeHasUncommittedWork(worktreePath: string): boolean {
|
|
681
|
+
try {
|
|
682
|
+
assertWorktreePointerTrusted(worktreePath);
|
|
683
|
+
const st = execFileSync("git", hardenGitArgs(["status", "--porcelain"]), {
|
|
684
|
+
cwd: worktreePath,
|
|
685
|
+
env: buildGitSafeEnv("en_US.UTF-8"),
|
|
686
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
687
|
+
encoding: "utf-8",
|
|
688
|
+
timeout: 10_000,
|
|
689
|
+
windowsHide: true,
|
|
690
|
+
});
|
|
691
|
+
return st.trim().length > 0;
|
|
692
|
+
} catch {
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Dependency task ids whose worktree carries uncommitted work that the
|
|
698
|
+
* gate's evaluated tree does not contain. Probes without a worktree path are
|
|
699
|
+
* skipped (shared-cwd work is inside the gate cwd itself). */
|
|
700
|
+
export function findTasksWithWorktreeWork(probes: VerifierGateWorkProbe[]): string[] {
|
|
701
|
+
const dirty: string[] = [];
|
|
702
|
+
for (const probe of probes) {
|
|
703
|
+
if (!probe.worktreePath) continue;
|
|
704
|
+
if (worktreeHasUncommittedWork(probe.worktreePath)) dirty.push(probe.taskId);
|
|
705
|
+
}
|
|
706
|
+
return dirty;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* W2 guard (worktree-verifier-gate-excludes-work): a gate PASS — fresh or
|
|
711
|
+
* memoized — may only suppress the LLM verifier when the checks evaluated the
|
|
712
|
+
* work under verification. In worktree mode the gate runs in the verifier's
|
|
713
|
+
* own pristine tree while the executor's changes live in a different
|
|
714
|
+
* worktree; a PASS there certifies the untouched base. When any probed
|
|
715
|
+
* dependency worktree carries uncommitted work, the skip is refused and the
|
|
716
|
+
* LLM verifier spawns with the guard evidence (reason
|
|
717
|
+
* "unverified_worktree_work"). Non-skip decisions pass through untouched.
|
|
718
|
+
*/
|
|
719
|
+
function resolveWorktreeGuardDecision(
|
|
720
|
+
probes: VerifierGateWorkProbe[] | undefined,
|
|
721
|
+
outcome: VerifyGateOutcome,
|
|
722
|
+
): { outcome: VerifyGateOutcome; decision: VerifierSpawnDecision; forcedSpawn: boolean } {
|
|
723
|
+
const decision = resolveVerifierSpawnDecision(outcome);
|
|
724
|
+
if (!decision.skipVerifier || !probes || probes.length === 0) return { outcome, decision, forcedSpawn: false };
|
|
725
|
+
const dirtyDeps = findTasksWithWorktreeWork(probes);
|
|
726
|
+
if (dirtyDeps.length === 0) return { outcome, decision, forcedSpawn: false };
|
|
727
|
+
const note = `worktree-guard: dependency task(s) ${dirtyDeps.join(", ")} carry uncommitted changes in their worktrees — the gate evaluated a tree that does not contain that work; refusing the deterministic skip`;
|
|
728
|
+
return {
|
|
729
|
+
outcome: { ...outcome, evidenceLines: [...outcome.evidenceLines, note] },
|
|
730
|
+
decision: { skipVerifier: false, reason: "unverified_worktree_work", attachGateContext: true },
|
|
731
|
+
forcedSpawn: true,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
571
735
|
export interface VerifierGatePreGateInput {
|
|
572
736
|
cwd: string;
|
|
573
737
|
/** Explicit checks take precedence over every other source (tool path /
|
|
@@ -599,6 +763,13 @@ export interface VerifierGatePreGateInput {
|
|
|
599
763
|
/** PHASE 4 (U13-ADJ): explicit layer-3 determinism toggle — env
|
|
600
764
|
* PI_CREW_VERIFY_DETERMINISM beats this; both default OFF. */
|
|
601
765
|
determinism?: boolean;
|
|
766
|
+
/** W2 (worktree-verifier-gate-excludes-work): one probe per dependency task
|
|
767
|
+
* whose work this verifier certifies (taskId + its worktree path). When any
|
|
768
|
+
* probed worktree carries uncommitted changes, a gate PASS — fresh OR
|
|
769
|
+
* memoized — refuses the skipVerifier decision (the gate evaluated a tree
|
|
770
|
+
* that structurally excludes that work) and the LLM verifier spawns with
|
|
771
|
+
* the guard note (reason "unverified_worktree_work"). */
|
|
772
|
+
dependencyWorktreeProbes?: VerifierGateWorkProbe[];
|
|
602
773
|
}
|
|
603
774
|
|
|
604
775
|
export interface VerifierGatePreGateResult {
|
|
@@ -656,6 +827,39 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
|
|
|
656
827
|
return { outcome, decision, promptContext: renderVerifyGateContext(outcome) };
|
|
657
828
|
}
|
|
658
829
|
}
|
|
830
|
+
// ── W2 (nonmanifest-ground-truth-self-grading): tree binding BEFORE any
|
|
831
|
+
// command runs ────────────────────────────────────────────────────────
|
|
832
|
+
// The manifest bookends pin only the 8 manifest files, but the pinned
|
|
833
|
+
// checks execute whatever harness the WORKING TREE resolves (runners,
|
|
834
|
+
// shims, test files). When the host pinned a dispatch-time tree binding,
|
|
835
|
+
// any tracked divergence — or an unmeasurable tree — means the checks
|
|
836
|
+
// would execute content the host never pinned: the self-grading exploit.
|
|
837
|
+
// Fail the gate to the LLM verifier spawn (never a skip) before running
|
|
838
|
+
// anything, exactly like the manifest drift path above.
|
|
839
|
+
if (input.hostSpec) {
|
|
840
|
+
const treeDriftNote = detectWorkingTreeDrift(input.cwd, input.hostSpec);
|
|
841
|
+
if (treeDriftNote) {
|
|
842
|
+
const outcome: VerifyGateOutcome = {
|
|
843
|
+
verdict: "FAILED",
|
|
844
|
+
checks: [],
|
|
845
|
+
evidenceLines: [treeDriftNote],
|
|
846
|
+
allPassed: false,
|
|
847
|
+
totalDurationMs: 0,
|
|
848
|
+
worktreeIntegrityNote: treeDriftNote,
|
|
849
|
+
};
|
|
850
|
+
const decision = resolveVerifierSpawnDecision(outcome);
|
|
851
|
+
incMetric(input.metricRegistry, "crew.verification.verify_gate_runs_total", "Deterministic verify_gate executions by verdict", {
|
|
852
|
+
verdict: outcome.verdict,
|
|
853
|
+
});
|
|
854
|
+
incMetric(
|
|
855
|
+
input.metricRegistry,
|
|
856
|
+
"crew.verification.verifier_spawns_total",
|
|
857
|
+
"LLM verifier spawns that still ran, labeled by the deciding layer",
|
|
858
|
+
{ reason: decision.reason },
|
|
859
|
+
);
|
|
860
|
+
return { outcome, decision, promptContext: renderVerifyGateContext(outcome) };
|
|
861
|
+
}
|
|
862
|
+
}
|
|
659
863
|
// ── M1-7: NO package.json fallback ─────────────────────────────────────
|
|
660
864
|
// Checks come from the caller or the dispatch-pinned host spec ONLY —
|
|
661
865
|
// nothing here reads the worker-mutable task cwd. Zero resolvable checks
|
|
@@ -670,9 +874,12 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
|
|
|
670
874
|
if (resolveVerifyGateMemoEnabled()) {
|
|
671
875
|
const memoHit = lookupVerifyGateMemo(input.cwd, checks);
|
|
672
876
|
if (memoHit) {
|
|
673
|
-
|
|
877
|
+
// W2: the guard runs on memo hits too — a recorded PASS for a pristine
|
|
878
|
+
// tree must not skip the verifier when the verified dependency work
|
|
879
|
+
// lives outside this tree (the original finding-3 memo-replay PoC).
|
|
880
|
+
const guarded = resolveWorktreeGuardDecision(input.dependencyWorktreeProbes, memoHit);
|
|
674
881
|
incMetric(input.metricRegistry, "crew.verification.verify_gate_runs_total", "Deterministic verify_gate executions by verdict", {
|
|
675
|
-
verdict:
|
|
882
|
+
verdict: guarded.outcome.verdict,
|
|
676
883
|
});
|
|
677
884
|
incMetric(
|
|
678
885
|
input.metricRegistry,
|
|
@@ -680,22 +887,36 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
|
|
|
680
887
|
"W10-2 memoized PASS verdicts served without re-running checks",
|
|
681
888
|
{},
|
|
682
889
|
);
|
|
683
|
-
if (
|
|
890
|
+
if (guarded.forcedSpawn) {
|
|
891
|
+
incMetric(
|
|
892
|
+
input.metricRegistry,
|
|
893
|
+
"crew.verification.verifier_gate_worktree_guard_total",
|
|
894
|
+
"W2: PASS-skip refusals — dependency worktree work the gate's evaluated tree does not contain",
|
|
895
|
+
{ result: "spawn_forced" },
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
if (guarded.decision.skipVerifier) {
|
|
684
899
|
incMetric(
|
|
685
900
|
input.metricRegistry,
|
|
686
901
|
"crew.verification.verifier_spawns_skipped_total",
|
|
687
902
|
"LLM verifier spawns saved by the U13 deterministic pre-gate",
|
|
688
|
-
{ reason: decision.reason },
|
|
903
|
+
{ reason: guarded.decision.reason },
|
|
689
904
|
);
|
|
690
905
|
} else {
|
|
691
906
|
incMetric(
|
|
692
907
|
input.metricRegistry,
|
|
693
908
|
"crew.verification.verifier_spawns_total",
|
|
694
909
|
"LLM verifier spawns that still ran, labeled by the deciding layer",
|
|
695
|
-
{ reason: decision.reason },
|
|
910
|
+
{ reason: guarded.decision.reason },
|
|
696
911
|
);
|
|
697
912
|
}
|
|
698
|
-
return {
|
|
913
|
+
return {
|
|
914
|
+
outcome: guarded.outcome,
|
|
915
|
+
decision: guarded.decision,
|
|
916
|
+
// A guard-forced spawn must carry the WHY into the verifier's prompt
|
|
917
|
+
// (plain memo hits normally render no context at all).
|
|
918
|
+
promptContext: guarded.forcedSpawn ? renderVerifyGateContext(guarded.outcome) : "",
|
|
919
|
+
};
|
|
699
920
|
}
|
|
700
921
|
}
|
|
701
922
|
const exec = createSubprocessVerifyGateExecutor();
|
|
@@ -743,6 +964,24 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
|
|
|
743
964
|
};
|
|
744
965
|
}
|
|
745
966
|
}
|
|
967
|
+
// ── W2 bookend: tree binding AFTER a green gate (mirrors the manifest
|
|
968
|
+
// bookend above) ─────────────────────────────────────────────────────
|
|
969
|
+
// A PASS is only trustworthy if the tracked tree that produced it is STILL
|
|
970
|
+
// the dispatch-pinned tree when the gate finishes — a mid-gate harness
|
|
971
|
+
// swap (concurrent writer in a shared cwd) downgrades the verdict to
|
|
972
|
+
// INCONCLUSIVE so the LLM verifier spawns. Downgrade-only, like above.
|
|
973
|
+
if (input.hostSpec && outcome.verdict === "PASS") {
|
|
974
|
+
const treeDriftNote = detectWorkingTreeDrift(input.cwd, input.hostSpec);
|
|
975
|
+
if (treeDriftNote) {
|
|
976
|
+
const note = `${treeDriftNote} (during the gate run)`;
|
|
977
|
+
outcome = {
|
|
978
|
+
...outcome,
|
|
979
|
+
verdict: "INCONCLUSIVE",
|
|
980
|
+
evidenceLines: [...outcome.evidenceLines, note],
|
|
981
|
+
worktreeIntegrityNote: note,
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
}
|
|
746
985
|
// ── M3-28 (R47-V2, fix-wave 6): the U6B classifier layer is NOT consulted ──
|
|
747
986
|
// The consult used to live here, guarded by `outcome.verdict === "INCONCLUSIVE"`
|
|
748
987
|
// and fed the literal gate verdict. That input can never be pass-shaped:
|
|
@@ -771,7 +1010,20 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
|
|
|
771
1010
|
);
|
|
772
1011
|
}
|
|
773
1012
|
const preGate: VerifierPreGateResult | undefined = undefined;
|
|
774
|
-
|
|
1013
|
+
// W2: the guard runs on fresh verdicts too — a green gate in the verifier's
|
|
1014
|
+
// own pristine worktree must not skip the verifier when the dependency work
|
|
1015
|
+
// it certifies lives in other, uncommitted worktrees.
|
|
1016
|
+
const guarded = resolveWorktreeGuardDecision(input.dependencyWorktreeProbes, outcome);
|
|
1017
|
+
if (guarded.forcedSpawn) {
|
|
1018
|
+
incMetric(
|
|
1019
|
+
input.metricRegistry,
|
|
1020
|
+
"crew.verification.verifier_gate_worktree_guard_total",
|
|
1021
|
+
"W2: PASS-skip refusals — dependency worktree work the gate's evaluated tree does not contain",
|
|
1022
|
+
{ result: "spawn_forced" },
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
outcome = guarded.outcome;
|
|
1026
|
+
const decision = guarded.decision;
|
|
775
1027
|
incMetric(input.metricRegistry, "crew.verification.verify_gate_runs_total", "Deterministic verify_gate executions by verdict", {
|
|
776
1028
|
verdict: outcome.verdict,
|
|
777
1029
|
});
|
|
@@ -46,10 +46,20 @@
|
|
|
46
46
|
* non-Linux platforms where /proc startTime is unavailable.
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
|
-
import { createHash } from "node:crypto";
|
|
50
|
-
import {
|
|
49
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
50
|
+
import {
|
|
51
|
+
closeSync,
|
|
52
|
+
existsSync,
|
|
53
|
+
mkdirSync,
|
|
54
|
+
openSync,
|
|
55
|
+
readdirSync,
|
|
56
|
+
readFileSync,
|
|
57
|
+
renameSync,
|
|
58
|
+
statSync,
|
|
59
|
+
unlinkSync,
|
|
60
|
+
writeFileSync,
|
|
61
|
+
} from "node:fs";
|
|
51
62
|
import * as path from "node:path";
|
|
52
|
-
import { atomicWriteJson } from "../state/atomic-write.ts";
|
|
53
63
|
import { projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
|
|
54
64
|
|
|
55
65
|
/** Heartbeat staleness threshold (ms). Default 60s per RFC §P1g. */
|
|
@@ -143,7 +153,22 @@ export function workspaceLockPath(cwd: string): string {
|
|
|
143
153
|
return path.join(locksDir, `${hash}.lock`);
|
|
144
154
|
}
|
|
145
155
|
|
|
146
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* Observe a file's on-disk identity (device + inode). Returns undefined when
|
|
158
|
+
* the file cannot be stated (missing, permission...).
|
|
159
|
+
*/
|
|
160
|
+
function statLockIdentity(lockPath: string): { dev: number; ino: number } | undefined {
|
|
161
|
+
try {
|
|
162
|
+
const stat = statSync(lockPath);
|
|
163
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read + parse a lockfile. Returns undefined if missing/corrupt.
|
|
171
|
+
*/
|
|
147
172
|
function readLock(lockPath: string): WorkspaceLockContents | undefined {
|
|
148
173
|
if (!existsSync(lockPath)) return undefined;
|
|
149
174
|
try {
|
|
@@ -162,16 +187,6 @@ function readLock(lockPath: string): WorkspaceLockContents | undefined {
|
|
|
162
187
|
}
|
|
163
188
|
}
|
|
164
189
|
|
|
165
|
-
/**
|
|
166
|
-
* Write the lockfile atomically (temp+rename+fsync via atomicWriteJson).
|
|
167
|
-
* Used for HEARTBEAT refresh only (a claim that already owns the lock is refreshing its
|
|
168
|
-
* timestamp — overwrite is correct because the owner verified ownership first).
|
|
169
|
-
*/
|
|
170
|
-
function writeLock(lockPath: string, contents: WorkspaceLockContents): void {
|
|
171
|
-
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
172
|
-
atomicWriteJson(lockPath, contents);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
190
|
export interface RefreshWorkspaceLockHeartbeatOptions {
|
|
176
191
|
/** Test injection: override current time (ms). Default Date.now(). */
|
|
177
192
|
now?: () => number;
|
|
@@ -191,24 +206,83 @@ export interface RefreshWorkspaceLockHeartbeatOptions {
|
|
|
191
206
|
* safeRelease). This is what keeps a >60s-running goal's lock from being
|
|
192
207
|
* force-stolen via isLockStale clause (b) despite a live pid.
|
|
193
208
|
*
|
|
209
|
+
* W6 fix (refresh-clobber-steal): the refresh write is a CONDITIONAL replace
|
|
210
|
+
* (compare-and-swap on file identity + bytes), never an unconditional
|
|
211
|
+
* temp+rename. The (dev, ino) identity AND exact bytes observed at read time
|
|
212
|
+
* are re-verified immediately before the rename; if a concurrent stale-steal
|
|
213
|
+
* claimed the lock between our read and our rename (the R3-M-B1/FS4 race),
|
|
214
|
+
* the observed identity/bytes no longer match → the temp file is discarded,
|
|
215
|
+
* the file is NOT touched, and false is returned — the refresh can never
|
|
216
|
+
* replace a file it did not observe at read time (the pre-fix rename was
|
|
217
|
+
* identity-blind and silently clobbered the stealer's verified claim). The
|
|
218
|
+
* post-write re-read below is kept as belt-and-braces for the mirror-image
|
|
219
|
+
* race (someone replacing the file AFTER our rename).
|
|
220
|
+
*
|
|
194
221
|
* Non-throwing by contract: failures surface as false, never as an exception
|
|
195
222
|
* into the caller's loop (the goal-loop still wraps calls in try/catch →
|
|
196
223
|
* logInternalError warn as belt-and-braces, see goal-loop-runner.ts).
|
|
197
224
|
*/
|
|
198
225
|
export function refreshWorkspaceLockHeartbeat(lockPath: string, goalId: string, opts: RefreshWorkspaceLockHeartbeatOptions = {}): boolean {
|
|
199
226
|
try {
|
|
227
|
+
// Observe identity AND bytes together at read time. A plain readLock()
|
|
228
|
+
// gives content but no file identity; the identity is what makes the
|
|
229
|
+
// pre-rename re-check below a real compare-and-swap.
|
|
230
|
+
let rawObserved: string;
|
|
231
|
+
try {
|
|
232
|
+
rawObserved = readFileSync(lockPath, "utf-8");
|
|
233
|
+
} catch {
|
|
234
|
+
return false; // missing/unreadable — nothing to refresh
|
|
235
|
+
}
|
|
236
|
+
const observedIdentity = statLockIdentity(lockPath);
|
|
237
|
+
if (!observedIdentity) return false;
|
|
200
238
|
const current = readLock(lockPath);
|
|
201
239
|
if (!current) return false;
|
|
202
240
|
const pid = opts.pid ?? process.pid;
|
|
203
241
|
if (current.goalId !== goalId || current.pid !== pid) return false;
|
|
204
242
|
const now = opts.now ?? Date.now;
|
|
205
|
-
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
243
|
+
// Write the refreshed payload to a SIBLING temp file (same directory →
|
|
244
|
+
// same filesystem → rename is atomic). Only the pre-rename CAS below
|
|
245
|
+
// decides whether the rename happens; nothing has touched the lock path
|
|
246
|
+
// up to that point.
|
|
247
|
+
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
248
|
+
const tempPath = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
249
|
+
writeFileSync(tempPath, JSON.stringify({ ...current, heartbeat: now() }), "utf-8");
|
|
250
|
+
try {
|
|
251
|
+
// CAS gate: rename ONLY if the lock path still holds BOTH the observed
|
|
252
|
+
// (dev, ino) identity AND the observed bytes. A stealer's unlink→O_EXCL
|
|
253
|
+
// claim in between produces a new inode with different (goalId-bearing)
|
|
254
|
+
// bytes → both checks miss → refuse (never clobber the new owner).
|
|
255
|
+
const identityNow = statLockIdentity(lockPath);
|
|
256
|
+
let rawNow: string | undefined;
|
|
257
|
+
try {
|
|
258
|
+
rawNow = readFileSync(lockPath, "utf-8");
|
|
259
|
+
} catch {
|
|
260
|
+
rawNow = undefined;
|
|
261
|
+
}
|
|
262
|
+
if (
|
|
263
|
+
!identityNow ||
|
|
264
|
+
rawNow !== rawObserved ||
|
|
265
|
+
identityNow.dev !== observedIdentity.dev ||
|
|
266
|
+
identityNow.ino !== observedIdentity.ino
|
|
267
|
+
) {
|
|
268
|
+
// R3-M-B1/FS4 (Wave-3) + W6: the lock was stolen/re-acquired between
|
|
269
|
+
// our read and our rename — leave the file untouched and report
|
|
270
|
+
// ownership lost so the heartbeat loop blocks the goal (never
|
|
271
|
+
// silently keep refreshing a lock another goal owns).
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
renameSync(tempPath, lockPath);
|
|
275
|
+
} finally {
|
|
276
|
+
// Best-effort temp cleanup: after a successful rename the temp is gone
|
|
277
|
+
// (ENOENT — swallowed); after a refused CAS this is what removes it.
|
|
278
|
+
try {
|
|
279
|
+
unlinkSync(tempPath);
|
|
280
|
+
} catch {
|
|
281
|
+
/* already renamed away or never existed */
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Post-write re-read: someone may STILL have replaced the file after our
|
|
285
|
+
// rename — if it no longer holds our goalId+pid we lost ownership.
|
|
212
286
|
const reread = readLock(lockPath);
|
|
213
287
|
if (!reread || reread.goalId !== goalId || reread.pid !== pid) return false;
|
|
214
288
|
return true;
|
|
@@ -158,7 +158,17 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
|
|
|
158
158
|
),
|
|
159
159
|
),
|
|
160
160
|
// U11 (hybrid 2-tier worker model): per-role embedded-tier opt-in.
|
|
161
|
-
// DEFAULT-OFF
|
|
161
|
+
// DEFAULT-OFF. USER-ONLY (sensitive mark, w4b security fix): this is the
|
|
162
|
+
// THIRD in-process routing knob alongside runtime.mode and
|
|
163
|
+
// runtime.isolationPolicy (both already sensitive). An embedded-routed
|
|
164
|
+
// worker's Pi session runs IN-PROCESS in the host pi process, where the
|
|
165
|
+
// two deterministic host protections do not exist — the PI_CREW_SANDBOX=bwrap
|
|
166
|
+
// OS boundary is resolved only on the child spawn path (child-pi.ts) and
|
|
167
|
+
// the env allowlist scrub (sanitizeEnvSecrets) exists only for child envs —
|
|
168
|
+
// so enabling/role-extending it from untrusted config silently voids the
|
|
169
|
+
// user's sandbox opt-in and can expose host env/FS to bash-capable roles.
|
|
170
|
+
// The user-tier config file remains the product knob; project config and
|
|
171
|
+
// run params (same sensitive walk) may not set it.
|
|
162
172
|
embeddedWorkers: Type.Optional(
|
|
163
173
|
Type.Object(
|
|
164
174
|
{
|
|
@@ -167,7 +177,7 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
|
|
|
167
177
|
maxConcurrent: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
168
178
|
faultThreshold: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
169
179
|
},
|
|
170
|
-
{ additionalProperties: false },
|
|
180
|
+
{ additionalProperties: false, sensitive: true },
|
|
171
181
|
),
|
|
172
182
|
),
|
|
173
183
|
modelFallback: Type.Optional(PiTeamsModelFallbackConfigSchema),
|