pi-aia-asf 0.12.1 → 0.13.0

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 CHANGED
@@ -7,6 +7,126 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - **The enforcement layer (`lib/gates/`, wired into `/asf`).** Until now every
15
+ ASF gate was a sentence in a markdown file that the agent was asked to
16
+ remember. These are code that runs, each with the two fixtures that make it
17
+ trustworthy.
18
+ - **`defaults.mjs` — three-tier configuration.** Every tunable ships with a
19
+ researched general default (with its source), a documented adaptation band,
20
+ and project calibration allowed **only inside the band**. A value outside
21
+ the band is explicit user configuration and is labelled `override`, never
22
+ silently obeyed. Statistical-validity constants (`bench.significance`,
23
+ `valid.interleavedComparison`, `valid.rawSamplesRetained`,
24
+ `valid.scannedZeroIsPass`) are **non-adaptive** — a project may not
25
+ calibrate its way out of a valid comparison, only make it more patient.
26
+ - **`ledger.mjs` — the evidence ledger.** Entries are derived from observed
27
+ tool traffic (suite runs, health verdicts, browser verification). There is
28
+ **no model-callable way to append an entry**, malformed entries are rejected
29
+ and recorded, and `scanned: 0` is reported as *examined nothing*, never as a
30
+ pass.
31
+ - **`authority.mjs` — gate authority is earned and revocable.**
32
+ `probation → active` requires clean runs **plus at least one caught fixture
33
+ case** (a gate that never fires looks exactly like a gate that works). An
34
+ active gate is demoted automatically when its false-positive rate or
35
+ blocks-per-run exceeds budget. Every transition is append-only, needs a
36
+ reason, and appears in the delivery report. A bypass request is recorded as
37
+ friction, not as evidence that the gate is wrong.
38
+ - **`blockers.mjs` — objective `tool_call` blockers.** Secrets in a tracked
39
+ file, a test subset run without stating its impact area, push without a
40
+ green full suite, destructive git on a dirty tree. Each declares its own
41
+ `failSafe: "block" | "allow"` — a crashing secret check blocks, a crashing
42
+ convenience check does not stop the loop. Every blocker has an autonomous
43
+ exit the agent can take itself; nothing here opens a UI prompt for approval.
44
+ - **`settle.mjs` — the completion gate**, bounded at `MAX_CONTINUATIONS = 2`.
45
+ Past the cap the gaps are **reported**, not blocked forever.
46
+ - **`measurement.mjs` — the measurement contract and the comparison.** A bare
47
+ numeric claim (`p95 < 2s`) is detected and reported as incomplete rather
48
+ than accepted. Regression verdicts use significance **plus effect size**
49
+ with retained raw samples — a naive ±15% threshold missed a real +10%
50
+ regression in 40/40 local trials where this caught 40/40. On a busy machine
51
+ the verdict is `skipped: machine busy`: no verdict, not a pass.
52
+ - **`acceptance.mjs`, `report.mjs`** — perf/security acceptance validation and
53
+ the delivery report, which is **assembled from the ledger and gate verdicts,
54
+ not written**, and shows each DoD item's enforcement class (H / M / S) so a
55
+ self-attested item cannot read as a verified one.
56
+ - **`config.mjs` + `declarations.mjs`** — the config screen (unknown parameters
57
+ refused, non-adaptive ones reported as ignored, out-of-band values clamped
58
+ and labelled) and the obligation declarations, which carry **no consumer
59
+ vocabulary** and are validated against the tests that enforce them.
60
+ - **New commands:** `/asf rules [H|M|S|area]`, `/asf config`, `/asf gates`,
61
+ `/asf report [--checklist]`.
62
+ - **`obligations.d/aia-asf.json`** — ASF's 17 standing obligations declared for
63
+ the loop guard, `source: "aia-asf"`, carried verbatim by the guard, which
64
+ never interprets it.
65
+ - **`references/06k-performance.md`** — the measurement contract, CI-cheap vs
66
+ scheduled placement, load scenarios (including the arrival-model trap),
67
+ escalation, and the ASVS performance/resource overlap.
68
+ - **5 new suites, 292 checks** (`test-gates`, `test-performance-gates`,
69
+ `test-config-screen`, `test-delivery-report`, `test-security-mapping`) on a
70
+ shared `test/gate-harness.mjs` that requires **a fire fixture and a near-miss
71
+ fixture for every gate**, and meta-asserts that gate count equals covered
72
+ gate count so a gate cannot be added and quietly left untested.
73
+
74
+ ### Changed
75
+
76
+ - **06h is now pinned to real ASVS 5.0 numbering** (`v5.0.0_release`, V1–V17).
77
+ The previous chapter table had been written from memory and was wrong. ASF
78
+ does not maintain a second security checklist alongside ASVS, and one
79
+ abuse-case load run now satisfies both the security and performance DoD.
80
+ - **06b gains Rules 21–23** (measurement contract, performance placement, gate
81
+ autonomy/negative-controls/budget) and four new DoD lines; the rule count in
82
+ `SKILL.md` is now asserted to *agree* with the heading count rather than to
83
+ equal a hardcoded number, which is what let the drift through.
84
+ - **02-research.md gains T1/T2/T3 evidence classes.** Every shipped default
85
+ cites its class and source; a T3 value is never presented as a rule, and "a
86
+ default that cannot explain itself is a guess with better formatting".
87
+ - **06d: a delegated verification prompt must carry its measurement contract**,
88
+ and a returned number without one is a claim, not a result.
89
+
90
+ ### Fixed
91
+
92
+ - **`obligations.d/` was not in `package.json` `files`** — found by clean-room
93
+ verification, not by the suite. An installed ASF would have shipped the
94
+ enforcement code with no declarations for it to load, so `/asf rules` would
95
+ have reported an empty and perfectly green list.
96
+ - **`assertSignalsExist` reported false drift in every consumer project.** It
97
+ ran against the installed package, which ships no `test/`, producing one
98
+ bogus failure per obligation. It now says once that the check was skipped
99
+ because this is not the declaring repo — and still fails hard where a `test/`
100
+ directory exists and a named file or assertion is genuinely missing.
101
+
102
+ ## [0.12.2] - 2026-10-14
103
+
104
+ ### Fixed
105
+
106
+ - **`npm run check` now checks.** It was `tsc --noCheck …` with a compiler that
107
+ was not even resolvable here — `node_modules` is a hand-built symlink tree
108
+ pointing at the global pi install, which `npm install` cannot reconcile, so
109
+ the declared devDependency was never installed. Three layers of nothing:
110
+ no compiler, no checking, and — even when a compiler was found — the host API
111
+ resolved to `any`.
112
+ - `scripts/typecheck.sh` + `tsconfig.typecheck.json`: strict typecheck of
113
+ `index.ts` against the pinned host API, installed into gitignored `.tools/`
114
+ and mapped explicitly. Prints the compiler **and** host-types versions it
115
+ ran, so the gate cannot silently run something other than what is declared.
116
+ - **`lib/code-health/index.d.mts`** — the Code Health gate stays plain ESM on
117
+ purpose, but at the call site its entire surface was `any`. This is the
118
+ boundary: a field added to `runHealth`'s return that is not declared here is
119
+ now a type error rather than a silent pass. Verified by mutation —
120
+ `h.noSuchFieldOnPurpose` → `TS2339`.
121
+ - **devDependencies were stale**: pinned at pi `0.83.0` while the extension
122
+ runs against pi `1.2.0`. Now `1.2.0`, with `typebox` at the `1.3.27` the
123
+ host ships.
124
+
125
+ ### Changed
126
+
127
+ - **Releases are gated.** `scripts/release.sh` runs the typecheck and the full
128
+ suite before anything is versioned, tagged or published.
129
+
10
130
  ## [0.12.1] - 2026-10-14
11
131
 
12
132
  ### Added
package/index.ts CHANGED
@@ -15,7 +15,20 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
15
15
  import { mkdir, readFile, writeFile } from "node:fs/promises";
16
16
  import { existsSync, readFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
+ import { dirname } from "node:path";
19
+ import { fileURLToPath } from "node:url";
18
20
  import { homedir } from "node:os";
21
+ import { cpus, loadavg } from "node:os";
22
+ import { execFileSync } from "node:child_process";
23
+
24
+ import { createLedger, deriveSuiteRun, renderLedger } from "./lib/gates/ledger.mjs";
25
+ import { evaluateBlockers, BLOCKERS } from "./lib/gates/blockers.mjs";
26
+ import { createAuthority, renderAuthority } from "./lib/gates/authority.mjs";
27
+ import { decideSettlement, renderCompletionGate, MAX_CONTINUATIONS } from "./lib/gates/settle.mjs";
28
+ import { cli as configCli, screenModel, assertParametersAreConsumed, readProjectConfig } from "./lib/gates/config.mjs";
29
+ import { loadDeclarations, renderRules, assertSignalsExist } from "./lib/gates/declarations.mjs";
30
+ import { renderDeliveryReport, renderChecklist } from "./lib/gates/report.mjs";
31
+ import { PARAMS } from "./lib/gates/defaults.mjs";
19
32
 
20
33
  // ─── Types ─────────────────────────────────────────────────────────────────
21
34
 
@@ -236,7 +249,122 @@ function dependencySummary(): string {
236
249
 
237
250
  // ─── Extension registration ────────────────────────────────────────────────
238
251
 
252
+ // ─── Enforcement session state ─────────────────────────────────────────────
253
+ //
254
+ // What the extension *observed*, not what the agent asserted. The ledger is
255
+ // written from tool traffic and there is no model-callable way to append to it:
256
+ // if the agent could append an entry it would append a convenient one.
257
+
258
+ const ledger = createLedger();
259
+ const authority = createAuthority();
260
+
261
+ interface GateStat {
262
+ verdicts: number;
263
+ fired: number;
264
+ confirmedFalse: number;
265
+ blocks: number;
266
+ }
267
+ const gateStats: Record<string, GateStat> = {};
268
+
269
+ /** Set when register() runs; the ledger writes entries through it. */
270
+ let piRef: ExtensionAPI | null = null;
271
+
272
+ /** suite-run attempts awaiting their result, keyed by toolCallId. */
273
+ const pendingSuiteRuns = new Map<string, Record<string, unknown>>();
274
+ /** Completed suite runs — the fact the push gate checks for. */
275
+ const suiteRuns: Array<{ scope: string; exitCode: number; command: string; durationMs: number; testCount: number | string }> = [];
276
+ /** Memoised dirty-tree answer, refreshed at most once per tool call. */
277
+ let dirtyCache: { at: number; dirty: boolean } | null = null;
278
+
279
+ /** Bounded: beyond the cap the gate reports the gap instead of nagging forever. */
280
+ let continuationsRequested = 0;
281
+ let lastSettleDecision: { missing: string[]; exhausted?: boolean } | null = null;
282
+ let webTouched = false;
283
+ let impactStated = false;
284
+
285
+ /** Where this extension is installed — the declaration file ships beside it. */
286
+ function extensionDir(): string {
287
+ try {
288
+ return dirname(fileURLToPath(import.meta.url));
289
+ } catch {
290
+ return process.cwd();
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Project characteristics the adaptation band may calibrate within. Anything
296
+ * not measured is simply absent, so the general default is used rather than a
297
+ * guess — a default that cannot explain itself is a guess with better formatting.
298
+ */
299
+ function collectMeasurements(): Record<string, unknown> {
300
+ const m: Record<string, unknown> = {};
301
+ const last = [...ledger.entries].reverse().find((e: any) => e.kind === "suite-run" && e.scope === "full");
302
+ if (last && typeof last.durationMs === "number") m.suiteRuntimeMs = last.durationMs;
303
+ const cores = cpus()?.length;
304
+ if (cores) m.cores = cores;
305
+ const la = loadavg()?.[0];
306
+ if (typeof la === "number" && cores) m.machineQuiet = la <= cores;
307
+ return m;
308
+ }
309
+
310
+ function recordVerdict(gate: string, v: { fired?: boolean; scanned?: number }) {
311
+ const s = (gateStats[gate] ||= { verdicts: 0, fired: 0, confirmedFalse: 0, blocks: 0 });
312
+ s.verdicts++;
313
+ if (v.fired) s.fired++;
314
+ // scanned: 0 is recorded as examined-nothing. It is never a pass, here or in
315
+ // the report, and the ledger keeps the number so the claim can be audited.
316
+ if (v.scanned === 0) s.confirmedFalse++;
317
+ }
318
+
319
+ function appendEvidence(entry: Record<string, unknown>) {
320
+ const res = ledger.append(entry);
321
+ if (res.ok) {
322
+ try {
323
+ // Durable and excluded from model context: evidence costs no prompt budget.
324
+ (piRef as unknown as { appendEntry?: (t: string, d?: unknown) => void } | null)?.appendEntry?.("asf-evidence", res.entry);
325
+ } catch {
326
+ /* the in-memory ledger is the source of truth for this session */
327
+ }
328
+ } else {
329
+ try {
330
+ (piRef as unknown as { appendEntry?: (t: string, d?: unknown) => void } | null)?.appendEntry?.("asf-evidence-rejected", { problem: res.error });
331
+ } catch {
332
+ /* reported where possible */
333
+ }
334
+ }
335
+ return res;
336
+ }
337
+
338
+ /** Is the working tree dirty? Fail-open: an unknown state must not block. */
339
+ function gitDirty(): boolean {
340
+ const now = Date.now();
341
+ if (dirtyCache && now - dirtyCache.at < 5000) return dirtyCache.dirty;
342
+ let dirty = false;
343
+ try {
344
+ const out = execFileSync("git", ["status", "--porcelain"], { cwd: process.cwd(), encoding: "utf-8", timeout: 5000 });
345
+ dirty = String(out).trim().length > 0;
346
+ } catch {
347
+ // Not a repo, or git unavailable: we cannot claim the tree is dirty, and a
348
+ // convenience gate must not block on its own blindness.
349
+ dirty = false;
350
+ }
351
+ dirtyCache = { at: now, dirty };
352
+ return dirty;
353
+ }
354
+
355
+ function numberConfig(config: Record<string, unknown>, key: string, fallback: number): number {
356
+ const v = config[key];
357
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
358
+ }
359
+
360
+ /** Small work is held to the reduced DoD — the scale rule is the trigger here. */
361
+ function currentScale(): "small" | "standard" | "major" {
362
+ return lastScaleWasSmall ? "small" : "standard";
363
+ }
364
+ let lastScaleWasSmall = false;
365
+
239
366
  export default function register(pi: ExtensionAPI): void {
367
+ piRef = pi;
240
368
  // Startup check: warn once per session if deps are missing (unless disabled)
241
369
  // The extension loads at startup; log to console so it surfaces in logs.
242
370
 
@@ -292,6 +420,7 @@ export default function register(pi: ExtensionAPI): void {
292
420
  case "refactor":
293
421
  return await setPhase(ctx, "intake", "refactor", "large");
294
422
  case "small":
423
+ lastScaleWasSmall = true;
295
424
  return await setPhase(ctx, "implementation", undefined, "small");
296
425
  case "status": {
297
426
  const project = projectName();
@@ -411,6 +540,78 @@ export default function register(pi: ExtensionAPI): void {
411
540
  : "";
412
541
  return res.text + tail;
413
542
  }
543
+ case "rules": {
544
+ // The registry question with an answer: "what enforces anything around
545
+ // browser testing?" used to require reading every reference file.
546
+ const here = process.cwd();
547
+ const pkgDir = extensionDir();
548
+ const { obligations, problems } = loadDeclarations(pkgDir);
549
+ if (problems.length) {
550
+ return `ASF declaration has problems:\n- ${problems.join("\n- ")}`;
551
+ }
552
+ const filter = args[1];
553
+ const cls = ["H", "M", "S"].includes((filter || "").toUpperCase()) ? filter.toUpperCase() : undefined;
554
+ const area = cls ? undefined : filter;
555
+ const drift = assertSignalsExist(pkgDir, obligations);
556
+ const text = renderRules(obligations, { area, cls });
557
+ return text + (drift.length ? `\n\nDeclaration/test drift:\n- ${drift.join("\n- ")}` : "");
558
+ }
559
+ case "config": {
560
+ // One screen for every parameter. The screen is a convenience, not the
561
+ // only door: --get/--set/--explain/--check work headless with the same
562
+ // validation, so a typo is refused here rather than discovered later by
563
+ // a gate silently not running.
564
+ const a = args.map((s) => s.trim()).filter(Boolean);
565
+ const flag = (a[1] || "--get").toLowerCase();
566
+ const projectDir = process.cwd();
567
+ const measurements = collectMeasurements();
568
+ if (flag === "--screen" || flag === "screen") {
569
+ const rows = screenModel({ projectDir, measurements });
570
+ return rows
571
+ .map(
572
+ (r) =>
573
+ `${r.id} = ${r.currentValue} [${r.origin}] band ${r.band}${r.nonAdaptive ? " (NON-ADAPTIVE)" : ""}\n ${r.meaning ?? r.description.split("\n")[0]}`,
574
+ )
575
+ .join("\n");
576
+ }
577
+ if (flag === "--check") {
578
+ const problems = assertParametersAreConsumed(readProjectConfig(projectDir), {
579
+ knownGates: [...new Set(Object.values(PARAMS).map((p: { gate: string }) => p.gate))],
580
+ });
581
+ return problems.length ? `configuration problems:\n- ${problems.join("\n- ")}` : "configuration is consistent";
582
+ }
583
+ const action = flag === "--set" ? "set" : flag === "--unset" ? "unset" : flag === "--explain" ? "explain" : "get";
584
+ let key: string | undefined = a[2];
585
+ let value: string | undefined;
586
+ if (action === "set" && key && key.includes("=")) {
587
+ const [k, ...rest] = key.split("=");
588
+ key = k;
589
+ value = rest.join("=");
590
+ }
591
+ return configCli({ action, key, value, projectDir, measurements });
592
+ }
593
+ case "report": {
594
+ // The delivery report is ASSEMBLED, not written (P35). Everything below
595
+ // comes from what the harness observed this session; a gap stays visible
596
+ // as a gap rather than becoming a tick.
597
+ const checklist = args[1] === "--checklist";
598
+ return checklist
599
+ ? renderChecklist()
600
+ : renderDeliveryReport({
601
+ ledger,
602
+ authority,
603
+ gateStats,
604
+ completion: lastSettleDecision || { missing: [] },
605
+ });
606
+ }
607
+ case "gates": {
608
+ // What the enforcement layer decided, and what it charged.
609
+ return [
610
+ renderLedger(ledger),
611
+ renderAuthority(authority, gateStats),
612
+ renderCompletionGate(lastSettleDecision || { missing: [] }),
613
+ ].join("\n\n");
614
+ }
414
615
  case "abort":
415
616
  return await setPhase(ctx, "none");
416
617
  default:
@@ -424,6 +625,10 @@ export default function register(pi: ExtensionAPI): void {
424
625
  " /asf status — show current phase\n" +
425
626
  " /asf health — run the code health gate (function/module/architecture)\n" +
426
627
  " /asf verify — run the definition-of-done QA gate\n" +
628
+ " /asf rules [H|M|S|area] — list declared obligations and what enforces them\n" +
629
+ " /asf report [--checklist] — the delivery report, assembled from observed evidence\n" +
630
+ " /asf config [--get [k] | --set k=v | --unset k | --explain k | --check | --screen] — every ASF parameter, with its origin\n" +
631
+ " /asf gates — gate verdicts, authority state and friction for this session\n" +
427
632
  " /asf abort — end the current session\n\n" +
428
633
  dependencySummary()
429
634
  );
@@ -473,4 +678,178 @@ export default function register(pi: ExtensionAPI): void {
473
678
  ctx.ui.notify(await runApprove(ctx), "info");
474
679
  },
475
680
  });
681
+
682
+ // ── tool_call: observe, then block only what is objectively blockable ────
683
+ //
684
+ // Two jobs, in this order. First **observe**: turn what the extension watched
685
+ // happen into evidence, because "I ran the full suite" and "I did not run the
686
+ // full suite" must not be two sentences with no observable difference. Then
687
+ // **block**: only rules decidable from the tool input plus session state, each
688
+ // with an autonomous exit the agent can take itself.
689
+ //
690
+ // No gate here asks a human anything. `block: true, reason` returns the reason
691
+ // to the model, which is agent-facing feedback; nothing calls a UI prompt to
692
+ // seek approval. A blocker whose only exit is a human decision is misdesigned
693
+ // and belongs in the report, not in the path.
694
+ pi.on("tool_call", (event) => {
695
+ try {
696
+ const toolName = String((event as { toolName?: unknown }).toolName || "");
697
+ const input = (event as { input?: Record<string, unknown> }).input || {};
698
+
699
+ // Observe: a full test-suite run becomes evidence only because we saw it.
700
+ const command = typeof input.command === "string" ? input.command : undefined;
701
+ if (command) {
702
+ const started = Date.now();
703
+ const derived = deriveSuiteRun(command, 0, 0, "");
704
+ if (derived) {
705
+ // Exit code and duration are filled in by the tool_result handler;
706
+ // recording the attempt here keeps the ledger chronological.
707
+ (derived as Record<string, unknown>)._startedAt = started;
708
+ pendingSuiteRuns.set((event as { toolCallId?: string }).toolCallId || String(started), derived);
709
+ }
710
+ if (/\basf-approve\b/.test(command)) {
711
+ // A bypass is recorded as friction, never as evidence the gate is
712
+ // wrong: otherwise the exploit is to bypass a gate until it demotes.
713
+ appendEvidence({
714
+ kind: "gate-block",
715
+ gate: "asf-approve",
716
+ rule: "bypass-requested",
717
+ reason: "asf-approve invoked — recorded as friction; a bypass request is not evidence that a gate is wrong",
718
+ bypassed: true,
719
+ });
720
+ }
721
+ }
722
+
723
+ // Observe: a write to a web surface means browser verification is owed.
724
+ const touched = typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : "";
725
+ if (touched && /\.(tsx|jsx|html|vue|svelte|css)$/i.test(touched)) webTouched = true;
726
+
727
+ const config = readProjectConfig(process.cwd());
728
+ const enabled: Record<string, boolean> = {};
729
+ for (const b of BLOCKERS) {
730
+ const k = b.switch.replace(/^gate\./, "");
731
+ if (config[`gate.block.${k}`] === false || config[b.switch] === false) enabled[b.switch] = false;
732
+ }
733
+
734
+ const res = evaluateBlockers(toolName, input, {
735
+ suiteRuns,
736
+ dirty: () => gitDirty(),
737
+ impactStated,
738
+ trackedPaths: undefined,
739
+ }, { enabled });
740
+
741
+ for (const v of res.verdicts || []) {
742
+ recordVerdict(v.gate, v);
743
+ appendEvidence({ kind: "gate-verdict", gate: v.gate, fired: !!v.fired, scanned: typeof v.scanned === "number" ? v.scanned : 0 });
744
+ }
745
+
746
+ if (res.block) {
747
+ const gate = res.rule || "unknown";
748
+ const s = (gateStats[gate] ||= { verdicts: 0, fired: 0, confirmedFalse: 0, blocks: 0 });
749
+ s.blocks++;
750
+ appendEvidence({ kind: "gate-block", gate, rule: res.rule || gate, reason: String(res.reason || "").slice(0, 400) });
751
+ // Automatic demotion check (P45): a gate that blocks repeatedly in one
752
+ // run is interrupting the loop rather than guarding it.
753
+ const budgets = {
754
+ falsePositiveBudget: numberConfig(config, "gate.falsePositiveBudget", 0.05),
755
+ blocksPerRunBudget: numberConfig(config, "gate.blocksPerRunBudget", 3),
756
+ };
757
+ const check = authority.checkBudget(gate, {
758
+ blocksThisRun: s.blocks,
759
+ verdicts: gateStats[gate]?.verdicts || 0,
760
+ falsePositives: gateStats[gate]?.confirmedFalse || 0,
761
+ budgets,
762
+ });
763
+ if (check.demote && check.report) {
764
+ try {
765
+ (pi as unknown as { appendEntry?: (t: string, d?: unknown) => void }).appendEntry?.("asf-gate-transition", {
766
+ gate,
767
+ to: "demoted",
768
+ reason: check.reasons.join("; "),
769
+ });
770
+ } catch {
771
+ /* reported where possible */
772
+ }
773
+ }
774
+ return { block: true, reason: res.reason };
775
+ }
776
+ return undefined;
777
+ } catch {
778
+ // An observer must never break the thing it observes. The fail-safe for
779
+ // the secret rule lives inside evaluateBlockers, where it is explicit.
780
+ return undefined;
781
+ }
782
+ });
783
+
784
+ // ── tool_result: complete the evidence for an observed suite run ────────
785
+ //
786
+ // The exit code and duration are what make a suite run a fact rather than an
787
+ // attempt, and they come from what the tool returned — not from what the
788
+ // agent says it returned. Test counts are recorded only where a known runner's
789
+ // output can actually be read; otherwise they stay 'unknown', never 0.
790
+ pi.on("tool_result", (event) => {
791
+ try {
792
+ const id = String((event as { toolCallId?: unknown }).toolCallId || "");
793
+ const pending = pendingSuiteRuns.get(id);
794
+ if (!pending) return undefined;
795
+ pendingSuiteRuns.delete(id);
796
+
797
+ const content = (event as { content?: unknown }).content;
798
+ const output = typeof content === "string" ? content : JSON.stringify(content ?? "");
799
+ const isError = !!(event as { isError?: boolean }).isError;
800
+ const startedAt = typeof pending._startedAt === "number" ? pending._startedAt : Date.now();
801
+ const derived = deriveSuiteRun(
802
+ String(pending.command || ""),
803
+ isError ? 1 : 0,
804
+ Date.now() - startedAt,
805
+ output,
806
+ );
807
+ if (derived) {
808
+ delete (derived as Record<string, unknown>)._startedAt;
809
+ suiteRuns.push({
810
+ scope: derived.scope,
811
+ exitCode: derived.exitCode,
812
+ command: derived.command,
813
+ durationMs: derived.durationMs,
814
+ testCount: derived.testCount,
815
+ });
816
+ appendEvidence({ ...derived });
817
+ }
818
+ return undefined;
819
+ } catch {
820
+ return undefined;
821
+ }
822
+ });
823
+
824
+ // ── agent_before_settle: no "done" without evidence ───────────────────
825
+ //
826
+ // Registered here and not on `agent_settled` because pi documents the latter
827
+ // as final and notification-only, while `agent_before_settle` is the final
828
+ // actionable boundary. And nothing here awaits a resumed run: the loop guard
829
+ // documents a production hang caused by exactly that.
830
+ pi.on("agent_before_settle", () => {
831
+ try {
832
+ const decision = decideSettlement(ledger, {
833
+ scale: currentScale(),
834
+ webTouched,
835
+ continuationsSoFar: continuationsRequested,
836
+ });
837
+ lastSettleDecision = decision;
838
+ appendEvidence({
839
+ kind: "gate-verdict",
840
+ gate: "completion",
841
+ fired: decision.missing.length > 0,
842
+ scanned: ledger.entries.length,
843
+ });
844
+ if (decision.block && continuationsRequested < MAX_CONTINUATIONS) {
845
+ continuationsRequested++;
846
+ // One bounded continuation naming exactly what is missing. No UI prompt:
847
+ // this sends the agent back to work, it does not ask anyone anything.
848
+ return { continue: true };
849
+ }
850
+ return undefined;
851
+ } catch {
852
+ return undefined;
853
+ }
854
+ });
476
855
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Type surface for the Code Health gate, which stays plain ESM JavaScript
3
+ * (lib/code-health/*.mjs) on purpose: it is a build-time harness that shells
4
+ * out to linters, and rewriting it in TypeScript would buy nothing.
5
+ *
6
+ * This file is the boundary — what index.ts is allowed to rely on. It is
7
+ * hand-maintained, and that is deliberate: a field added to `runHealth`'s
8
+ * return that is not declared here becomes a type error at the call site
9
+ * instead of a silent `any`. Before this file, the whole gate was `any` and
10
+ * `npm run check` could not have noticed.
11
+ */
12
+
13
+ export interface HealthRow {
14
+ key: string;
15
+ value: number | null;
16
+ status: string;
17
+ [extra: string]: unknown;
18
+ }
19
+
20
+ export interface HealthConfig {
21
+ enabled: boolean;
22
+ /** "block" makes violations fail the gate; "warn" reports without blocking. */
23
+ gate: { mode: "block" | "warn" | string; scope: string };
24
+ ignore?: string[];
25
+ [extra: string]: unknown;
26
+ }
27
+
28
+ export interface HealthResult {
29
+ /** Human-readable report — what the `/asf health` command prints. */
30
+ text: string;
31
+ /** Populated only with `--json`. */
32
+ json: unknown;
33
+ /** 1 only when the gate is configured to block and the verdict is unhealthy. */
34
+ exitCode: 0 | 1;
35
+ healthy: boolean;
36
+ rows: HealthRow[];
37
+ config: HealthConfig;
38
+ }
39
+
40
+ export interface HealthDeps {
41
+ readFile?: (path: string) => string | null;
42
+ tools?: unknown;
43
+ [extra: string]: unknown;
44
+ }
45
+
46
+ export declare function runHealth(
47
+ projectRoot: string,
48
+ args?: string[],
49
+ deps?: HealthDeps,
50
+ ): Promise<HealthResult>;
51
+
52
+ export declare function parseArgs(args: string[]): Record<string, boolean | string>;
53
+
54
+ export declare const BASELINE_FILE: string;
@@ -0,0 +1,19 @@
1
+ export interface Acceptance {
2
+ [field: string]: unknown;
3
+ }
4
+ export const REQUIRED_PERF: string[];
5
+ export const REQUIRED_SECURITY: string[];
6
+ export function validatePerfAcceptance(
7
+ acceptance: Acceptance | null,
8
+ now?: Date,
9
+ ): { ok: boolean; invalid: string[]; acceptance: Acceptance | null };
10
+ export function validateSecurityAcceptance(
11
+ acceptance: Acceptance | null,
12
+ now?: Date,
13
+ ): { ok: boolean; invalid: string[]; expired?: boolean; acceptance: Acceptance | null };
14
+ export function securityGate(input: {
15
+ findings?: { id?: string; title?: string; severity?: string }[];
16
+ acceptances?: Acceptance[];
17
+ followUps?: { finding?: string; ticket?: string }[];
18
+ }): { ok: boolean; open: string[] };
19
+ export function renderAcceptance(kind: "perf" | "security", acceptance: Acceptance): string;