pi-aia-asf 0.12.2 → 0.13.1
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 +142 -2
- package/index.ts +379 -0
- package/lib/gates/acceptance.d.mts +19 -0
- package/lib/gates/acceptance.mjs +110 -0
- package/lib/gates/authority.d.mts +42 -0
- package/lib/gates/authority.mjs +158 -0
- package/lib/gates/blockers.d.mts +29 -0
- package/lib/gates/blockers.mjs +193 -0
- package/lib/gates/config.d.mts +37 -0
- package/lib/gates/config.mjs +209 -0
- package/lib/gates/declarations.d.mts +35 -0
- package/lib/gates/declarations.mjs +211 -0
- package/lib/gates/defaults.d.mts +36 -0
- package/lib/gates/defaults.mjs +426 -0
- package/lib/gates/ledger.d.mts +42 -0
- package/lib/gates/ledger.mjs +267 -0
- package/lib/gates/measurement.d.mts +31 -0
- package/lib/gates/measurement.mjs +223 -0
- package/lib/gates/report.d.mts +25 -0
- package/lib/gates/report.mjs +121 -0
- package/lib/gates/settle.d.mts +19 -0
- package/lib/gates/settle.mjs +98 -0
- package/obligations.d/aia-asf.json +404 -0
- package/package.json +3 -2
- package/skills/aia-asf/SKILL.md +34 -2
- package/skills/aia-asf/references/02-research.md +43 -0
- package/skills/aia-asf/references/06b-testing-qa.md +109 -0
- package/skills/aia-asf/references/06d-delegation.md +34 -0
- package/skills/aia-asf/references/06h-security.md +55 -7
- package/skills/aia-asf/references/06k-performance.md +281 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,11 +7,151 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.
|
|
10
|
+
## [0.13.1] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A measurement without a target no longer reads as a verdict.** 0.13.0
|
|
15
|
+
shipped the measurement contract but left the last clause of the requirement
|
|
16
|
+
unimplemented: *where no defensible default exists the output is
|
|
17
|
+
`no-default: needs a declared target`, never a silent pass*. Now:
|
|
18
|
+
- **`perf.target` is the one parameter that ships no default.** A latency or
|
|
19
|
+
throughput target is a product decision; a number ASF invented would be a
|
|
20
|
+
fabricated specification. With nobody having declared one, the parameter
|
|
21
|
+
resolves to `value: null, origin: "no-default"` and the verdict text is the
|
|
22
|
+
literal `no-default: needs a declared target`.
|
|
23
|
+
- **`target` is required on latency and throughput claims.** A claim can carry
|
|
24
|
+
a complete measurement contract — percentile, load, samples, environment,
|
|
25
|
+
lab-or-field — and still be judging nothing. That is now reported, with
|
|
26
|
+
`noDefault` set on the result. A **regression** claim still needs no target:
|
|
27
|
+
it compares two arms rather than a threshold.
|
|
28
|
+
- **The parameter guard keeps its teeth.** The flag that exempts a parameter
|
|
29
|
+
from a band is the same flag that *forbids* it from shipping a general
|
|
30
|
+
default, so a fabricated number cannot smuggle itself in as "no default
|
|
31
|
+
needed". Adding `perf.target` also tripped the existing guard for a missing
|
|
32
|
+
`meaning` field — the guard working as intended on its first real test.
|
|
33
|
+
- `SKILL.md`/06k/06b state the rule where the contract is stated, and the
|
|
34
|
+
config screen shows `no-default` as a first-class origin.
|
|
35
|
+
|
|
36
|
+
## [0.13.0] - 2026-10-16
|
|
11
37
|
|
|
12
38
|
### Added
|
|
13
39
|
|
|
14
|
-
- (
|
|
40
|
+
- **The enforcement layer (`lib/gates/`, wired into `/asf`).** Until now every
|
|
41
|
+
ASF gate was a sentence in a markdown file that the agent was asked to
|
|
42
|
+
remember. These are code that runs, each with the two fixtures that make it
|
|
43
|
+
trustworthy.
|
|
44
|
+
- **`defaults.mjs` — three-tier configuration.** Every tunable ships with a
|
|
45
|
+
researched general default (with its source), a documented adaptation band,
|
|
46
|
+
and project calibration allowed **only inside the band**. A value outside
|
|
47
|
+
the band is explicit user configuration and is labelled `override`, never
|
|
48
|
+
silently obeyed. Statistical-validity constants (`bench.significance`,
|
|
49
|
+
`valid.interleavedComparison`, `valid.rawSamplesRetained`,
|
|
50
|
+
`valid.scannedZeroIsPass`) are **non-adaptive** — a project may not
|
|
51
|
+
calibrate its way out of a valid comparison, only make it more patient.
|
|
52
|
+
- **`ledger.mjs` — the evidence ledger.** Entries are derived from observed
|
|
53
|
+
tool traffic (suite runs, health verdicts, browser verification). There is
|
|
54
|
+
**no model-callable way to append an entry**, malformed entries are rejected
|
|
55
|
+
and recorded, and `scanned: 0` is reported as *examined nothing*, never as a
|
|
56
|
+
pass.
|
|
57
|
+
- **`authority.mjs` — gate authority is earned and revocable.**
|
|
58
|
+
`probation → active` requires clean runs **plus at least one caught fixture
|
|
59
|
+
case** (a gate that never fires looks exactly like a gate that works). An
|
|
60
|
+
active gate is demoted automatically when its false-positive rate or
|
|
61
|
+
blocks-per-run exceeds budget. Every transition is append-only, needs a
|
|
62
|
+
reason, and appears in the delivery report. A bypass request is recorded as
|
|
63
|
+
friction, not as evidence that the gate is wrong.
|
|
64
|
+
- **`blockers.mjs` — objective `tool_call` blockers.** Secrets in a tracked
|
|
65
|
+
file, a test subset run without stating its impact area, push without a
|
|
66
|
+
green full suite, destructive git on a dirty tree. Each declares its own
|
|
67
|
+
`failSafe: "block" | "allow"` — a crashing secret check blocks, a crashing
|
|
68
|
+
convenience check does not stop the loop. Every blocker has an autonomous
|
|
69
|
+
exit the agent can take itself; nothing here opens a UI prompt for approval.
|
|
70
|
+
- **`settle.mjs` — the completion gate**, bounded at `MAX_CONTINUATIONS = 2`.
|
|
71
|
+
Past the cap the gaps are **reported**, not blocked forever.
|
|
72
|
+
- **`measurement.mjs` — the measurement contract and the comparison.** A bare
|
|
73
|
+
numeric claim (`p95 < 2s`) is detected and reported as incomplete rather
|
|
74
|
+
than accepted. Regression verdicts use significance **plus effect size**
|
|
75
|
+
with retained raw samples — a naive ±15% threshold missed a real +10%
|
|
76
|
+
regression in 40/40 local trials where this caught 40/40. On a busy machine
|
|
77
|
+
the verdict is `skipped: machine busy`: no verdict, not a pass.
|
|
78
|
+
- **`acceptance.mjs`, `report.mjs`** — perf/security acceptance validation and
|
|
79
|
+
the delivery report, which is **assembled from the ledger and gate verdicts,
|
|
80
|
+
not written**, and shows each DoD item's enforcement class (H / M / S) so a
|
|
81
|
+
self-attested item cannot read as a verified one.
|
|
82
|
+
- **`config.mjs` + `declarations.mjs`** — the config screen (unknown parameters
|
|
83
|
+
refused, non-adaptive ones reported as ignored, out-of-band values clamped
|
|
84
|
+
and labelled) and the obligation declarations, which carry **no consumer
|
|
85
|
+
vocabulary** and are validated against the tests that enforce them.
|
|
86
|
+
- **New commands:** `/asf rules [H|M|S|area]`, `/asf config`, `/asf gates`,
|
|
87
|
+
`/asf report [--checklist]`.
|
|
88
|
+
- **`obligations.d/aia-asf.json`** — ASF's 17 standing obligations declared for
|
|
89
|
+
the loop guard, `source: "aia-asf"`, carried verbatim by the guard, which
|
|
90
|
+
never interprets it.
|
|
91
|
+
- **`references/06k-performance.md`** — the measurement contract, CI-cheap vs
|
|
92
|
+
scheduled placement, load scenarios (including the arrival-model trap),
|
|
93
|
+
escalation, and the ASVS performance/resource overlap.
|
|
94
|
+
- **5 new suites, 292 checks** (`test-gates`, `test-performance-gates`,
|
|
95
|
+
`test-config-screen`, `test-delivery-report`, `test-security-mapping`) on a
|
|
96
|
+
shared `test/gate-harness.mjs` that requires **a fire fixture and a near-miss
|
|
97
|
+
fixture for every gate**, and meta-asserts that gate count equals covered
|
|
98
|
+
gate count so a gate cannot be added and quietly left untested.
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
|
|
102
|
+
- **06h is now pinned to real ASVS 5.0 numbering** (`v5.0.0_release`, V1–V17).
|
|
103
|
+
The previous chapter table had been written from memory and was wrong. ASF
|
|
104
|
+
does not maintain a second security checklist alongside ASVS, and one
|
|
105
|
+
abuse-case load run now satisfies both the security and performance DoD.
|
|
106
|
+
- **06b gains Rules 21–23** (measurement contract, performance placement, gate
|
|
107
|
+
autonomy/negative-controls/budget) and four new DoD lines; the rule count in
|
|
108
|
+
`SKILL.md` is now asserted to *agree* with the heading count rather than to
|
|
109
|
+
equal a hardcoded number, which is what let the drift through.
|
|
110
|
+
- **02-research.md gains T1/T2/T3 evidence classes.** Every shipped default
|
|
111
|
+
cites its class and source; a T3 value is never presented as a rule, and "a
|
|
112
|
+
default that cannot explain itself is a guess with better formatting".
|
|
113
|
+
- **06d: a delegated verification prompt must carry its measurement contract**,
|
|
114
|
+
and a returned number without one is a claim, not a result.
|
|
115
|
+
|
|
116
|
+
### Fixed
|
|
117
|
+
|
|
118
|
+
- **`obligations.d/` was not in `package.json` `files`** — found by clean-room
|
|
119
|
+
verification, not by the suite. An installed ASF would have shipped the
|
|
120
|
+
enforcement code with no declarations for it to load, so `/asf rules` would
|
|
121
|
+
have reported an empty and perfectly green list.
|
|
122
|
+
- **`assertSignalsExist` reported false drift in every consumer project.** It
|
|
123
|
+
ran against the installed package, which ships no `test/`, producing one
|
|
124
|
+
bogus failure per obligation. It now says once that the check was skipped
|
|
125
|
+
because this is not the declaring repo — and still fails hard where a `test/`
|
|
126
|
+
directory exists and a named file or assertion is genuinely missing.
|
|
127
|
+
|
|
128
|
+
## [0.12.2] - 2026-10-14
|
|
129
|
+
|
|
130
|
+
### Fixed
|
|
131
|
+
|
|
132
|
+
- **`npm run check` now checks.** It was `tsc --noCheck …` with a compiler that
|
|
133
|
+
was not even resolvable here — `node_modules` is a hand-built symlink tree
|
|
134
|
+
pointing at the global pi install, which `npm install` cannot reconcile, so
|
|
135
|
+
the declared devDependency was never installed. Three layers of nothing:
|
|
136
|
+
no compiler, no checking, and — even when a compiler was found — the host API
|
|
137
|
+
resolved to `any`.
|
|
138
|
+
- `scripts/typecheck.sh` + `tsconfig.typecheck.json`: strict typecheck of
|
|
139
|
+
`index.ts` against the pinned host API, installed into gitignored `.tools/`
|
|
140
|
+
and mapped explicitly. Prints the compiler **and** host-types versions it
|
|
141
|
+
ran, so the gate cannot silently run something other than what is declared.
|
|
142
|
+
- **`lib/code-health/index.d.mts`** — the Code Health gate stays plain ESM on
|
|
143
|
+
purpose, but at the call site its entire surface was `any`. This is the
|
|
144
|
+
boundary: a field added to `runHealth`'s return that is not declared here is
|
|
145
|
+
now a type error rather than a silent pass. Verified by mutation —
|
|
146
|
+
`h.noSuchFieldOnPurpose` → `TS2339`.
|
|
147
|
+
- **devDependencies were stale**: pinned at pi `0.83.0` while the extension
|
|
148
|
+
runs against pi `1.2.0`. Now `1.2.0`, with `typebox` at the `1.3.27` the
|
|
149
|
+
host ships.
|
|
150
|
+
|
|
151
|
+
### Changed
|
|
152
|
+
|
|
153
|
+
- **Releases are gated.** `scripts/release.sh` runs the typecheck and the full
|
|
154
|
+
suite before anything is versioned, tagged or published.
|
|
15
155
|
|
|
16
156
|
## [0.12.1] - 2026-10-14
|
|
17
157
|
|
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,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;
|