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
@@ -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
- return { snapshot, checks };
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
- : executorDeath
558
- ? "A verification command could not run (executor error — exit=err). Treat the build/test state as UNKNOWN; run the build/tests yourself before concluding."
559
- : outcome.determinismNote
560
- ? "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."
561
- : "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.";
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
- const decision = resolveVerifierSpawnDecision(memoHit);
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: memoHit.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 (decision.skipVerifier) {
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 { outcome: memoHit, decision, promptContext: "" };
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
- const decision = resolveVerifierSpawnDecision(outcome, preGate);
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 { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
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
- /** Read + parse a lockfile. Returns undefined if missing/corrupt. */
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
- writeLock(lockPath, { ...current, heartbeat: now() });
206
- // R3-M-B1/FS4 (Wave-3): writeLock is temp+rename (NOT atomic against a
207
- // concurrent stale-steal's unlink→O_EXCL) — a stealer may have replaced
208
- // the lock between our read and our rename, and our rename then clobbered
209
- // THEIR file. Re-read and verify: if the file no longer holds our
210
- // goalId+pid we lost ownership — return false so the heartbeat loop blocks
211
- // the goal (never silently keep refreshing a lock another goal owns).
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 — behavior pick, not authority (no sensitive mark).
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),