pi-aia-asf 0.15.1 → 0.17.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 CHANGED
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.1] - 2026-11-07
11
+
12
+ ### Added
13
+
14
+ - (describe changes for 0.17.1)
15
+
16
+ ## [0.17.0] - 2026-11-07
17
+
18
+ ### Added — the health gate ships its own toolchain
19
+
20
+ The gate could not measure. `detectTools()` looked only in the **project's**
21
+ `node_modules`, so on any repo without eslint/jscpd/madge it produced eight
22
+ "tool not installed" skips and reported a level it never examined — and a run
23
+ that changed source could be judged by metrics that were all missing.
24
+
25
+ - **The tools are now ASF's own dependencies**: `eslint`, `eslint-plugin-sonarjs`,
26
+ `@typescript-eslint/parser`, `jscpd`, `madge`. Installing pi-aia-asf installs
27
+ them, so a fresh clone with an empty `node_modules` is still measured.
28
+ - **ASF-first resolution** (`lib/code-health/tools.mjs`): ASF's copy, then the
29
+ project's, then PATH. The same metric means the same thing in every repo, and a
30
+ project pinning its own eslint still wins.
31
+ - **No more `npx --no-install`.** Every metric tool is spawned by absolute path
32
+ from the resolved install; `npx` could silently run a different version or none.
33
+ - **Deterministic eslint.** The gate writes its own flat config to a temp file and
34
+ runs with `--no-config-lookup`, so a project's eslint config cannot change what
35
+ is measured. The sonarjs plugin and TS parser are imported by absolute file URL
36
+ from ASF's install.
37
+ - **Missing tools name the package** ("jscpd is not available — it ships with
38
+ pi-aia-asf; reinstall the package") instead of a bare "tool not installed".
39
+
40
+ ### Fixed
41
+
42
+ - **A sonarjs metric counted the wrong rule id** (`cognitive-complexity` instead
43
+ of `sonarjs/cognitive-complexity`), so it always reported zero violations.
44
+ - **Vendored trees were scanned as project source.** The model level walked
45
+ `.local`, `site-packages`, `.venv`, `vendor`, `third_party`, `target`, `Pods`
46
+ and every other dot-directory: from a home directory it produced 958 findings
47
+ from Python packages belonging to nobody's project. Those trees are skipped now.
48
+ - **A circular dependency in ASF itself** (`code-health/model.mjs` ↔ `run.mjs`,
49
+ via a dynamic import) — found by madge the moment it could actually run. The
50
+ file walker moved to `lib/code-health/files.mjs`, imported by both.
51
+
52
+ ## [0.16.1] - 2026-11-07
53
+
54
+ ### Fixed — the 2026-11-07 endless completion-gate loop
55
+
56
+ The gate could steer forever for **no reason the agent could act on**. Two defects
57
+ compounded: it demanded `/asf health`, a pi slash command an extension has no tool
58
+ to invoke (so the gap could never close), and the "never the same steer twice" rule
59
+ keyed on a fingerprint that concurrent saves kept wiping, so the identical ask was
60
+ re-issued indefinitely. Both are now structurally impossible.
61
+
62
+ - **A gate can only block on something the agent can close.** `splitGaps()` sorts
63
+ every gap into blocking (agent-closable) and reported (needs a human), and it
64
+ runs **once, after all gaps are assembled** — splitting earlier let gaps pushed
65
+ afterwards skip the filter. A human-only gap is rendered as "not verified / not
66
+ blocking" and never resumes the run.
67
+ - **`asf_health` tool.** The health gate's only demand is now an agent-side tool
68
+ call, which records a verdict in the ledger. No gate asks for a slash command.
69
+ - **A warranted loop has no ceiling.** A real, agent-closable gap is steered for as
70
+ long as it stays open; the old `exhausted` paths that settled the run with gaps
71
+ still open are gone. Repeats are worded as repeats ("still open after N previous
72
+ steers") instead of pretending to be new information, and no message claims a
73
+ bound ("of at most 12") the gate does not have.
74
+ - **The steer count is in-process memory, and state.json is only its record.** A
75
+ corrupt, raced or hand-edited file can no longer reset the count or erase a
76
+ fingerprint; `Math.max` on read plus a monotonic merge on write make it
77
+ non-decreasing.
78
+ - **Saves are serialized per project and merged under the lock.** Eleven paths
79
+ read-modify-write the state file; the write is now queued per project and
80
+ re-reads the file inside the lock, merging the monotonic fields (steer count,
81
+ fingerprints, touched files, last mutation). Write-to-temp-then-`rename` means a
82
+ reader never sees a half-written file.
83
+ - **A corrupt state file can no longer disable the gate.** Wrong-typed fields used
84
+ to throw inside the settle handler, and the catch-all returned `undefined` —
85
+ which settled the run as if the gate had passed. Fields are coerced on load and
86
+ on both sides of a merge, and an internal error is now surfaced once as
87
+ `asf-gate-error` (deduped by error class, since an I/O message carries a random
88
+ tmp filename) instead of being swallowed.
89
+ - **A park is asked once per (run, reason)**, and guidance-only continuations are
90
+ bounded per run, so no secondary nudge can hold a session open on its own.
91
+
92
+ Every guard above is pinned by a test that fails when the guard is removed.
93
+
10
94
  ## [0.15.1] - 2026-11-06
11
95
 
12
96
  ### Fixed
package/index.ts CHANGED
@@ -12,19 +12,21 @@
12
12
  */
13
13
 
14
14
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
15
- import { mkdir, readFile, writeFile } from "node:fs/promises";
15
+ import { StringEnum } from "@earendil-works/pi-ai";
16
+ import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
16
17
  import { existsSync, readFileSync } from "node:fs";
17
18
  import { join } from "node:path";
18
19
  import { dirname, basename } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
20
21
  import { homedir } from "node:os";
21
22
  import { cpus, loadavg } from "node:os";
23
+ import { Type } from "typebox";
22
24
  import { execFileSync } from "node:child_process";
23
25
 
24
26
  import { createLedger, deriveSuiteRun, renderLedger } from "./lib/gates/ledger.mjs";
25
27
  import { evaluateBlockers, BLOCKERS, RULE_TEXT, refusalStage, __resetRefusals } from "./lib/gates/blockers.mjs";
26
28
  import { createAuthority, renderAuthority } from "./lib/gates/authority.mjs";
27
- import { decideSettlement, renderCompletionGate, MAX_STEERS, MAX_IDENTICAL_STEERS, steerFingerprint } from "./lib/gates/settle.mjs";
29
+ import { decideSettlement, renderCompletionGate, MAX_STEERS, steerFingerprint } from "./lib/gates/settle.mjs";
28
30
  import { projectKey as resolveProjectKey, __clearProjectCache } from "./lib/project.mjs";
29
31
  import { classifyToolCall, activationDecision, isSourcePath, bashWritesSource } from "./lib/activation.mjs";
30
32
  import { renderRunCard, cardSummary, CARD_CEILING_BYTES } from "./lib/run-card.mjs";
@@ -189,6 +191,7 @@ async function loadState(project: string): Promise<ProjectStateFile> {
189
191
  if (!data || typeof data !== "object") return restoreFromMirror(project, { current: null, history: [] }, "corrupt");
190
192
  if (!data.history) data.history = [];
191
193
  if (!data.current) return restoreFromMirror(project, data, "cleared");
194
+ sanitizeSteerFields(data.current);
192
195
  return data;
193
196
  } catch {
194
197
  return restoreFromMirror(project, { current: null, history: [] }, "corrupt");
@@ -218,12 +221,137 @@ async function restoreFromMirror(project: string, data: ProjectStateFile, how: s
218
221
  */
219
222
  const runMirror = new Map<string, AsfState>();
220
223
 
224
+ /**
225
+ * How much each run has been steered, held IN THIS PROCESS.
226
+ *
227
+ * The 2026-11-07 loop: the bound on identical steers lived only in state.json.
228
+ * A concurrent writer (the tool_call handler saves the same file on every source
229
+ * mutation) could put an older copy back, and the counter restarted — so the
230
+ * same gap was re-asked forever and the autonomous loop never ended. The state
231
+ * file is now a *record*; this map is the *authority* for this session. A run's
232
+ * steer count can go up, never down, and an identical steer is never issued
233
+ * twice in the same process no matter what the file says.
234
+ */
235
+ const steerMemory = new Map<string, { count: number; seen: Set<string>; guidance: number }>();
236
+ /** (runId|reason) already parked in this process — a park is asked once. */
237
+ const parkedAlready = new Set<string>();
238
+
239
+ /**
240
+ * Guidance-only continuations (card, steers) are capped per session. The
241
+ * completion gate has its own bound; this one covers the paths that continue a
242
+ * run merely to say something.
243
+ */
244
+ const MAX_GUIDANCE_CONTINUATIONS = 24;
245
+ /**
246
+ * Per-RUN, not per-process: a long session runs many runs, and each deserves
247
+ * its cards. A run kept alive purely by its own advice is the pathology.
248
+ */
249
+ function steerContinuationsAllowed(mem: { guidance: number }): boolean {
250
+ if (mem.guidance >= MAX_GUIDANCE_CONTINUATIONS) {
251
+ appendEvidence({ kind: "steer", gate: "guidance-cap", suppressed: true });
252
+ return false;
253
+ }
254
+ mem.guidance++;
255
+ return true;
256
+ }
257
+ function steerLedgerFor(key: string): { count: number; seen: Set<string>; guidance: number } {
258
+ let m = steerMemory.get(key);
259
+ if (!m) {
260
+ m = { count: 0, seen: new Set<string>(), guidance: 0 };
261
+ if (steerMemory.size > 64) steerMemory.clear();
262
+ steerMemory.set(key, m);
263
+ }
264
+ return m;
265
+ }
266
+
267
+ /** Coerce a loaded run state's steer fields — a hand-edited or raced file must not disable the bound. */
268
+ /**
269
+ * Coerce the fields the gate reads into the types it assumes. A hand-edited or
270
+ * older-version file must never be able to (a) throw inside the handler or
271
+ * (b) make every later save fail — so this runs on load AND on both sides of a
272
+ * merge, not only on the steer fields it was originally written for.
273
+ */
274
+ function sanitizeSteerFields(state: AsfState | null | undefined): void {
275
+ if (!state) return;
276
+ if (typeof state.steers !== "number" || !Number.isFinite(state.steers) || state.steers < 0) state.steers = 0;
277
+ if (!Array.isArray(state.seenSteers)) state.seenSteers = [];
278
+ state.seenSteers = (state.seenSteers as unknown[]).filter((x): x is string => typeof x === "string" && x.trim().length > 0);
279
+ if (!Array.isArray(state.filesTouched)) state.filesTouched = [];
280
+ else state.filesTouched = (state.filesTouched as unknown[]).filter((f): f is string => typeof f === "string" && f.trim().length > 0).slice(-10);
281
+ if (typeof state.lastMutationAt !== "number" || !Number.isFinite(state.lastMutationAt)) state.lastMutationAt = 0;
282
+ }
283
+
284
+ /**
285
+ * Saves are SERIALIZED per project.
286
+ *
287
+ * Eleven code paths save the same file (activation, settle, park, close, abort,
288
+ * restore…). Each one read-modify-writes it, so two overlapping saves let an
289
+ * older copy land last — which is how the 2026-11-07 loop survived its own
290
+ * bound: the settle handler wrote the steer counter, a source-mutation save put
291
+ * an older copy back, and the counter restarted. Writes are now queued per
292
+ * project, so a save cannot interleave with another.
293
+ */
294
+ const saveQueue = new Map<string, Promise<void>>();
295
+
296
+ /**
297
+ * Monotonic fields: once a run has been steered, or has touched a file, an older
298
+ * copy of the state must never erase that. Eleven code paths read-modify-write
299
+ * this file; the queue serializes the WRITES, and this merge closes the window
300
+ * the queue cannot: a caller that loaded the state before another caller saved
301
+ * it still writes a stale snapshot. Re-reading inside the lock and merging the
302
+ * monotonic fields makes a lost update impossible for the fields that matter to
303
+ * the completion gate.
304
+ */
305
+ function mergeMonotonic(incoming: ProjectStateFile, onDisk: ProjectStateFile | null): ProjectStateFile {
306
+ const a = incoming.current;
307
+ const b = onDisk?.current;
308
+ // Both sides are sanitized: the on-disk copy can be hand-edited or written by
309
+ // an older version, and spreading a non-array field here threw inside the
310
+ // settle handler — which then settled the run silently (a gate bypass).
311
+ sanitizeSteerFields(a);
312
+ sanitizeSteerFields(b);
313
+ if (!a || !b) return incoming;
314
+ if (a.runId !== b.runId) return incoming; // a different run: no merge
315
+ a.steers = Math.max(Number(a.steers) || 0, Number(b.steers) || 0);
316
+ a.seenSteers = [...new Set([...(a.seenSteers || []), ...(b.seenSteers || [])])].filter((x) => typeof x === "string" && x.trim()).slice(-50);
317
+ // Chronological: the on-disk list is older, the incoming one carries the new
318
+ // entry. Appending the incoming entries AFTER the on-disk ones keeps
319
+ // newest-last, which is what the slice(-10) below trims.
320
+ const touched = [...(b.filesTouched || [])];
321
+ for (const f of a.filesTouched || []) if (!touched.includes(f)) touched.push(f);
322
+ a.filesTouched = touched.slice(-10);
323
+ if (typeof b.lastMutationAt === "number") a.lastMutationAt = Math.max(Number(a.lastMutationAt) || 0, b.lastMutationAt);
324
+ if (!a.parked && b.parked) a.parked = b.parked;
325
+ return incoming;
326
+ }
327
+
328
+ async function readStateFile(file: string): Promise<ProjectStateFile | null> {
329
+ try {
330
+ const parsed = JSON.parse(await readFile(file, "utf-8")) as ProjectStateFile;
331
+ return parsed && typeof parsed === "object" ? parsed : null;
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+
337
+ async function writeStateFile(project: string, data: ProjectStateFile): Promise<void> {
338
+ const file = stateFileFor(project);
339
+ await mkdir(join(STATE_DIR, project), { recursive: true });
340
+ const merged = mergeMonotonic(data, await readStateFile(file));
341
+ // Write-then-rename: a reader never sees a half-written file.
342
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
343
+ await writeFile(tmp, JSON.stringify(merged, null, 2), "utf-8");
344
+ await rename(tmp, file);
345
+ }
346
+
221
347
  async function saveState(project: string, data: ProjectStateFile): Promise<void> {
222
348
  if (data.current) runMirror.set(project, JSON.parse(JSON.stringify(data.current)));
223
349
  else runMirror.delete(project);
224
- const file = stateFileFor(project);
225
- await mkdir(join(STATE_DIR, project), { recursive: true });
226
- await writeFile(file, JSON.stringify(data, null, 2), "utf-8");
350
+ const snapshot = JSON.parse(JSON.stringify(data)) as ProjectStateFile;
351
+ const prev = saveQueue.get(project) || Promise.resolve();
352
+ const next = prev.then(() => writeStateFile(project, snapshot), () => writeStateFile(project, snapshot));
353
+ saveQueue.set(project, next.catch(() => undefined));
354
+ await next;
227
355
  }
228
356
 
229
357
  // ─── Spec-memory read (shared with pi-vigilant) ────────────────────────────
@@ -373,7 +501,7 @@ const suiteRuns: Array<{ scope: string; exitCode: number; command: string; durat
373
501
  /** Memoised dirty-tree answer, refreshed at most once per tool call. */
374
502
  let dirtyCache: { at: number; dirty: boolean } | null = null;
375
503
 
376
- let lastSettleDecision: { missing: string[]; exhausted?: boolean } | null = null;
504
+ let lastSettleDecision: { missing: string[] } | null = null;
377
505
  let webTouched = false;
378
506
  let impactStated = false;
379
507
 
@@ -402,9 +530,12 @@ const deliveredSteers = new Set<string>();
402
530
  * information. Events (gate-block, suite-run, transitions) are never deduped:
403
531
  * they are what a trace study measures.
404
532
  */
405
- const EVENT_KINDS = new Set(["run-restored", "gate-block", "suite-run", "spec-transition", "browser-verify", "browser-na", "health-run", "perf-scenario", "run-open", "run-close", "park", "steer", "passive"]);
533
+ const EVENT_KINDS = new Set(["gate-error", "run-restored", "gate-block", "suite-run", "spec-transition", "browser-verify", "browser-na", "health-run", "perf-scenario", "run-open", "run-close", "park", "steer", "passive"]);
406
534
  const persistedVerdicts = new Set<string>();
407
535
  let passiveHeartbeatWritten = false;
536
+ /** Re-anchoring is time-rate-limited AND count-limited per run. */
537
+ const MAX_REANCHORS_PER_RUN = 5;
538
+ let reanchorsThisRun = 0;
408
539
 
409
540
  function verdictSignature(entry: Record<string, unknown>): string {
410
541
  const { ts, at, timestamp, ...rest } = entry as Record<string, unknown> & { ts?: unknown; at?: unknown; timestamp?: unknown };
@@ -575,6 +706,67 @@ let lastScaleWasSmall = false;
575
706
 
576
707
  export default function register(pi: ExtensionAPI): void {
577
708
  piRef = pi;
709
+
710
+ // ── asf_health: the agent's own way to produce a health verdict ───────────
711
+ // The completion gate requires a recorded health verdict, and until 0.15.2 the
712
+ // ONLY way one was recorded was the ledger recognising a bash command that
713
+ // looked like `/asf health` — a pi slash command the agent cannot type. An
714
+ // autonomous run was therefore steered forever at a gap it had no hands for
715
+ // (the 2026-11-07 loop). Any gate that blocks must have an agent-side action;
716
+ // this is that action for the health gate.
717
+ pi.registerTool({
718
+ name: "asf_health",
719
+ label: "ASF Health",
720
+ description:
721
+ "Run ASF's code health gate (function/module/architecture) on this project and " +
722
+ "record the verdict as evidence. This is the agent-side equivalent of /asf health " +
723
+ "and it is what clears the completion gate's health gap — call it instead of " +
724
+ "waiting for a human to type /asf health.",
725
+ promptSnippet: "Run the code health gate and record its verdict",
726
+ parameters: Type.Object({
727
+ level: Type.Optional(
728
+ StringEnum(["small", "standard", "major"] as const, {
729
+ description: "Health level to record; defaults to the run's scale.",
730
+ }),
731
+ ),
732
+ scope: Type.Optional(
733
+ Type.String({ description: "Optional scope passed to the health gate (e.g. 'function')." }),
734
+ ),
735
+ }),
736
+ async execute(_toolCallId: string, params: { level?: unknown; scope?: unknown }) {
737
+ try {
738
+ const levelIn = typeof params?.level === "string" ? params.level : "";
739
+ const scopeIn = typeof params?.scope === "string" ? params.scope : "";
740
+ const { runHealth } = await import("./lib/code-health/index.mjs");
741
+ const args = scopeIn ? [`--scope=${scopeIn}`] : [];
742
+ const res = await runHealth(process.cwd(), args);
743
+ const verdict = res.exitCode === 1 ? "fail" : res.healthy ? "pass" : "warn";
744
+ const scanned = Array.isArray(res.rows) ? res.rows.length : 0;
745
+ const level: string = ["small", "standard", "major"].includes(levelIn) ? levelIn : currentScale();
746
+ const appended = ledger.append({ kind: "health-run", level, verdict, scanned });
747
+ appendEvidence({ kind: "health-run", level, verdict, scanned, by: "tool" });
748
+ return {
749
+ content: [
750
+ {
751
+ type: "text",
752
+ text:
753
+ `${res.text}\n\nverdict: ${verdict} (${scanned} metric(s) measured, level ${level}) — ` +
754
+ (appended.ok
755
+ ? "recorded as evidence; the completion gate's health gap is now closed."
756
+ : `NOT recorded (${appended.error})`),
757
+ },
758
+ ],
759
+ details: { verdict, scanned, level, recorded: appended.ok, by: "tool", error: "" },
760
+ };
761
+ } catch (e) {
762
+ return {
763
+ content: [{ type: "text", text: `asf_health failed: ${(e && (e as Error).message) || String(e)}` }],
764
+ details: { verdict: "unknown", scanned: 0, level: "standard", recorded: false, by: "tool", error: String((e as Error)?.message || e) },
765
+ };
766
+ }
767
+ },
768
+ });
769
+
578
770
  // Startup check: warn once per session if deps are missing (unless disabled)
579
771
  // The extension loads at startup; log to console so it surfaces in logs.
580
772
 
@@ -1180,7 +1372,7 @@ export default function register(pi: ExtensionAPI): void {
1180
1372
  passiveHeartbeatWritten = true;
1181
1373
  appendEvidence({ kind: "passive", by: "no-run" });
1182
1374
  }
1183
- return (steerEntries.length > priorCount ? { entries: steerEntries, continue: true } : undefined) as never;
1375
+ return (steerEntries.length > priorCount && steerContinuationsAllowed(steerLedgerFor(`${project}|no-run`)) ? { entries: steerEntries, continue: true } : undefined) as never;
1184
1376
  }
1185
1377
 
1186
1378
  // Only a passing test that STARTED after the last source change validates
@@ -1204,12 +1396,16 @@ export default function register(pi: ExtensionAPI): void {
1204
1396
  } catch {
1205
1397
  /* spec memory unreadable: this item cannot be judged, the rest still is */
1206
1398
  }
1399
+ // The in-process ledger is the authority; the file is a record that may be
1400
+ // stale, raced or hand-edited. max() means a counter can never go down.
1401
+ const mem = steerLedgerFor(`${project}|${state.runId || "run"}`);
1402
+ sanitizeSteerFields(state);
1207
1403
  const decision = decideSettlement(ledger, {
1208
1404
  untracedMet,
1209
1405
  scale: currentScale(state),
1210
1406
  webTouched,
1211
- continuationsSoFar: state.steers || 0,
1212
- seenSteers: state.seenSteers || [],
1407
+ continuationsSoFar: Math.max(state.steers || 0, mem.count),
1408
+ seenSteers: [...new Set([...(state.seenSteers || []), ...mem.seen])],
1213
1409
  sourceMutated: !!state.sourceMutated,
1214
1410
  testRunRecorded,
1215
1411
  });
@@ -1222,22 +1418,36 @@ export default function register(pi: ExtensionAPI): void {
1222
1418
  });
1223
1419
 
1224
1420
  if (decision.park) {
1421
+ // Park the SAME reason once. An autonomous run has no PO waiting to
1422
+ // answer, so re-parking every settle would be the same endless loop in
1423
+ // a different costume: ask, get nothing, settle, ask again.
1424
+ const parkKey = `${state.runId || "run"}|${decision.park.reason}`;
1425
+ if (parkedAlready.has(parkKey)) {
1426
+ appendEvidence({ kind: "park-suppressed", reason: decision.park.reason });
1427
+ } else {
1428
+ parkedAlready.add(parkKey);
1429
+ steerEntries.push({
1430
+ type: "custom_message",
1431
+ customType: "asf-park",
1432
+ content: `ASF: this needs input ASF cannot supply — ${decision.park.question}\nAsk the PO through the project's input channel (for ticket work: park the ticket), then continue with anything that does not depend on it.`,
1433
+ display: true,
1434
+ details: { park: decision.park },
1435
+ });
1436
+ }
1225
1437
  state.parked = { reason: decision.park.reason, question: decision.park.question, at: new Date().toISOString() };
1226
1438
  await saveState(project, file);
1227
1439
  appendEvidence({ kind: "park", reason: decision.park.reason, question: decision.park.question });
1228
- steerEntries.push({
1229
- type: "custom_message",
1230
- customType: "asf-park",
1231
- content: `ASF: this needs input ASF cannot supply — ${decision.park.question}\nAsk the PO through the project's input channel (for ticket work: park the ticket), then continue with anything that does not depend on it.`,
1232
- display: true,
1233
- details: { park: decision.park },
1234
- });
1235
- return { entries: steerEntries, continue: true } as never;
1440
+ const parkedEntry = steerEntries.find((e) => e.customType === "asf-park");
1441
+ // Only continue while there is something NEW to say; a repeat park is
1442
+ // recorded and the run settles.
1443
+ return (parkedEntry ? { entries: steerEntries, continue: true } : undefined) as never;
1236
1444
  }
1237
1445
 
1238
1446
  if (decision.block) {
1239
- state.steers = decision.steer;
1240
- state.seenSteers = [...(state.seenSteers || []), decision.fingerprint || ""].slice(-50);
1447
+ mem.count += 1;
1448
+ if (decision.fingerprint) mem.seen.add(decision.fingerprint);
1449
+ state.steers = Math.max(typeof decision.steer === "number" ? decision.steer : 0, mem.count);
1450
+ state.seenSteers = [...new Set([...(state.seenSteers || []), ...(decision.fingerprint ? [decision.fingerprint] : [])])].slice(-50);
1241
1451
  state.updatedAt = new Date().toISOString();
1242
1452
  await saveState(project, file);
1243
1453
  // The continuation MUST carry content. pi commits `entries` and only
@@ -1265,9 +1475,25 @@ export default function register(pi: ExtensionAPI): void {
1265
1475
  file.current = null;
1266
1476
  await saveState(project, file);
1267
1477
  }
1268
- return (steerEntries.length > priorCount ? { entries: steerEntries, continue: true } : undefined) as never;
1269
- } catch {
1270
- return undefined;
1478
+ // A continuation for guidance alone (card/steers) is also bounded: an
1479
+ // agent that keeps producing new distinct steers must not be kept alive
1480
+ // by its own advice.
1481
+ const wantsContinue = steerEntries.length > priorCount && steerContinuationsAllowed(mem);
1482
+ return (wantsContinue ? { entries: steerEntries, continue: true } : undefined) as never;
1483
+ } catch (e) {
1484
+ // An internal error is NOT a pass. Silently returning undefined let the
1485
+ // run settle with its evidence gaps unexamined — the gate simply vanished.
1486
+ // The error is recorded, surfaced once (deduped like every other steer),
1487
+ // and the run is left to settle only after the agent has been told.
1488
+ const msg = `ASF: the completion gate hit an internal error and could not judge this run: ${(e && (e as Error).message) || String(e)}. Record it and continue; the gate did not pass, it failed to run.`;
1489
+ appendEvidence({ kind: "gate-error", error: String((e && (e as Error).message) || e).slice(0, 300) });
1490
+ // Keyed on the error CLASS, not the message: an I/O error's message carries
1491
+ // a random tmp filename, so keying on it would make every occurrence look
1492
+ // new and the gate would apologise forever.
1493
+ const key = `gate-error|${(e && (e as { code?: string }).code) || (e && (e as Error).name) || "unknown"}`;
1494
+ if (deliveredSteers.has(key)) return undefined;
1495
+ deliveredSteers.add(key);
1496
+ return { entries: [{ type: "custom_message", customType: "asf-gate-error", content: msg, display: true }], continue: true } as never;
1271
1497
  }
1272
1498
  });
1273
1499
 
@@ -1335,9 +1561,10 @@ export default function register(pi: ExtensionAPI): void {
1335
1561
  machineTurnsSinceHuman++;
1336
1562
  if (machineTurnsSinceHuman >= REANCHOR_AFTER_MACHINE_TURNS && Date.now() - lastReanchorAt >= REANCHOR_MIN_INTERVAL_MS) {
1337
1563
  const file = await loadState(projectName());
1338
- if (file.current && file.current.phase !== "none") {
1564
+ if (file.current && file.current.phase !== "none" && reanchorsThisRun < MAX_REANCHORS_PER_RUN) {
1339
1565
  lastReanchorAt = Date.now();
1340
1566
  machineTurnsSinceHuman = 0;
1567
+ reanchorsThisRun++;
1341
1568
  pendingCard = { text: cardFor(file.current), reason: "reanchor" };
1342
1569
  appendEvidence({ kind: "steer", gate: "reanchor", machineTurns: REANCHOR_AFTER_MACHINE_TURNS });
1343
1570
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Source-file discovery for the health gate.
3
+ *
4
+ * Its own module so model.mjs can use it without importing run.mjs, which
5
+ * imports model.mjs — that pair was a real circular dependency the gate found
6
+ * in ASF itself once madge was actually installed.
7
+ */
8
+
9
+ import { readdirSync } from "node:fs";
10
+ import { join, relative, sep } from "node:path";
11
+
12
+ /** Recursively list source files under root, skipping ignored dirs/files. */
13
+ export function listSourceFiles(root, ignorePatterns) {
14
+ const ignoreDirs = new Set([
15
+ "node_modules", ".git", "dist", "build", "coverage", "test", "tests", "__tests__", "fixtures",
16
+ ".next", ".cache",
17
+ // vendored / generated trees: measuring a dependency's code says nothing
18
+ // about this project, and on a home directory it produced 958 findings from
19
+ // Python site-packages.
20
+ "site-packages", "dist-packages", "venv", ".venv", "env", ".tox", "__pycache__",
21
+ "vendor", "third_party", "bower_components", "target", "Pods", ".gradle", ".m2",
22
+ ]);
23
+ const out = [];
24
+ const walk = (dir) => {
25
+ let entries;
26
+ try {
27
+ entries = readdirSync(dir, { withFileTypes: true });
28
+ } catch {
29
+ return;
30
+ }
31
+ for (const e of entries) {
32
+ const abs = join(dir, e.name);
33
+ const rel = relative(root, abs);
34
+ if (e.isDirectory()) {
35
+ // Dot-directories (.git, .local, .cache, .venv …) are tool state, not
36
+ // project source.
37
+ if (e.name.startsWith(".") && e.name !== ".") continue;
38
+ if (ignoreDirs.has(e.name) || rel.split(sep).some((part) => ignoreDirs.has(part))) continue;
39
+ walk(abs);
40
+ } else if (e.isFile()) {
41
+ if (/\.(test|spec)\.[^.]+$/.test(e.name)) continue;
42
+ if (ignorePatterns && ignorePatterns.some((p) => p.includes(e.name))) continue;
43
+ out.push(rel);
44
+ }
45
+ }
46
+ };
47
+ walk(root);
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * Detect which metric tools are available. Resolution is ASF-first (see
53
+ * tools.mjs): the gate ships its own toolchain, so a project without eslint is
54
+ * still measured rather than skipped.
55
+ */
56
+ export function detectTools(root) {
57
+ const tools = resolveTools(root);
58
+ return {
59
+ eslint: tools.eslint,
60
+ eslintSonarjs: tools.eslintSonarjs,
61
+ jscpd: tools.jscpd,
62
+ madge: tools.madge,
63
+ dependencyCruiser: tools.dependencyCruiser,
64
+ typescriptParser: tools.typescriptParser,
65
+ bins: tools.bins,
66
+ pkgs: tools.pkgs,
67
+ sources: tools.sources,
68
+ missing: missingTools(tools),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Build a flat-config eslint config for the requested rules and write it to a
74
+ * temp file. Deterministic on purpose: `--no-config-lookup` means the project's
75
+ * own config cannot change what the gate measures, and the plugin/parser are
76
+ * imported by absolute file URL so they resolve from ASF's install.
77
+ */
78
+ function writeEslintConfig(rules, tools, root) {
79
+ const imports = [];
80
+ // flat config wants plugin OBJECTS, not names
81
+ let pluginsSrc = "{}";
82
+ const langOpts = ['ecmaVersion: "latest"', 'sourceType: "module"'];
83
+ // Resolve each package's real entry point instead of guessing a path: the
84
+ // plugin's main file moves between major versions (lib/index.js → cjs/plugin.js).
85
+ const entry = (name) => {
86
+ for (const base of [ASF_ROOT, root]) {
87
+ try {
88
+ return pathToFileURL(createRequire(join(base, "package.json")).resolve(name)).href;
89
+ } catch { /* try the next base */ }
90
+ }
91
+ return null;
92
+ };
93
+ const sonarjsEntry = tools.pkgs.sonarjs && entry("eslint-plugin-sonarjs");
94
+ if (sonarjsEntry) {
95
+ // CJS plugin with __esModule: the real object is a nested default.
96
+ imports.push(`import sonarjsMod from ${JSON.stringify(sonarjsEntry)};\nconst sonarjs = sonarjsMod.default || sonarjsMod;`);
97
+ pluginsSrc = "{ sonarjs }";
98
+ }
99
+ const tsParserEntry = tools.pkgs.tsParser && entry("@typescript-eslint/parser");
100
+ if (tsParserEntry) {
101
+ imports.push(`import tsParserMod from ${JSON.stringify(tsParserEntry)};\nconst tsParser = tsParserMod.default || tsParserMod;`);
102
+ langOpts.push("parser: tsParser");
103
+ langOpts.push('parserOptions: { ecmaVersion: "latest", sourceType: "module", ecmaFeatures: { jsx: true } }');
104
+ }
105
+ const body = `${imports.join("\n")}
106
+ export default [{
107
+ files: ["**/*.js", "**/*.mjs", "**/*.cjs", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
108
+ languageOptions: { ${langOpts.join(", ")} },
109
+ plugins: ${pluginsSrc},
110
+ rules: ${JSON.stringify(rules, null, 2)},
111
+ }];
112
+ `;
113
+ const file = join(tmpdir(), `asf-eslint-${process.pid}-${Math.random().toString(36).slice(2, 8)}.mjs`);
114
+ writeFileSync(file, body, "utf-8");
115
+ return file;
116
+ }
117
+
@@ -25,6 +25,10 @@
25
25
  * exception stays visible instead of becoming a silent pass.
26
26
  */
27
27
 
28
+ // The walker lives in files.mjs: importing it from run.mjs made the pair circular.
29
+ import { listSourceFiles } from "./files.mjs";
30
+
31
+
28
32
  /**
29
33
  * Declarations whose documented meaning is "deleting the parent takes the
30
34
  * children". Relational, ORM-mapped and object-store alike — the *concept* is
@@ -371,7 +375,7 @@ export function hasPersistenceFiles(files) {
371
375
  */
372
376
  export async function scanModel(root, { files, readFile, config } = {}) {
373
377
  const fs = readFile || (await import("node:fs/promises")).readFile;
374
- const list = files || (await import("./run.mjs")).listSourceFiles(root, null);
378
+ const list = files || listSourceFiles(root, null);
375
379
  const findings = [];
376
380
  const errors = [];
377
381
  let scanned = 0;
@@ -8,7 +8,15 @@
8
8
  import { readdirSync, statSync, existsSync } from "node:fs";
9
9
  import { join, relative, sep } from "node:path";
10
10
  import { spawn } from "node:child_process";
11
+ import { writeFileSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { pathToFileURL } from "node:url";
14
+ import { createRequire } from "node:module";
11
15
  import { scanModel } from "./model.mjs";
16
+ import { resolveTools, missingTools, ASF_ROOT } from "./tools.mjs";
17
+ import { listSourceFiles } from "./files.mjs";
18
+
19
+ export { listSourceFiles };
12
20
 
13
21
  /** Run a command with a hard timeout; resolve on exit. */
14
22
  export function exec(cmd, args, { cwd, timeoutMs = 120_000, env } = {}) {
@@ -34,45 +42,70 @@ export function exec(cmd, args, { cwd, timeoutMs = 120_000, env } = {}) {
34
42
  });
35
43
  }
36
44
 
37
- /** Recursively list source files under root, skipping ignored dirs/files. */
38
- export function listSourceFiles(root, ignorePatterns) {
39
- const ignoreDirs = new Set(["node_modules", ".git", "dist", "build", "coverage", "test", "tests", "__tests__", "fixtures", ".next", ".cache"]);
40
- const out = [];
41
- const walk = (dir) => {
42
- let entries;
43
- try {
44
- entries = readdirSync(dir, { withFileTypes: true });
45
- } catch {
46
- return;
47
- }
48
- for (const e of entries) {
49
- const abs = join(dir, e.name);
50
- const rel = relative(root, abs);
51
- if (e.isDirectory()) {
52
- if (ignoreDirs.has(e.name) || rel.split(sep).some((part) => ignoreDirs.has(part))) continue;
53
- walk(abs);
54
- } else if (e.isFile()) {
55
- if (/\.(test|spec)\.[^.]+$/.test(e.name)) continue;
56
- if (ignorePatterns && ignorePatterns.some((p) => p.includes(e.name))) continue;
57
- out.push(rel);
58
- }
59
- }
45
+ /**
46
+ * Detect which metric tools are available. Resolution is ASF-first (see
47
+ * tools.mjs): the gate ships its own toolchain, so a project without eslint is
48
+ * still measured rather than skipped.
49
+ */
50
+ export function detectTools(root) {
51
+ const tools = resolveTools(root);
52
+ return {
53
+ eslint: tools.eslint,
54
+ eslintSonarjs: tools.eslintSonarjs,
55
+ jscpd: tools.jscpd,
56
+ madge: tools.madge,
57
+ dependencyCruiser: tools.dependencyCruiser,
58
+ typescriptParser: tools.typescriptParser,
59
+ bins: tools.bins,
60
+ pkgs: tools.pkgs,
61
+ sources: tools.sources,
62
+ missing: missingTools(tools),
60
63
  };
61
- walk(root);
62
- return out;
63
64
  }
64
65
 
65
- /** Detect which metric tools are available in the project. */
66
- export function detectTools(root, { execFn = exec, timeoutMs = 30_000 } = {}) {
67
- const bin = (name) => existsSync(join(root, "node_modules", ".bin", name));
68
- const pkg = (name) => existsSync(join(root, "node_modules", name));
69
- const eslint = bin("eslint");
70
- const eslintSonarjs = eslint && pkg("eslint-plugin-sonarjs");
71
- const jscpd = bin("jscpd") || pkg("jscpd");
72
- const madge = bin("madge") || pkg("madge");
73
- const dependencyCruiser = bin("depcruise") || pkg("dependency-cruiser");
74
- const typescriptParser = pkg("@typescript-eslint/parser");
75
- return { eslint, eslintSonarjs, jscpd, madge, dependencyCruiser, typescriptParser };
66
+ /**
67
+ * Build a flat-config eslint config for the requested rules and write it to a
68
+ * temp file. Deterministic on purpose: `--no-config-lookup` means the project's
69
+ * own config cannot change what the gate measures, and the plugin/parser are
70
+ * imported by absolute file URL so they resolve from ASF's install.
71
+ */
72
+ function writeEslintConfig(rules, tools, root) {
73
+ const imports = [];
74
+ let pluginsSrc = "{}";
75
+ const langOpts = ['ecmaVersion: "latest"', 'sourceType: "module"'];
76
+ // Resolve each package's real entry point instead of guessing a path: the
77
+ // plugin's main file moves between major versions (lib/index.js → cjs/plugin.js).
78
+ const entry = (name) => {
79
+ for (const base of [ASF_ROOT, root]) {
80
+ try {
81
+ return pathToFileURL(createRequire(join(base, "package.json")).resolve(name)).href;
82
+ } catch { /* try the next base */ }
83
+ }
84
+ return null;
85
+ };
86
+ const sonarjsEntry = tools.pkgs.sonarjs && entry("eslint-plugin-sonarjs");
87
+ if (sonarjsEntry) {
88
+ // CJS plugin with __esModule: the real plugin object is a nested default.
89
+ imports.push(`import sonarjsMod from ${JSON.stringify(sonarjsEntry)};\nconst sonarjs = sonarjsMod.default || sonarjsMod;`);
90
+ pluginsSrc = "{ sonarjs }";
91
+ }
92
+ const tsParserEntry = tools.pkgs.tsParser && entry("@typescript-eslint/parser");
93
+ if (tsParserEntry) {
94
+ imports.push(`import tsParserMod from ${JSON.stringify(tsParserEntry)};\nconst tsParser = tsParserMod.default || tsParserMod;`);
95
+ langOpts.push("parser: tsParser");
96
+ langOpts.push('parserOptions: { ecmaVersion: "latest", sourceType: "module", ecmaFeatures: { jsx: true } }');
97
+ }
98
+ const body = `${imports.join("\n")}
99
+ export default [{
100
+ files: ["**/*.js", "**/*.mjs", "**/*.cjs", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
101
+ languageOptions: { ${langOpts.join(", ")} },
102
+ plugins: ${pluginsSrc},
103
+ rules: ${JSON.stringify(rules, null, 2)},
104
+ }];
105
+ `;
106
+ const file = join(tmpdir(), `asf-eslint-${process.pid}-${Math.random().toString(36).slice(2, 8)}.mjs`);
107
+ writeFileSync(file, body, "utf-8");
108
+ return file;
76
109
  }
77
110
 
78
111
  const ESLINT_RULES = {
@@ -99,22 +132,30 @@ export async function runEslint(plans, root, { execFn = exec, timeoutMs = 120_00
99
132
  if (eslintPlans.length === 0) return {};
100
133
  const files = listSourceFiles(root, null).filter((f) => /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(f));
101
134
  if (files.length === 0) return {};
135
+ const tools = resolveTools(root);
136
+ if (!tools.bins.eslint) {
137
+ return { __error: "eslint is not available — it ships with pi-aia-asf; reinstall the package" };
138
+ }
102
139
  const hasTs = files.some((f) => /\.tsx?$/.test(f));
103
- const args = ["--no-eslintrc", "--format", "json"];
104
- if (hasTs) {
105
- if (!detectTools(root).typescriptParser) {
106
- return { __error: "TypeScript parser (@typescript-eslint/parser) not installed — cannot lint .ts files" };
107
- }
108
- args.push("--parser", "@typescript-eslint/parser");
140
+ if (hasTs && !tools.typescriptParser) {
141
+ return { __error: "@typescript-eslint/parser is not available — it ships with pi-aia-asf; reinstall the package" };
109
142
  }
143
+ const rules = {};
110
144
  for (const p of eslintPlans) {
111
145
  const ruleId = ESLINT_RULE_IDS[p.key];
112
146
  if (!ruleId) continue;
113
- const rule = ESLINT_RULES[p.key](p.threshold);
114
- args.push("--rule", JSON.stringify({ [ruleId]: rule }));
147
+ // eslint 9 flat config uses the bare rule name; the plugin namespace is
148
+ // only for sonarjs.
149
+ rules[p.tool === "eslint-sonarjs" ? `sonarjs/${ruleId}` : ruleId] = ESLINT_RULES[p.key](p.threshold);
150
+ }
151
+ const configFile = writeEslintConfig(rules, tools, root);
152
+ const args = ["--no-config-lookup", "--config", configFile, "--format", "json", ...files];
153
+ let res;
154
+ try {
155
+ res = await execFn(tools.bins.eslint, args, { cwd: root, timeoutMs });
156
+ } finally {
157
+ try { rmSync(configFile, { force: true }); } catch { /* best effort */ }
115
158
  }
116
- args.push(...files);
117
- const res = await execFn("npx", ["--no-install", "eslint", ...args], { cwd: root, timeoutMs });
118
159
  if (res.timedOut) return { __error: `eslint timed out after ${timeoutMs / 1000}s` };
119
160
  if (res.code !== 0 && !res.stdout) return { __error: `eslint failed: ${(res.stderr || "").slice(0, 300)}` };
120
161
  let data;
@@ -135,7 +176,10 @@ export async function runEslint(plans, root, { execFn = exec, timeoutMs = 120_00
135
176
  }
136
177
  const out = {};
137
178
  for (const p of eslintPlans) {
138
- const ruleId = ESLINT_RULE_IDS[p.key];
179
+ // The reported id carries the plugin namespace for sonarjs rules; counting
180
+ // the bare name silently reported 0 violations for every sonarjs metric.
181
+ const bare = ESLINT_RULE_IDS[p.key];
182
+ const ruleId = p.tool === "eslint-sonarjs" ? `sonarjs/${bare}` : bare;
139
183
  if (unavailable.has(ruleId)) {
140
184
  out[p.key] = { value: 0, unavailable: true };
141
185
  } else {
@@ -158,7 +202,9 @@ export async function runJscpd(plan, root, { execFn = exec, timeoutMs = 120_000,
158
202
  "--ignore", "**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/test/**,**/tests/**,**/__tests__/**,**/fixtures/**,**/*.test.*,**/*.spec.*",
159
203
  root,
160
204
  ];
161
- const res = await execFn("npx", ["--no-install", "jscpd", ...args], { cwd: root, timeoutMs });
205
+ const bin = resolveTools(root).bins.jscpd;
206
+ if (!bin) return { error: "jscpd is not available — it ships with pi-aia-asf; reinstall the package" };
207
+ const res = await execFn(bin, args, { cwd: root, timeoutMs });
162
208
  if (res.timedOut) return { error: `jscpd timed out after ${timeoutMs / 1000}s` };
163
209
  const reportPath = join(outDir, "jscpd-report.json");
164
210
  try {
@@ -174,7 +220,9 @@ export async function runJscpd(plan, root, { execFn = exec, timeoutMs = 120_000,
174
220
 
175
221
  /** Run madge --circular; return { value: cycle count } or { error }. */
176
222
  export async function runMadge(plan, root, { execFn = exec, timeoutMs = 120_000 } = {}) {
177
- const res = await execFn("npx", ["--no-install", "madge", "--circular", "--extensions", "js,jsx,ts,tsx,mjs,cjs", root], { cwd: root, timeoutMs });
223
+ const bin = resolveTools(root).bins.madge;
224
+ if (!bin) return { error: "madge is not available — it ships with pi-aia-asf; reinstall the package" };
225
+ const res = await execFn(bin, ["--circular", "--extensions", "js,jsx,ts,tsx,mjs,cjs", root], { cwd: root, timeoutMs });
178
226
  if (res.timedOut) return { error: `madge timed out after ${timeoutMs / 1000}s` };
179
227
  const text = res.stdout + res.stderr;
180
228
  if (res.code === 0) return { value: 0 };
@@ -187,7 +235,9 @@ export async function runDepcruise(plan, root, { execFn = exec, timeoutMs = 120_
187
235
  const cfg = plan.args.config;
188
236
  const files = listSourceFiles(root, null).filter((f) => /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/.test(f));
189
237
  if (files.length === 0) return { value: 0 };
190
- const res = await execFn("npx", ["--no-install", "depcruise", "--config", cfg, "--output-type", "err", "--fail-on", "error", ...files], { cwd: root, timeoutMs });
238
+ const bin = resolveTools(root).bins.depcruise;
239
+ if (!bin) return { error: "dependency-cruiser is not available — install it in the project to use this metric" };
240
+ const res = await execFn(bin, ["--config", cfg, "--output-type", "err", "--fail-on", "error", ...files], { cwd: root, timeoutMs });
191
241
  if (res.timedOut) return { error: `dependency-cruiser timed out after ${timeoutMs / 1000}s` };
192
242
  return { value: res.code === 0 ? 0 : 1 };
193
243
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Code Health Gate — tool resolution.
3
+ *
4
+ * The gate measures nothing without its tools, and a project that happens not to
5
+ * ship eslint/jscpd/madge is not a project without complexity problems. So the
6
+ * tools are ASF's OWN dependencies (package.json) and are resolved from ASF
7
+ * first: the same metric means the same thing in every repo, and a fresh clone
8
+ * with an empty node_modules still gets a real measurement instead of eight
9
+ * "tool not installed" skips.
10
+ *
11
+ * Resolution order, per tool:
12
+ * 1. ASF's own node_modules — deterministic, ships with the package
13
+ * 2. the project's node_modules — a project may pin its own version
14
+ * 3. PATH — a global install
15
+ */
16
+
17
+ import { existsSync, statSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ /** ASF's package root (this file is <root>/lib/code-health/tools.mjs). */
22
+ export const ASF_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
23
+
24
+ const TOOLS = {
25
+ eslint: { bin: "eslint", pkg: "eslint" },
26
+ sonarjs: { pkg: "eslint-plugin-sonarjs" },
27
+ tsParser: { pkg: "@typescript-eslint/parser" },
28
+ jscpd: { bin: "jscpd", pkg: "jscpd" },
29
+ madge: { bin: "madge", pkg: "madge" },
30
+ depcruise: { bin: "depcruise", pkg: "dependency-cruiser" },
31
+ };
32
+
33
+ function isExecutable(p) {
34
+ try {
35
+ return statSync(p).isFile();
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function findBin(base, name) {
42
+ const p = join(base, "node_modules", ".bin", name);
43
+ return isExecutable(p) ? p : null;
44
+ }
45
+
46
+ function findPkg(base, name) {
47
+ const p = join(base, "node_modules", name);
48
+ return existsSync(p) ? p : null;
49
+ }
50
+
51
+ function findOnPath(name) {
52
+ for (const dir of String(process.env.PATH || "").split(":")) {
53
+ if (!dir) continue;
54
+ const p = join(dir, name);
55
+ if (isExecutable(p)) return p;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Resolve every tool. `bins` holds absolute paths to spawn (never `npx`, which
62
+ * silently installs a different version or nothing at all).
63
+ *
64
+ * @param {string} projectRoot
65
+ * @returns {{eslint:boolean, eslintSonarjs:boolean, jscpd:boolean, madge:boolean,
66
+ * dependencyCruiser:boolean, typescriptParser:boolean,
67
+ * bins:Record<string,string|null>, pkgs:Record<string,string|null>,
68
+ * sources:Record<string,string>}}
69
+ */
70
+ export function resolveTools(projectRoot) {
71
+ const bases = [
72
+ { dir: ASF_ROOT, source: "asf" },
73
+ { dir: projectRoot, source: "project" },
74
+ ];
75
+ const bins = {};
76
+ const pkgs = {};
77
+ const sources = {};
78
+
79
+ for (const [key, spec] of Object.entries(TOOLS)) {
80
+ if (spec.bin) {
81
+ bins[key] = null;
82
+ for (const b of bases) {
83
+ const found = findBin(b.dir, spec.bin);
84
+ if (found) {
85
+ bins[key] = found;
86
+ sources[key] = b.source;
87
+ break;
88
+ }
89
+ }
90
+ if (!bins[key]) {
91
+ const onPath = findOnPath(spec.bin);
92
+ if (onPath) {
93
+ bins[key] = onPath;
94
+ sources[key] = "path";
95
+ }
96
+ }
97
+ }
98
+ if (spec.pkg) {
99
+ pkgs[key] = null;
100
+ for (const b of bases) {
101
+ const found = findPkg(b.dir, spec.pkg);
102
+ if (found) {
103
+ pkgs[key] = found;
104
+ if (!sources[key]) sources[key] = b.source;
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ // eslint is usable when its binary exists; the sonarjs rules only when the
112
+ // plugin package is there too. A missing plugin must not disable the six
113
+ // eslint metrics that do not need it.
114
+ const eslint = !!bins.eslint;
115
+ return {
116
+ eslint,
117
+ eslintSonarjs: eslint && !!pkgs.sonarjs,
118
+ jscpd: !!bins.jscpd,
119
+ madge: !!bins.madge,
120
+ dependencyCruiser: !!bins.depcruise,
121
+ typescriptParser: !!pkgs.tsParser,
122
+ bins,
123
+ pkgs,
124
+ sources,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Human-readable list of what is missing, for the "make them requirements"
130
+ * contract: the gate names the package, not just "tool not installed".
131
+ */
132
+ export function missingTools(tools) {
133
+ const out = [];
134
+ if (!tools.eslint) out.push("eslint");
135
+ if (!tools.eslintSonarjs) out.push("eslint-plugin-sonarjs");
136
+ if (!tools.jscpd) out.push("jscpd");
137
+ if (!tools.madge) out.push("madge");
138
+ if (!tools.typescriptParser) out.push("@typescript-eslint/parser");
139
+ return out;
140
+ }
@@ -82,7 +82,10 @@ export function blockTestSubset(input, ctx = {}) {
82
82
  scanned: 1,
83
83
  rule: "test-subset-without-impact",
84
84
  reason:
85
- `this runs a test SUBSET without stating the impact area. ` +
85
+ // The command is part of the text on purpose: two different subsets are
86
+ // two different steers (the agent must be told WHICH run it skipped the
87
+ // suite for), and the dedupe in index.ts keys on this exact string.
88
+ `"${String(command).replace(/\s+/g, " ").slice(0, 120)}" runs a test SUBSET without stating the impact area. ` +
86
89
  `Autonomous exit: run the full suite (the default), or re-issue with the ` +
87
90
  `impact area stated so the subset is a decision rather than a shortcut.`,
88
91
  };
@@ -242,9 +242,9 @@ export function sessionFacts(ledger, { scale = "standard", webTouched = false }
242
242
  const health = ledger.latest("health-run");
243
243
  if (scale !== "small") {
244
244
  if (!health) {
245
- missing.push("/asf health was not run this session (run it; its verdict is recorded, not claimed)");
245
+ missing.push("the code health gate has not been run this session — call the asf_health tool (its verdict is recorded, not claimed)");
246
246
  } else if (health.verdict === "unknown") {
247
- missing.push("/asf health ran but produced no verdict — a run without a verdict is not a pass");
247
+ missing.push("the code health gate ran but produced no verdict — call the asf_health tool again; a run without a verdict is not a pass");
248
248
  }
249
249
  }
250
250
 
@@ -28,12 +28,10 @@
28
28
 
29
29
  import { sessionFacts } from "./ledger.mjs";
30
30
 
31
- /** An identical steer is never issued twice. */
32
- export const MAX_IDENTICAL_STEERS = 1;
33
31
  /**
34
- * Absolute ceiling on distinct steers per session. Beyond it the gaps are
35
- * reported and the run is allowed to settle — this is a reporting boundary, not
36
- * a stop: the work is never halted, only the asking.
32
+ * A REPORTING number only — never a stop, and no longer referenced by any
33
+ * decision. It is kept so tooling that prints "steer N" has a number to name;
34
+ * there is no cap on warranted work, and a real gap is re-raised forever.
37
35
  */
38
36
  export const MAX_STEERS = 12;
39
37
 
@@ -51,6 +49,39 @@ const PARK_PATTERNS = [
51
49
  /** A lint or a typecheck is not a test run; the floor must not be closable by one. */
52
50
  const CHECK_ONLY = /\b(eslint|tsc|typecheck|lint|prettier|biome|check)\b/;
53
51
 
52
+ /**
53
+ * Gaps an autonomous agent CANNOT close, because closing them means a human
54
+ * typing something ASF has no tool for (a slash command, a click, a key).
55
+ *
56
+ * This is the 2026-11-07 loop: the gate demanded "/asf health", which is a pi
57
+ * slash command. The agent has no tool that records a health verdict, so it
58
+ * could never clear the gap, and every settle asked again — an autonomous loop
59
+ * steered forever at a wall. A gap of that shape is a REPORTING item, never a
60
+ * blocker: the delivery report says it is unverified, and the run settles.
61
+ *
62
+ * Anything the agent CAN do (run tests, run the asf_health tool, verify in the
63
+ * browser, add a trace) stays a blocker. When you add a gate, it must have an
64
+ * agent-side action; if it does not, it belongs here.
65
+ */
66
+ const HUMAN_ONLY = /\/asf\s+\w+|type\s+\/|ask the (po|user|human)|needs? (your|the po's?) (input|approval|key)/i;
67
+
68
+ /** Can the agent close this gap itself? */
69
+ export function agentClosable(gap) {
70
+ return !HUMAN_ONLY.test(String(gap || ""));
71
+ }
72
+
73
+ /**
74
+ * Split gaps into what may BLOCK and what is only REPORTED. Every caller that
75
+ * builds a missing list goes through this, so a gate cannot quietly start
76
+ * blocking on something the agent has no hands for.
77
+ */
78
+ export function splitGaps(gaps = []) {
79
+ const blocking = [];
80
+ const reported = [];
81
+ for (const g of gaps) (agentClosable(g) ? blocking : reported).push(g);
82
+ return { blocking, reported };
83
+ }
84
+
54
85
  export function classifyPark(missing = []) {
55
86
  for (const m of missing) {
56
87
  for (const p of PARK_PATTERNS) {
@@ -92,7 +123,10 @@ export function decideSettlement(ledger, opts = {}) {
92
123
  } = opts;
93
124
 
94
125
  const facts = sessionFacts(ledger, { scale, webTouched });
95
- const missing = [...facts.missing];
126
+ // Every gap is collected here, then split ONCE at the end. Splitting earlier
127
+ // was a bug: gaps pushed after the split skipped the human-only filter and
128
+ // could block on something the agent has no hands for.
129
+ const gaps = [...facts.missing];
96
130
 
97
131
  // The floor for an active run: source changed ⇒ a *test* run, not a check.
98
132
  // 50% of the runs ASF will own (61 of 121) contained no validation at all.
@@ -100,7 +134,7 @@ export function decideSettlement(ledger, opts = {}) {
100
134
  const hasOnlyChecks = (ledger.entries || []).some(
101
135
  (e) => e.kind === "suite-run" && CHECK_ONLY.test(String(e.command || "")),
102
136
  );
103
- missing.push(
137
+ gaps.push(
104
138
  hasOnlyChecks
105
139
  ? "source changed but only a lint/typecheck ran — run the tests (a check does not close this)"
106
140
  : "source changed this run but no passing test run was recorded (run the tests that cover the change)",
@@ -110,7 +144,7 @@ export function decideSettlement(ledger, opts = {}) {
110
144
  // A spec marked met during this run must say what it delivers and where.
111
145
  // Specs met in earlier runs are not re-litigated here.
112
146
  if (untracedMet.length) {
113
- missing.push(
147
+ gaps.push(
114
148
  `spec(s) marked met this run without a trace (outcome + codePath): ${untracedMet.slice(0, 6).join(", ")}${untracedMet.length > 6 ? ` +${untracedMet.length - 6}` : ""} — add it with update_spec_status trace`,
115
149
  );
116
150
  }
@@ -120,13 +154,28 @@ export function decideSettlement(ledger, opts = {}) {
120
154
  const seen = new Set(
121
155
  ledger.entries.filter((e) => e.kind === "spec-transition" || e.kind === "perf-scenario" || e.kind === "browser-verify" || e.kind === "browser-na").map((e) => e.kind),
122
156
  );
123
- if (!recorded) missing.push("the delivery report cannot be rendered: no health verdict was recorded this session");
157
+ if (!recorded) gaps.push("the delivery report cannot be rendered: no health verdict was recorded this session");
124
158
  if (webTouched && !seen.has("browser-verify") && !seen.has("browser-na")) {
125
- missing.push("the delivery report is missing its browser section");
159
+ gaps.push("the delivery report is missing its browser section");
126
160
  }
127
161
  }
128
162
 
129
- if (missing.length === 0) return { block: false, missing: [], report: null, fingerprint: "", steer: continuationsSoFar };
163
+ const split = splitGaps(gaps);
164
+ const missing = split.blocking;
165
+ const reportedOnly = split.reported;
166
+
167
+ if (missing.length === 0) {
168
+ return {
169
+ block: false,
170
+ missing: [],
171
+ reported: reportedOnly,
172
+ report: reportedOnly.length
173
+ ? `completion gate: ${reportedOnly.length} gap(s) need a human action ASF cannot take — reported as unverified, not blocking: ${reportedOnly.join("; ")}`
174
+ : null,
175
+ fingerprint: "",
176
+ steer: continuationsSoFar,
177
+ };
178
+ }
130
179
 
131
180
  const fingerprint = steerFingerprint(missing);
132
181
 
@@ -137,6 +186,7 @@ export function decideSettlement(ledger, opts = {}) {
137
186
  return {
138
187
  block: false,
139
188
  missing,
189
+ reported: reportedOnly,
140
190
  park,
141
191
  fingerprint,
142
192
  steer: continuationsSoFar,
@@ -144,46 +194,30 @@ export function decideSettlement(ledger, opts = {}) {
144
194
  };
145
195
  }
146
196
 
147
- // Never issue the same steer twice. If the identical gap came back, the agent
148
- // either fixed it (it would not be missing) or the steer did not land; asking
149
- // again in the same words is the definition of a loop. Report and let settle.
150
- if (seenSteers.includes(fingerprint) && MAX_IDENTICAL_STEERS <= 1) {
151
- return {
152
- block: false,
153
- missing,
154
- exhausted: true,
155
- fingerprint,
156
- steer: continuationsSoFar,
157
- report:
158
- `completion gate: ${missing.length} evidence gap(s) remain and this exact gap was already raised — ` +
159
- `it is not asked twice. The delivery report must state these as unverified.`,
160
- };
161
- }
162
-
163
- // Reporting boundary, not a stop.
164
- if (continuationsSoFar >= MAX_STEERS) {
165
- return {
166
- block: false,
167
- missing,
168
- exhausted: true,
169
- fingerprint,
170
- steer: continuationsSoFar,
171
- report:
172
- `completion gate: ${missing.length} evidence gap(s) remain after ${MAX_STEERS} steers. ` +
173
- `Reported rather than re-requested — the delivery report must state these as unverified.`,
174
- };
175
- }
197
+ // A REAL gap is never a stop. The 2026-11-07 loop was unwarranted (it steered
198
+ // over a pi slash command the agent has no tool for) — the cure is to remove
199
+ // unclosable and repeated-no-reason steers, NOT to cap warranted work. So this
200
+ // gate keeps asking for as long as the gap is real: it re-raises the same gap
201
+ // with an escalating count instead of settling, and it never claims an upper
202
+ // bound it does not have.
203
+ const attempt = continuationsSoFar + 1;
204
+ const repeat = seenSteers.includes(fingerprint);
205
+ const lead = repeat
206
+ ? `ASF completion gate: still open after ${attempt - 1} previous steer(s). Missing:`
207
+ : `ASF completion gate: this run cannot be declared done yet. Missing:`;
176
208
 
177
209
  return {
178
210
  block: true,
179
211
  missing,
212
+ reported: reportedOnly,
180
213
  fingerprint,
181
- steer: continuationsSoFar + 1,
214
+ steer: attempt,
182
215
  continuation:
183
- `ASF completion gate: this run cannot be declared done yet. Missing:\n- ${missing.join(
216
+ `${lead}\n- ${missing.join(
184
217
  "\n- ",
185
- )}\n\nAddress exactly these and finish. Nothing here stops you — keep working. ` +
186
- `This gap has not been raised before, so it is raised once (steer ${continuationsSoFar + 1} of at most ${MAX_STEERS}); an identical one never comes back.`,
218
+ )}\n\nAddress exactly these and finish. Nothing here stops you, and there is no ` +
219
+ `ceiling on warranted work: this gap is re-raised until it is closed (steer ${attempt}). ` +
220
+ `Only an action ASF cannot take is ever parked or dropped.`,
187
221
  report: null,
188
222
  };
189
223
  }
@@ -194,13 +228,17 @@ export function decideSettlement(ledger, opts = {}) {
194
228
  */
195
229
  export function renderCompletionGate(decision) {
196
230
  const lines = ["## Completion gate"];
231
+ const reported = (decision && decision.reported) || [];
197
232
  if (!decision || !decision.missing || !decision.missing.length) {
198
- lines.push("- all session facts recorded: health verdict, full-suite green run, browser verification or structured n/a, delivery-report sections");
233
+ lines.push(reported.length
234
+ ? `- ${reported.length} gap(s) need a human action ASF has no tool for — reported, not blocking:` + reported.map((g) => `\n - ${g} (not verified)`).join("")
235
+ : "- all session facts recorded: health verdict, full-suite green run, browser verification or structured n/a, delivery-report sections");
199
236
  return lines.join("\n");
200
237
  }
201
- const why = decision.park ? ` (parked for the PO: ${decision.park.reason})` : decision.exhausted ? " (raised once, not re-requested — reported, not blocking)" : "";
238
+ const why = decision.park ? ` (parked for the PO: ${decision.park.reason})` : "";
202
239
  lines.push(`- ${decision.missing.length} evidence gap(s)${why}:`);
203
240
  for (const m of decision.missing) lines.push(` - ${m}`);
241
+ for (const r of reported) lines.push(` - ${r} (needs a human action; reported, not blocking)`);
204
242
  lines.push("- these are **not verified**; the delivery report must not present them as done");
205
243
  return lines.join("\n");
206
244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.15.1",
3
+ "version": "0.17.1",
4
4
  "description": "Ai Applied Agentic Software Factory — codifies the full software development flow: intake, research, spec capture, adversarial analysis, planning with approval gates, test-first implementation, and release. Requires pi-vigilant, pi-smart-web-search, pi-smart-fetch, and pi-aia-browser.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -72,5 +72,12 @@
72
72
  "jiti": "^2.7.0",
73
73
  "typebox": "1.3.27",
74
74
  "typescript": "5.9.3"
75
+ },
76
+ "dependencies": {
77
+ "@typescript-eslint/parser": "^8.72.1",
78
+ "eslint": "^9.39.5",
79
+ "eslint-plugin-sonarjs": "^4.2.2",
80
+ "jscpd": "^5.4.2",
81
+ "madge": "^8.0.0"
75
82
  }
76
83
  }