pi-crew 0.11.11 → 0.11.13

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 (50) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/index.mjs +551 -519
  3. package/package.json +1 -1
  4. package/src/config/config-validation.ts +9 -0
  5. package/src/config/defaults.ts +7 -0
  6. package/src/config/env-vars.ts +24 -0
  7. package/src/config/types.ts +11 -0
  8. package/src/extension/registration/tool-registration.ts +7 -0
  9. package/src/extension/registration/verify-gate-tool.ts +263 -0
  10. package/src/extension/team-tool/run-deadline.ts +22 -1
  11. package/src/extension/team-tool/run.ts +38 -2
  12. package/src/extension/team-tool-types.ts +4 -0
  13. package/src/extension/team-tool.ts +31 -3
  14. package/src/prompt/inbox-poll.ts +141 -3
  15. package/src/runtime/broker/crew-broker.ts +48 -2
  16. package/src/runtime/broker/protocol/lock-busy.ts +47 -1
  17. package/src/runtime/broker/protocol/request-parsers.ts +8 -1
  18. package/src/runtime/budget-enforcement.ts +36 -20
  19. package/src/runtime/child-pi/child-pi-constants.ts +24 -0
  20. package/src/runtime/child-pi/child-pi-kill.ts +59 -2
  21. package/src/runtime/child-pi/child-pi-rpc.ts +1021 -0
  22. package/src/runtime/child-pi/child-pi-steering.ts +10 -8
  23. package/src/runtime/child-pi/child-pi-streams.ts +69 -19
  24. package/src/runtime/child-pi/child-pi.ts +29 -30
  25. package/src/runtime/child-pi/owned-process.ts +434 -0
  26. package/src/runtime/child-pi/postmortem-registry.ts +134 -0
  27. package/src/runtime/classifier/ambient-noise-gate.ts +126 -0
  28. package/src/runtime/classifier/classifier-service.ts +277 -61
  29. package/src/runtime/dispatch-batch.ts +32 -0
  30. package/src/runtime/live-session/live-agent-control.ts +2 -0
  31. package/src/runtime/live-session/live-agent-manager.ts +72 -2
  32. package/src/runtime/live-session/live-session-runtime.ts +435 -40
  33. package/src/runtime/merge-loop.ts +63 -35
  34. package/src/runtime/recovery/crash-recovery.ts +141 -3
  35. package/src/runtime/recovery/retry-executor.ts +29 -0
  36. package/src/runtime/run-tracker.ts +28 -0
  37. package/src/runtime/stale-reconciler.ts +36 -1
  38. package/src/runtime/task-runner.ts +103 -0
  39. package/src/runtime/verification/classifier-pre-gate.ts +262 -0
  40. package/src/runtime/verification/verification-gates.ts +5 -1
  41. package/src/runtime/verification/verify-gate.ts +394 -0
  42. package/src/schema/config-schema.ts +13 -0
  43. package/src/state/contracts.ts +18 -0
  44. package/src/state/coordination/mailbox.ts +203 -55
  45. package/src/state/stores/sqlite-run-state.ts +378 -0
  46. package/src/state/stores/state-store.ts +134 -6
  47. package/src/ui/agents-jobs-browser.ts +154 -8
  48. package/src/ui/events-state-source.ts +358 -0
  49. package/src/ui/run-snapshot-cache.ts +87 -3
  50. package/src/ui/widget/widget-model.ts +13 -2
@@ -0,0 +1,262 @@
1
+ /**
2
+ * U6B (upgrade spec 2026-10-09, §U6 Phase B): classifier VERIFIER PRE-GATE —
3
+ * the third consumer of the P2-1 host classifier seam, alongside retry-triage
4
+ * (child-executor.ts) and the ambient-noise gate (ambient-noise-gate.ts).
5
+ *
6
+ * Semantics: BEFORE an LLM verifier is spawned, the already-produced
7
+ * STRUCTURED verdict state is scored by the cheap host classifier — ONE
8
+ * `score` question (classifyScore, the graded sibling of classifyBool added
9
+ * by U6B) over JSON state ONLY:
10
+ * { verdict, evidenceLines, changedFiles }
11
+ * The classifier NEVER sees a diff, file contents, or prose transcripts — it
12
+ * judges whether the structured state alone is decisive enough to skip the
13
+ * LLM verifier spawn ("verdict được pre-gate chặn"). Every other outcome
14
+ * escalates to the LLM verifier, exactly the pre-U6B spawn decision.
15
+ *
16
+ * ESCALATION POLICY (spec §U6 Phase B, mandatory — "escalate LLM khi
17
+ * confidence thấp hoặc verdict FAILED"):
18
+ * - verdict failure-shaped (FAILED/error/blocked/...) or not clearly
19
+ * pass-shaped (INCONCLUSIVE/blank) → escalate (a cheap classifier may
20
+ * never rubber-stamp a failure or an ambiguous verdict);
21
+ * - provider confidence below BLOCK_CONFIDENCE_THRESHOLD (or unreported)
22
+ * → escalate;
23
+ * - score below BLOCK_SCORE_THRESHOLD (or outside 0..1) → escalate;
24
+ * - EVERY classifier failure mode (registry missing, `opencode` provider
25
+ * unconfigured → getAvailableOfType("classifier") = [], classify threw,
26
+ * stopReason error, answer missing) → escalate, soft.
27
+ *
28
+ * SOFT-BRANCH CONTRACT (spec caveat, mandatory):
29
+ * - `opencode` provider is sometimes unconfigured on this host — the
30
+ * pre-gate then degrades to "always escalate" (identical to pre-U6B
31
+ * behavior);
32
+ * - a WRONG classifier verdict can only cost ONE extra escalate (the LLM
33
+ * verifier runs anyway) or skip one spawn whose structured state showed a
34
+ * high-confidence unambiguous pass — it can NEVER fail a task. This
35
+ * module never throws up to the caller.
36
+ *
37
+ * Metrics (spec acceptance: % verdicts blocked by the pre-gate, % escalate,
38
+ * zero task-fail due to classifier — flag OFF by default until data exists):
39
+ * - crew.classifier.calls_total{consumer="verifier_pre_gate",outcome} +
40
+ * crew.classifier.confidence via classifyScore (service level);
41
+ * - crew.classifier.decisions_total{consumer="verifier_pre_gate",
42
+ * decision=<action>} + a `task.verifier_pre_gate` diagnostic event ONLY
43
+ * when a real classifier answer informed the decision (fallback paths are
44
+ * already counted by the service's calls_total and must not double-report
45
+ * a decision they did not make). Percentages derive from decisions_total.
46
+ * NOTE: `task.verifier_pre_gate` is NOT yet in TEAM_EVENT_TYPES
47
+ * (src/state/contracts.ts) — src/state/** is outside U6B's file
48
+ * ownership, so registration is deferred to the U13 mount commit (see
49
+ * MOUNT POINT below); scripts/check-event-types-registry.mjs runs
50
+ * report-only today.
51
+ *
52
+ * Concurrency: the seam caps host classify at 4 parallel calls; each consumer
53
+ * (retry-triage, ambient-noise gate, this pre-gate) issues at most ONE
54
+ * concurrent call, so the aggregate stays within the budget.
55
+ *
56
+ * ── MOUNT POINT (U13 lane — this module is intentionally NOT wired yet) ──
57
+ * The verifier spawn path stays UNTOUCHED by U6B per lane ownership:
58
+ * - verification-gates.ts command-gate spawn logic: NOT modified here
59
+ * (U13 owns the mount, spec §U13);
60
+ * - goal-loop judge spawn (goal-evaluator.ts evaluateGoal → runWorker):
61
+ * NOT modified here.
62
+ * When U13 mounts the deterministic ctx.executeTool gates, insert this
63
+ * pre-gate in the decision flow:
64
+ * 1. run the deterministic gates (U13: typecheck / test:critical / grep
65
+ * evidence via ctx.executeTool) and build the structured verdict state:
66
+ * { verdict: "PASS"|"FAILED"|..., evidenceLines: [...], changedFiles: [...] };
67
+ * 2. BEFORE spawning the LLM verifier:
68
+ * const preGate = await classifyVerifierPreGate({
69
+ * enabled: resolveClassifierEnabled(runtime.classifierEnabled), // default false
70
+ * modelRegistry: ctx.modelRegistry,
71
+ * classifierModel: resolveClassifierModel(runtime.classifierModel),
72
+ * verdict, eventsPath, runId, metricRegistry,
73
+ * });
74
+ * — preGate.blockVerifier === true → SKIP the LLM verifier spawn (the
75
+ * structured pass stands; mark the verdict's evaluator/model provenance
76
+ * e.g. "classifier-pre-gate/<model>" so consumers can tell);
77
+ * — otherwise → spawn the LLM verifier as today (optionally attach the
78
+ * pre-gate score/confidence to the verifier's prompt context).
79
+ * The args/result interfaces below are the stable contract for that mount;
80
+ * changes need both lanes' review.
81
+ */
82
+ import type { MetricRegistry } from "../../observability/metric-registry.ts";
83
+ import { appendEventAsync } from "../../state/event-log/event-log.ts";
84
+ import { logInternalError } from "../../utils/internal-error.ts";
85
+ import { classifyScore } from "../classifier/classifier-service.ts";
86
+
87
+ /** Structured verdict state the classifier reasons over — JSON state ONLY
88
+ * (spec: "chỉ JSON state, KHÔNG phán diff"). Built by the deterministic
89
+ * gates (U13) / the verdict-producing step; never diffs, never file
90
+ * contents — only labels, evidence lines and file NAMES. */
91
+ export interface VerifierPreGateVerdictState {
92
+ /** Short structured verdict label, e.g. "PASS", "FAILED", "INCONCLUSIVE". */
93
+ verdict: string;
94
+ /** Structured evidence lines (command exit codes, artifact refs). Capped before classify. */
95
+ evidenceLines: string[];
96
+ /** Changed-file paths (names only). Capped before classify. */
97
+ changedFiles: string[];
98
+ }
99
+
100
+ export type VerifierPreGateAction =
101
+ | "disabled"
102
+ | "blocked"
103
+ | "escalate_verdict_failed"
104
+ | "escalate_low_confidence"
105
+ | "escalate_low_score"
106
+ | "escalate_fallback";
107
+
108
+ /** Default score at/above which a pass-shaped verdict blocks the LLM verifier spawn. */
109
+ export const VERIFIER_PRE_GATE_BLOCK_SCORE_THRESHOLD = 0.9;
110
+ /** Default provider confidence at/above which blocking is allowed. An
111
+ * unreported confidence NEVER blocks (defensive: block is the risky branch). */
112
+ export const VERIFIER_PRE_GATE_BLOCK_CONFIDENCE_THRESHOLD = 0.8;
113
+
114
+ const VERDICT_MAX_CHARS = 200;
115
+ const EVIDENCE_MAX_LINES = 8;
116
+ const EVIDENCE_LINE_MAX_CHARS = 200;
117
+ const CHANGED_FILES_MAX = 16;
118
+
119
+ const VERDICT_FAIL_RE = /\b(fail(?:ed|ure)?|error(?:ed)?|blocked|fatal|timeout|timed[- ]out|red)\b/i;
120
+ const VERDICT_PASS_RE = /\b(pass(?:ed)?|green|succeed(?:ed)?|success(?:ful)?|ok|achieved|complete(?:d)?)\b/i;
121
+
122
+ /** A verdict reaches the block path ONLY when unambiguously pass-shaped:
123
+ * matches PASS vocabulary AND carries no failure vocabulary. Explicit
124
+ * failures and ambiguous labels ("INCONCLUSIVE", blank) both escalate. */
125
+ function isPassShapedVerdict(verdict: string): boolean {
126
+ return VERDICT_PASS_RE.test(verdict) && !VERDICT_FAIL_RE.test(verdict);
127
+ }
128
+
129
+ /** Cap the classifier state — classifiers reason over small low-cardinality
130
+ * JSON; the verdict fields are worker-influenced and can be arbitrarily
131
+ * long, so every string is bounded before it reaches the classify call. */
132
+ function capVerdictState(verdict: VerifierPreGateVerdictState): Record<string, unknown> {
133
+ return {
134
+ verdict: verdict.verdict.slice(0, VERDICT_MAX_CHARS),
135
+ evidenceLines: verdict.evidenceLines.slice(0, EVIDENCE_MAX_LINES).map((line) => line.slice(0, EVIDENCE_LINE_MAX_CHARS)),
136
+ changedFiles: verdict.changedFiles.slice(0, CHANGED_FILES_MAX).map((file) => file.slice(0, EVIDENCE_LINE_MAX_CHARS)),
137
+ };
138
+ }
139
+
140
+ export interface VerifierPreGateArgs {
141
+ /** Pre-resolved gate (resolveClassifierEnabled) — false short-circuits before ANY registry touch (dormant default). */
142
+ enabled: boolean;
143
+ /** Host-process model registry (ctx.modelRegistry, threaded unknown). */
144
+ modelRegistry: unknown;
145
+ /** Pre-resolved classifier model id (resolveClassifierModel). */
146
+ classifierModel: string;
147
+ /** Structured verdict state (JSON state only — never diffs). */
148
+ verdict: VerifierPreGateVerdictState;
149
+ /** Score at/above which a pass-shaped verdict blocks the spawn (default VERIFIER_PRE_GATE_BLOCK_SCORE_THRESHOLD). */
150
+ blockScoreThreshold?: number;
151
+ /** Confidence at/above which blocking is allowed (default VERIFIER_PRE_GATE_BLOCK_CONFIDENCE_THRESHOLD). */
152
+ blockConfidenceThreshold?: number;
153
+ /** Event-log identity for the task.verifier_pre_gate diagnostic event. */
154
+ eventsPath: string;
155
+ runId: string;
156
+ /** Optional task id (the goal-loop judge is not a task — omit there). */
157
+ taskId?: string;
158
+ /** Optional host metric registry (crew.classifier.* counters/histograms). */
159
+ metricRegistry?: MetricRegistry;
160
+ }
161
+
162
+ export interface VerifierPreGateResult {
163
+ /** true → skip the LLM verifier spawn: the structured verdict stands as a pass. */
164
+ blockVerifier: boolean;
165
+ /** true ONLY when a real classifier answer informed the decision (event + decision metric emitted). */
166
+ consulted: boolean;
167
+ /** Which branch produced the outcome (metric label + event data). */
168
+ action: VerifierPreGateAction;
169
+ /** Classifier score reading, when consulted. */
170
+ score: number | undefined;
171
+ /** Provider-reported confidence, when consulted and reported. */
172
+ confidence: number | undefined;
173
+ }
174
+
175
+ /**
176
+ * Ask the verifier pre-gate. NEVER REJECTS and never throws up to the
177
+ * caller — the disabled path returns without touching the registry (zero
178
+ * behavior change: flag off by default), every classifier failure mode falls
179
+ * back to "spawn the LLM verifier" (blockVerifier false).
180
+ */
181
+ export async function classifyVerifierPreGate(args: VerifierPreGateArgs): Promise<VerifierPreGateResult> {
182
+ if (!args.enabled) {
183
+ return { blockVerifier: false, consulted: false, action: "disabled", score: undefined, confidence: undefined };
184
+ }
185
+ const scoreThreshold = args.blockScoreThreshold ?? VERIFIER_PRE_GATE_BLOCK_SCORE_THRESHOLD;
186
+ const confidenceThreshold = args.blockConfidenceThreshold ?? VERIFIER_PRE_GATE_BLOCK_CONFIDENCE_THRESHOLD;
187
+ const result = await classifyScore({
188
+ modelRegistry: args.modelRegistry,
189
+ classifierModel: args.classifierModel,
190
+ questionKey: "decisive",
191
+ question: {
192
+ type: "score",
193
+ instructions:
194
+ "A unit of work reports a STRUCTURED verification verdict (JSON state only — you see no diffs and no file contents). Spawning an expensive LLM verifier is being considered. Score how DECISIVELY the structured state ALONE establishes that the work genuinely PASSED verification.",
195
+ criteria: [
196
+ "1.0 — the evidence lines conclusively prove the pass (explicit passing command exits, concrete artifact refs) and the changed-files list matches the work's expected scope",
197
+ "0.75 — evidence is strong but at least one line is a claim rather than an artifact",
198
+ "0.5 — evidence is mixed: some lines pass, others are inconclusive",
199
+ "0.25 — evidence is thin, generic, or mostly absent",
200
+ "0.0 — the state proves nothing usable, or any line already indicates a failure",
201
+ ],
202
+ },
203
+ state: capVerdictState(args.verdict),
204
+ fallback: 0,
205
+ metricRegistry: args.metricRegistry,
206
+ metricLabels: { consumer: "verifier_pre_gate" },
207
+ });
208
+ if (!result.fromClassifier) {
209
+ // Soft: every classifier failure mode (provider unconfigured, thrown
210
+ // call, malformed answer) escalates — identical to the pre-U6B spawn
211
+ // decision. The service's calls_total already counted the outcome; no
212
+ // decision was made, so no decision metric/event (noise-gate pattern).
213
+ return { blockVerifier: false, consulted: false, action: "escalate_fallback", score: undefined, confidence: undefined };
214
+ }
215
+ let action: VerifierPreGateAction;
216
+ if (!isPassShapedVerdict(args.verdict.verdict)) {
217
+ // Spec: escalate when verdict FAILED — and defensively for anything not
218
+ // unambiguously pass-shaped. Ordered FIRST: a cheap classifier may
219
+ // never rubber-stamp a failure, whatever its score/confidence.
220
+ action = "escalate_verdict_failed";
221
+ } else if (result.confidence === undefined || result.confidence < confidenceThreshold) {
222
+ // Spec: escalate when confidence thấp. An unreported confidence counts
223
+ // as low — blocking is the risky branch and needs positive certainty.
224
+ action = "escalate_low_confidence";
225
+ } else if (!Number.isFinite(result.score) || result.score < 0 || result.score > 1 || result.score < scoreThreshold) {
226
+ // Spec: block only on a decisively high score. Out-of-range scores are
227
+ // treated as non-decisive (defensive) — malformed can only escalate.
228
+ action = "escalate_low_score";
229
+ } else {
230
+ action = "blocked";
231
+ }
232
+ const blockVerifier = action === "blocked";
233
+ try {
234
+ args.metricRegistry
235
+ ?.counter("crew.classifier.decisions_total", "Classifier-informed decisions by consumer and verdict")
236
+ .inc({ consumer: "verifier_pre_gate", decision: action });
237
+ } catch (metricError) {
238
+ logInternalError("classifier-pre-gate.metric", metricError, `decision=${action}`, "warn");
239
+ }
240
+ void appendEventAsync(args.eventsPath, {
241
+ type: "task.verifier_pre_gate",
242
+ runId: args.runId,
243
+ ...(args.taskId !== undefined ? { taskId: args.taskId } : {}),
244
+ message: blockVerifier
245
+ ? "Verifier pre-gate BLOCKED the LLM verifier spawn (structured pass stands)"
246
+ : `Verifier pre-gate escalated to the LLM verifier (${action})`,
247
+ data: {
248
+ action,
249
+ verdict: args.verdict.verdict.slice(0, VERDICT_MAX_CHARS),
250
+ score: result.score,
251
+ confidence: result.confidence,
252
+ classifierModel: result.usedModel,
253
+ configuredModel: args.classifierModel,
254
+ stopReason: result.stopReason,
255
+ evidenceLineCount: args.verdict.evidenceLines.length,
256
+ changedFileCount: args.verdict.changedFiles.length,
257
+ },
258
+ }).catch(() => {
259
+ /* no-op: best-effort diagnostic append, ignore delivery errors */
260
+ });
261
+ return { blockVerifier, consulted: true, action, score: result.score, confidence: result.confidence };
262
+ }
@@ -239,7 +239,11 @@ function validateGateCommand(command: string): void {
239
239
  validateAllowedGateCommands(normalized, command);
240
240
  }
241
241
 
242
- async function executeCommand(
242
+ /** U13 (upgrade spec 2026-10-09): exported so the deterministic verify_gate
243
+ * engine (verify-gate.ts) reuses the SAME hardened command runner the
244
+ * verifier flow already uses — command validation, sanitized env, process-
245
+ * group kill, output cap, timeout. Additive export, no behavior change. */
246
+ export async function executeCommand(
243
247
  command: string,
244
248
  cwd: string,
245
249
  timeoutMs: number = 120000,
@@ -0,0 +1,394 @@
1
+ /**
2
+ * U13 (upgrade spec 2026-10-09 §U13): DETERMINISTIC VERIFIER PRE-GATE ENGINE.
3
+ *
4
+ * The spec's probe (round 10, 13/13 checks) proved extension tools can run
5
+ * other tools deterministically in the host process via
6
+ * `ctx.executeTool("bash", ...)` — the 5th parameter of ToolDefinition.execute
7
+ * (nested id `<parent>/1`, errors arrive as isError VALUES not throws,
8
+ * `exit_code` readable in structuredContent; limits 256 nested calls / 8KB
9
+ * per call / 32KB total args). That unlocks running the verifier's
10
+ * deterministic checks (typecheck / test:critical / grep evidence lines) for
11
+ * FREE before deciding whether the expensive LLM verifier worker must spawn.
12
+ *
13
+ * TWO ENTRY POINTS share this pure engine:
14
+ * 1. the `verify_gate` TOOL (src/extension/registration/verify-gate-tool.ts)
15
+ * — runs checks via ctx.executeTool("bash", ...) in the host process,
16
+ * exposure "codemode" (hidden from the model, callable by other tools);
17
+ * 2. the verifier SPAWN SITE (task-runner.ts runTeamTask) — runs the same
18
+ * engine through the hardened subprocess runner already exported by
19
+ * verification-gates.ts (executeCommand: command validation, sanitized
20
+ * env, process-group kill, output cap, timeout).
21
+ *
22
+ * TWO-LAYER DECISION (spec: "kết hợp classifier pre-gate U6-B — deterministic
23
+ * trước, classifier sau"):
24
+ * - Layer 1 (deterministic, this module): gate PASS → SKIP the LLM verifier
25
+ * (U13 acceptance: "một run verification với gate xanh không spawn LLM
26
+ * verifier"); gate FAILED → spawn the LLM verifier WITH the gate results
27
+ * in its prompt (it must diagnose/report, not re-derive exit codes).
28
+ * - Layer 2 (classifier, U6B classifier-pre-gate.ts): consulted ONLY when
29
+ * layer 1 is INCONCLUSIVE (no checks resolved — e.g. a repo without
30
+ * typecheck/test:critical scripts). A decisively-high classifier score
31
+ * over the STRUCTURED verdict state may block the spawn; every other
32
+ * outcome escalates. A FAILED gate never reaches the classifier — a cheap
33
+ * classifier may never rubber-stamp a failure (U6B escalation policy),
34
+ * so consulting it there would only burn a call.
35
+ *
36
+ * SAFETY: a gate that cannot run (disabled by env, no checks resolved,
37
+ * executor failure) degrades to "spawn the LLM verifier as today" — the
38
+ * pre-gate can only SAVE a spawn on positive deterministic evidence, never
39
+ * fail a task on its own.
40
+ *
41
+ * Metrics (spec acceptance: "metrics số verifier job tiết kiệm"):
42
+ * - crew.verification.verify_gate_runs_total{verdict} — gate executions;
43
+ * - crew.verification.verifier_spawns_skipped_total{reason} — LLM verifier
44
+ * spawns saved (the "tiết kiệm" counter);
45
+ * - crew.verification.verifier_spawns_total{reason} — spawns that still
46
+ * happened, labeled by which layer let them through.
47
+ */
48
+ import * as fs from "node:fs";
49
+ import { getCrewEnvBool, getCrewEnvInt } from "../../config/env-vars.ts";
50
+ import type { MetricRegistry } from "../../observability/metric-registry.ts";
51
+ import type { VerifierPreGateResult } from "./classifier-pre-gate.ts";
52
+ import { classifyVerifierPreGate } from "./classifier-pre-gate.ts";
53
+ import { executeCommand } from "./verification-gates.ts";
54
+
55
+ /** One deterministic check to run. Mirrors the phase-gate shape
56
+ * (verification-gates.ts) but kept local so the tool schema can reuse it. */
57
+ export interface VerifyGateCheck {
58
+ /** Short check name (metrics/events/evidence lines). */
59
+ name: string;
60
+ /** Shell command; exit code 0 = pass. */
61
+ command: string;
62
+ /** Stop the gate on the first failure of this check (default true). */
63
+ critical?: boolean;
64
+ }
65
+
66
+ /** Result of one executed check. */
67
+ export interface VerifyGateCheckResult {
68
+ name: string;
69
+ command: string;
70
+ exitCode: number | null;
71
+ passed: boolean;
72
+ durationMs: number;
73
+ /** Tail of the command output (bounded) — evidence for humans/LLM. */
74
+ outputTail: string;
75
+ skipped?: boolean;
76
+ error?: string;
77
+ }
78
+
79
+ /** Overall gate verdict. */
80
+ export type VerifyGateVerdict = "PASS" | "FAILED" | "INCONCLUSIVE";
81
+
82
+ export interface VerifyGateOutcome {
83
+ verdict: VerifyGateVerdict;
84
+ checks: VerifyGateCheckResult[];
85
+ /** Compact structured evidence lines (exit codes + commands). */
86
+ evidenceLines: string[];
87
+ allPassed: boolean;
88
+ totalDurationMs: number;
89
+ }
90
+
91
+ /** Injected command executor. The TOOL path wraps ctx.executeTool("bash");
92
+ * the spawn-site path wraps verification-gates executeCommand (subprocess).
93
+ * Must resolve (never reject) — failures come back as exitCode null/error. */
94
+ export type VerifyGateExecutor = (command: string, opts: VerifyGateExecutorOpts) => Promise<VerifyGateExecutorResult>;
95
+
96
+ export interface VerifyGateExecutorOpts {
97
+ cwd: string;
98
+ timeoutMs: number;
99
+ signal?: AbortSignal;
100
+ }
101
+
102
+ export interface VerifyGateExecutorResult {
103
+ exitCode: number | null;
104
+ output: string;
105
+ error?: string;
106
+ }
107
+
108
+ /** Default per-check timeout — mirrors runPhaseGates (verification-gates.ts). */
109
+ export const DEFAULT_VERIFY_GATE_TIMEOUT_MS = 120_000;
110
+ /** Evidence tail budget per check (chars). */
111
+ const OUTPUT_TAIL_MAX_CHARS = 2_000;
112
+
113
+ /** U13 gate enable resolution: PI_CREW_VERIFY_GATE beats default ON. */
114
+ export function resolveVerifyGateEnabled(explicit?: boolean): boolean {
115
+ const env = getCrewEnvBool("PI_CREW_VERIFY_GATE");
116
+ if (env !== undefined) return env;
117
+ return explicit ?? true;
118
+ }
119
+
120
+ /** U13 per-check timeout resolution: PI_CREW_VERIFY_GATE_TIMEOUT_MS beats
121
+ * default 120000 (mirrors runPhaseGates). Invalid/≤0 → default. */
122
+ export function resolveVerifyGateTimeoutMs(explicit?: number): number {
123
+ const env = getCrewEnvInt("PI_CREW_VERIFY_GATE_TIMEOUT_MS");
124
+ const raw = env ?? explicit ?? DEFAULT_VERIFY_GATE_TIMEOUT_MS;
125
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_VERIFY_GATE_TIMEOUT_MS;
126
+ }
127
+
128
+ /**
129
+ * Default check preset — the spec's "check cấu hình" set, resolved from the
130
+ * target repo's package.json scripts so the gate adapts instead of failing:
131
+ * - `typecheck` script present → `npm run --silent typecheck` (critical);
132
+ * - `test:critical` script present → `npm run --silent test:critical`
133
+ * (critical);
134
+ * - grep evidence lines: no universal marker exists, so they are provided
135
+ * explicitly via the tool's `checks` param (e.g. the bundle-marker rule
136
+ * `grep -c "<marker>" dist/index.mjs`) rather than defaulted.
137
+ * Repos without either script resolve ZERO checks → INCONCLUSIVE → the
138
+ * classifier layer (when enabled) or the LLM verifier spawn runs as today.
139
+ */
140
+ export function defaultVerifyGateChecks(dir: string): VerifyGateCheck[] {
141
+ let scripts: Record<string, unknown> = {};
142
+ try {
143
+ const raw = fs.readFileSync(`${dir}/package.json`, "utf8");
144
+ const parsed = raw ? (JSON.parse(raw) as { scripts?: Record<string, unknown> }) : undefined;
145
+ scripts = parsed?.scripts ?? {};
146
+ } catch {
147
+ scripts = {};
148
+ }
149
+ const checks: VerifyGateCheck[] = [];
150
+ if (typeof scripts.typecheck === "string") {
151
+ checks.push({ name: "typecheck", command: "npm run --silent typecheck", critical: true });
152
+ }
153
+ if (typeof scripts["test:critical"] === "string") {
154
+ checks.push({ name: "test-critical", command: "npm run --silent test:critical", critical: true });
155
+ }
156
+ return checks;
157
+ }
158
+
159
+ function tailOutput(output: string): string {
160
+ if (output.length <= OUTPUT_TAIL_MAX_CHARS) return output;
161
+ return `…${output.slice(-OUTPUT_TAIL_MAX_CHARS)}`;
162
+ }
163
+
164
+ /** Cap the number of evidence lines (prompt/metric hygiene). */
165
+ const EVIDENCE_MAX_LINES = 12;
166
+
167
+ export function evaluateVerifyGateResults(checks: VerifyGateCheckResult[]): VerifyGateOutcome {
168
+ const ran = checks.filter((c) => !c.skipped);
169
+ const allPassed = ran.length > 0 && ran.every((c) => c.passed);
170
+ const verdict: VerifyGateVerdict = ran.length === 0 ? "INCONCLUSIVE" : allPassed ? "PASS" : "FAILED";
171
+ const evidenceLines = checks
172
+ .slice(0, EVIDENCE_MAX_LINES)
173
+ .map((c) =>
174
+ c.skipped
175
+ ? `${c.name}: SKIPPED — ${c.command}`
176
+ : `${c.name}: exit=${c.exitCode ?? "err"} ${c.passed ? "PASS" : "FAIL"} — ${c.command}${c.error ? ` (error: ${c.error})` : ""}`,
177
+ );
178
+ return {
179
+ verdict,
180
+ checks,
181
+ evidenceLines,
182
+ allPassed,
183
+ totalDurationMs: checks.reduce((sum, c) => sum + c.durationMs, 0),
184
+ };
185
+ }
186
+
187
+ /** Run the gate checks sequentially through the injected executor.
188
+ * Mirrors runPhaseGates semantics: stop on first critical failure; abort
189
+ * signal between checks marks the rest skipped. NEVER rejects. */
190
+ export async function runVerifyGate(
191
+ checks: VerifyGateCheck[],
192
+ exec: VerifyGateExecutor,
193
+ opts: { cwd: string; signal?: AbortSignal; timeoutMs?: number },
194
+ ): Promise<VerifyGateOutcome> {
195
+ const timeoutMs = resolveVerifyGateTimeoutMs(opts.timeoutMs);
196
+ const results: VerifyGateCheckResult[] = [];
197
+ for (const check of checks) {
198
+ if (opts.signal?.aborted) {
199
+ results.push({
200
+ name: check.name,
201
+ command: check.command,
202
+ exitCode: null,
203
+ passed: false,
204
+ durationMs: 0,
205
+ outputTail: "",
206
+ skipped: true,
207
+ error: "Aborted",
208
+ });
209
+ continue;
210
+ }
211
+ const start = Date.now();
212
+ let execResult: VerifyGateExecutorResult;
213
+ try {
214
+ execResult = await exec(check.command, { cwd: opts.cwd, timeoutMs, signal: opts.signal });
215
+ } catch (error) {
216
+ // Executor contract says never-reject; defend anyway — an executor
217
+ // blowup must degrade the CHECK, not the host.
218
+ execResult = { exitCode: null, output: "", error: error instanceof Error ? error.message : String(error) };
219
+ }
220
+ results.push({
221
+ name: check.name,
222
+ command: check.command,
223
+ exitCode: execResult.exitCode,
224
+ passed: execResult.exitCode === 0,
225
+ durationMs: Date.now() - start,
226
+ outputTail: tailOutput(execResult.output ?? ""),
227
+ ...(execResult.error ? { error: execResult.error } : {}),
228
+ });
229
+ // Stop on critical failure (default critical) — mirrors runPhaseGates.
230
+ if (execResult.exitCode !== 0 && check.critical !== false) break;
231
+ }
232
+ return evaluateVerifyGateResults(results);
233
+ }
234
+
235
+ /** Spawn-site executor: the hardened subprocess runner the verifier flow
236
+ * already uses (validateGateCommand + sanitized env + group kill + caps). */
237
+ export function createSubprocessVerifyGateExecutor(): VerifyGateExecutor {
238
+ return async (command, opts) => {
239
+ if (opts.signal?.aborted) return { exitCode: null, output: "", error: "Aborted" };
240
+ const { exitCode, output } = await executeCommand(command, opts.cwd, opts.timeoutMs);
241
+ return { exitCode, output };
242
+ };
243
+ }
244
+
245
+ export interface VerifierSpawnDecision {
246
+ /** true → do NOT spawn the LLM verifier; the gate verdict stands. */
247
+ skipVerifier: boolean;
248
+ /** Which layer + branch decided (metric label). */
249
+ reason: string;
250
+ /** true → append the gate outcome block to the verifier's prompt. */
251
+ attachGateContext: boolean;
252
+ }
253
+
254
+ /** TWO-LAYER decision (deterministic first, classifier second). */
255
+ export function resolveVerifierSpawnDecision(outcome: VerifyGateOutcome, preGate?: VerifierPreGateResult): VerifierSpawnDecision {
256
+ if (outcome.verdict === "PASS") {
257
+ return { skipVerifier: true, reason: "deterministic_pass", attachGateContext: false };
258
+ }
259
+ if (outcome.verdict === "FAILED") {
260
+ // The LLM verifier gets the failing evidence in its prompt — it must
261
+ // diagnose/report the failure, not re-derive exit codes.
262
+ return { skipVerifier: false, reason: "deterministic_failed", attachGateContext: true };
263
+ }
264
+ // INCONCLUSIVE: classifier layer (U6B) may block on a decisively good
265
+ // structured state; disabled/soft-fail/low-confidence all escalate.
266
+ if (preGate?.blockVerifier) {
267
+ return { skipVerifier: true, reason: "classifier_blocked", attachGateContext: false };
268
+ }
269
+ return {
270
+ skipVerifier: false,
271
+ reason: preGate ? `classifier_${preGate.action}` : "inconclusive_no_classifier",
272
+ attachGateContext: false,
273
+ };
274
+ }
275
+
276
+ /** Render the gate outcome as a compact prompt block for the LLM verifier. */
277
+ export function renderVerifyGateContext(outcome: VerifyGateOutcome): string {
278
+ const lines = [
279
+ "## Deterministic verify_gate results (U13 pre-gate)",
280
+ `Gate verdict: ${outcome.verdict}`,
281
+ ...outcome.evidenceLines.map((line) => `- ${line}`),
282
+ ];
283
+ for (const check of outcome.checks) {
284
+ if (!check.skipped && !check.passed && check.outputTail.trim().length > 0) {
285
+ const tail = check.outputTail.trim().split("\n").slice(-5).join("\n");
286
+ lines.push(`### ${check.name} output tail`, "```", tail, "```");
287
+ }
288
+ }
289
+ lines.push(
290
+ "",
291
+ "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.",
292
+ );
293
+ return lines.join("\n");
294
+ }
295
+
296
+ /** Human/one-line summary used for the skip path's synthetic result artifact. */
297
+ export function renderVerifyGateSummary(outcome: VerifyGateOutcome, reason: string): string {
298
+ return [`verify_gate: ${outcome.verdict} (skip reason: ${reason})`, ...outcome.evidenceLines].join("\n");
299
+ }
300
+
301
+ export interface VerifierGatePreGateInput {
302
+ cwd: string;
303
+ /** Explicit checks override the default preset (tool path / tests). */
304
+ checks?: VerifyGateCheck[];
305
+ signal?: AbortSignal;
306
+ timeoutMs?: number;
307
+ /** U6B classifier layer args — consulted ONLY on INCONCLUSIVE. */
308
+ classifier?: {
309
+ enabled: boolean;
310
+ modelRegistry: unknown;
311
+ classifierModel: string;
312
+ eventsPath: string;
313
+ runId: string;
314
+ taskId?: string;
315
+ };
316
+ metricRegistry?: MetricRegistry;
317
+ }
318
+
319
+ export interface VerifierGatePreGateResult {
320
+ outcome: VerifyGateOutcome;
321
+ decision: VerifierSpawnDecision;
322
+ preGate?: VerifierPreGateResult;
323
+ /** Prompt block to append when the verifier still spawns ("" otherwise). */
324
+ promptContext: string;
325
+ }
326
+
327
+ function incMetric(registry: MetricRegistry | undefined, name: string, help: string, labels: Record<string, string>): void {
328
+ try {
329
+ registry?.counter(name, help).inc(labels);
330
+ } catch {
331
+ /* metrics are best-effort; never break the gate */
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Spawn-site orchestration: run the deterministic gate, consult the U6B
337
+ * classifier layer when inconclusive, resolve the two-layer spawn decision,
338
+ * and emit the acceptance metrics. NEVER rejects — executor failures degrade
339
+ * to INCONCLUSIVE checks, which escalate to the LLM verifier.
340
+ */
341
+ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): Promise<VerifierGatePreGateResult> {
342
+ const checks = input.checks ?? defaultVerifyGateChecks(input.cwd);
343
+ const outcome = await runVerifyGate(checks, createSubprocessVerifyGateExecutor(), {
344
+ cwd: input.cwd,
345
+ signal: input.signal,
346
+ timeoutMs: input.timeoutMs,
347
+ });
348
+ let preGate: VerifierPreGateResult | undefined;
349
+ if (outcome.verdict === "INCONCLUSIVE" && input.classifier?.enabled) {
350
+ try {
351
+ preGate = await classifyVerifierPreGate({
352
+ enabled: true,
353
+ modelRegistry: input.classifier.modelRegistry,
354
+ classifierModel: input.classifier.classifierModel,
355
+ verdict: {
356
+ verdict: outcome.verdict,
357
+ evidenceLines: outcome.evidenceLines,
358
+ changedFiles: [],
359
+ },
360
+ eventsPath: input.classifier.eventsPath,
361
+ runId: input.classifier.runId,
362
+ ...(input.classifier.taskId !== undefined ? { taskId: input.classifier.taskId } : {}),
363
+ metricRegistry: input.metricRegistry,
364
+ });
365
+ } catch {
366
+ preGate = undefined; // soft: escalate to the LLM verifier
367
+ }
368
+ }
369
+ const decision = resolveVerifierSpawnDecision(outcome, preGate);
370
+ incMetric(input.metricRegistry, "crew.verification.verify_gate_runs_total", "Deterministic verify_gate executions by verdict", {
371
+ verdict: outcome.verdict,
372
+ });
373
+ if (decision.skipVerifier) {
374
+ incMetric(
375
+ input.metricRegistry,
376
+ "crew.verification.verifier_spawns_skipped_total",
377
+ "LLM verifier spawns saved by the U13 deterministic pre-gate",
378
+ { reason: decision.reason },
379
+ );
380
+ } else {
381
+ incMetric(
382
+ input.metricRegistry,
383
+ "crew.verification.verifier_spawns_total",
384
+ "LLM verifier spawns that still ran, labeled by the deciding layer",
385
+ { reason: decision.reason },
386
+ );
387
+ }
388
+ return {
389
+ outcome,
390
+ decision,
391
+ ...(preGate ? { preGate } : {}),
392
+ promptContext: decision.attachGateContext ? renderVerifyGateContext(outcome) : "",
393
+ };
394
+ }