pi-aia-asf 0.15.0 → 0.16.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,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.16.1] - 2026-11-07
11
+
12
+ ### Added
13
+
14
+ - (describe changes for 0.16.1)
15
+
16
+ ## [0.16.0] - 2026-11-07
17
+
18
+ ### Fixed — the 2026-11-07 endless completion-gate loop
19
+
20
+ The gate could steer forever for **no reason the agent could act on**. Two defects
21
+ compounded: it demanded `/asf health`, a pi slash command an extension has no tool
22
+ to invoke (so the gap could never close), and the "never the same steer twice" rule
23
+ keyed on a fingerprint that concurrent saves kept wiping, so the identical ask was
24
+ re-issued indefinitely. Both are now structurally impossible.
25
+
26
+ - **A gate can only block on something the agent can close.** `splitGaps()` sorts
27
+ every gap into blocking (agent-closable) and reported (needs a human), and it
28
+ runs **once, after all gaps are assembled** — splitting earlier let gaps pushed
29
+ afterwards skip the filter. A human-only gap is rendered as "not verified / not
30
+ blocking" and never resumes the run.
31
+ - **`asf_health` tool.** The health gate's only demand is now an agent-side tool
32
+ call, which records a verdict in the ledger. No gate asks for a slash command.
33
+ - **A warranted loop has no ceiling.** A real, agent-closable gap is steered for as
34
+ long as it stays open; the old `exhausted` paths that settled the run with gaps
35
+ still open are gone. Repeats are worded as repeats ("still open after N previous
36
+ steers") instead of pretending to be new information, and no message claims a
37
+ bound ("of at most 12") the gate does not have.
38
+ - **The steer count is in-process memory, and state.json is only its record.** A
39
+ corrupt, raced or hand-edited file can no longer reset the count or erase a
40
+ fingerprint; `Math.max` on read plus a monotonic merge on write make it
41
+ non-decreasing.
42
+ - **Saves are serialized per project and merged under the lock.** Eleven paths
43
+ read-modify-write the state file; the write is now queued per project and
44
+ re-reads the file inside the lock, merging the monotonic fields (steer count,
45
+ fingerprints, touched files, last mutation). Write-to-temp-then-`rename` means a
46
+ reader never sees a half-written file.
47
+ - **A corrupt state file can no longer disable the gate.** Wrong-typed fields used
48
+ to throw inside the settle handler, and the catch-all returned `undefined` —
49
+ which settled the run as if the gate had passed. Fields are coerced on load and
50
+ on both sides of a merge, and an internal error is now surfaced once as
51
+ `asf-gate-error` (deduped by error class, since an I/O message carries a random
52
+ tmp filename) instead of being swallowed.
53
+ - **A park is asked once per (run, reason)**, and guidance-only continuations are
54
+ bounded per run, so no secondary nudge can hold a session open on its own.
55
+
56
+ Every guard above is pinned by a test that fails when the guard is removed.
57
+
58
+ ## [0.15.1] - 2026-11-06
59
+
60
+ ### Fixed
61
+
62
+ - **Specs are read under the same project key as run state.** 0.15.0 moved run state
63
+ to the git-root key but still read pi-vigilant's spec store by cwd basename. From
64
+ a subdirectory, the trace floor for specs met in the run therefore looked in the
65
+ wrong store and found nothing. pi-vigilant 0.9.0 writes the store under the root
66
+ key, and ASF reads it there. It falls back to the basename location, so an older
67
+ pi-vigilant does not leave ASF unable to see specs. Both suites pin the same
68
+ key vectors.
69
+
10
70
  ## [0.15.0] - 2026-11-06
11
71
 
12
72
  ### Added
package/index.ts CHANGED
@@ -12,13 +12,15 @@
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";
@@ -143,11 +145,10 @@ function stateFileFor(project: string): string {
143
145
  }
144
146
 
145
147
  /**
146
- * The key pi-vigilant uses for spec-memory and feedback (cwd basename). Specs are
147
- * a shared contract between the two extensions, so ASF reads them where
148
- * vigilant writes them; only ASF's OWN run state moved to the root-based key.
148
+ * The pre-0.15 key (cwd basename). Used only to find run state written by an
149
+ * older ASF, and spec memory written by pi-vigilant < 0.9.
149
150
  */
150
- function sharedSpecKey(): string {
151
+ function legacyKey(): string {
151
152
  try {
152
153
  return basename(process.cwd()) || "default";
153
154
  } catch {
@@ -155,12 +156,22 @@ function sharedSpecKey(): string {
155
156
  }
156
157
  }
157
158
 
159
+ /**
160
+ * The spec store for this project. pi-vigilant >= 0.9 writes it under the same
161
+ * root-based key ASF uses (pinned vectors in both suites); older versions wrote
162
+ * it under the basename, which is read as a fallback so an un-upgraded
163
+ * pi-vigilant does not make ASF go blind to specs.
164
+ */
165
+ async function loadProjectSpecs(): Promise<SpecItem[] | null> {
166
+ return (await loadSpecs(projectName())) ?? (await loadSpecs(legacyKey()));
167
+ }
168
+
158
169
  async function loadState(project: string): Promise<ProjectStateFile> {
159
170
  const file = stateFileFor(project);
160
171
  if (!existsSync(file)) {
161
172
  // One-time migration: a run opened under the old basename key is adopted,
162
173
  // not orphaned — an upgrade must never silently end an active run.
163
- const legacy = stateFileFor(sharedSpecKey());
174
+ const legacy = stateFileFor(legacyKey());
164
175
  if (legacy !== file && existsSync(legacy)) {
165
176
  try {
166
177
  const data = JSON.parse(await readFile(legacy, "utf-8")) as ProjectStateFile;
@@ -180,6 +191,7 @@ async function loadState(project: string): Promise<ProjectStateFile> {
180
191
  if (!data || typeof data !== "object") return restoreFromMirror(project, { current: null, history: [] }, "corrupt");
181
192
  if (!data.history) data.history = [];
182
193
  if (!data.current) return restoreFromMirror(project, data, "cleared");
194
+ sanitizeSteerFields(data.current);
183
195
  return data;
184
196
  } catch {
185
197
  return restoreFromMirror(project, { current: null, history: [] }, "corrupt");
@@ -209,12 +221,137 @@ async function restoreFromMirror(project: string, data: ProjectStateFile, how: s
209
221
  */
210
222
  const runMirror = new Map<string, AsfState>();
211
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
+
212
347
  async function saveState(project: string, data: ProjectStateFile): Promise<void> {
213
348
  if (data.current) runMirror.set(project, JSON.parse(JSON.stringify(data.current)));
214
349
  else runMirror.delete(project);
215
- const file = stateFileFor(project);
216
- await mkdir(join(STATE_DIR, project), { recursive: true });
217
- 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;
218
355
  }
219
356
 
220
357
  // ─── Spec-memory read (shared with pi-vigilant) ────────────────────────────
@@ -364,7 +501,7 @@ const suiteRuns: Array<{ scope: string; exitCode: number; command: string; durat
364
501
  /** Memoised dirty-tree answer, refreshed at most once per tool call. */
365
502
  let dirtyCache: { at: number; dirty: boolean } | null = null;
366
503
 
367
- let lastSettleDecision: { missing: string[]; exhausted?: boolean } | null = null;
504
+ let lastSettleDecision: { missing: string[] } | null = null;
368
505
  let webTouched = false;
369
506
  let impactStated = false;
370
507
 
@@ -393,9 +530,12 @@ const deliveredSteers = new Set<string>();
393
530
  * information. Events (gate-block, suite-run, transitions) are never deduped:
394
531
  * they are what a trace study measures.
395
532
  */
396
- 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"]);
397
534
  const persistedVerdicts = new Set<string>();
398
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;
399
539
 
400
540
  function verdictSignature(entry: Record<string, unknown>): string {
401
541
  const { ts, at, timestamp, ...rest } = entry as Record<string, unknown> & { ts?: unknown; at?: unknown; timestamp?: unknown };
@@ -566,6 +706,67 @@ let lastScaleWasSmall = false;
566
706
 
567
707
  export default function register(pi: ExtensionAPI): void {
568
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
+
569
770
  // Startup check: warn once per session if deps are missing (unless disabled)
570
771
  // The extension loads at startup; log to console so it surfaces in logs.
571
772
 
@@ -665,7 +866,7 @@ export default function register(pi: ExtensionAPI): void {
665
866
  ];
666
867
 
667
868
  if (scale === "large") {
668
- const specs = await loadSpecs(sharedSpecKey());
869
+ const specs = await loadProjectSpecs();
669
870
  lines.push("\nSPEC-TO-CODE TRACEABILITY (M1 — large work):");
670
871
  if (!specs || specs.length === 0) {
671
872
  lines.push(
@@ -1171,7 +1372,7 @@ export default function register(pi: ExtensionAPI): void {
1171
1372
  passiveHeartbeatWritten = true;
1172
1373
  appendEvidence({ kind: "passive", by: "no-run" });
1173
1374
  }
1174
- 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;
1175
1376
  }
1176
1377
 
1177
1378
  // Only a passing test that STARTED after the last source change validates
@@ -1183,7 +1384,7 @@ export default function register(pi: ExtensionAPI): void {
1183
1384
  let untracedMet: string[] = [];
1184
1385
  try {
1185
1386
  const runStart = Date.parse(state.startedAt || "") || 0;
1186
- const specs = (await loadSpecs(sharedSpecKey())) || [];
1387
+ const specs = (await loadProjectSpecs()) || [];
1187
1388
  untracedMet = specs
1188
1389
  .filter((sp) => sp.status === "met")
1189
1390
  .filter((sp) => {
@@ -1195,12 +1396,16 @@ export default function register(pi: ExtensionAPI): void {
1195
1396
  } catch {
1196
1397
  /* spec memory unreadable: this item cannot be judged, the rest still is */
1197
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);
1198
1403
  const decision = decideSettlement(ledger, {
1199
1404
  untracedMet,
1200
1405
  scale: currentScale(state),
1201
1406
  webTouched,
1202
- continuationsSoFar: state.steers || 0,
1203
- seenSteers: state.seenSteers || [],
1407
+ continuationsSoFar: Math.max(state.steers || 0, mem.count),
1408
+ seenSteers: [...new Set([...(state.seenSteers || []), ...mem.seen])],
1204
1409
  sourceMutated: !!state.sourceMutated,
1205
1410
  testRunRecorded,
1206
1411
  });
@@ -1213,22 +1418,36 @@ export default function register(pi: ExtensionAPI): void {
1213
1418
  });
1214
1419
 
1215
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
+ }
1216
1437
  state.parked = { reason: decision.park.reason, question: decision.park.question, at: new Date().toISOString() };
1217
1438
  await saveState(project, file);
1218
1439
  appendEvidence({ kind: "park", reason: decision.park.reason, question: decision.park.question });
1219
- steerEntries.push({
1220
- type: "custom_message",
1221
- customType: "asf-park",
1222
- 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.`,
1223
- display: true,
1224
- details: { park: decision.park },
1225
- });
1226
- 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;
1227
1444
  }
1228
1445
 
1229
1446
  if (decision.block) {
1230
- state.steers = decision.steer;
1231
- 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);
1232
1451
  state.updatedAt = new Date().toISOString();
1233
1452
  await saveState(project, file);
1234
1453
  // The continuation MUST carry content. pi commits `entries` and only
@@ -1256,9 +1475,25 @@ export default function register(pi: ExtensionAPI): void {
1256
1475
  file.current = null;
1257
1476
  await saveState(project, file);
1258
1477
  }
1259
- return (steerEntries.length > priorCount ? { entries: steerEntries, continue: true } : undefined) as never;
1260
- } catch {
1261
- 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;
1262
1497
  }
1263
1498
  });
1264
1499
 
@@ -1326,9 +1561,10 @@ export default function register(pi: ExtensionAPI): void {
1326
1561
  machineTurnsSinceHuman++;
1327
1562
  if (machineTurnsSinceHuman >= REANCHOR_AFTER_MACHINE_TURNS && Date.now() - lastReanchorAt >= REANCHOR_MIN_INTERVAL_MS) {
1328
1563
  const file = await loadState(projectName());
1329
- if (file.current && file.current.phase !== "none") {
1564
+ if (file.current && file.current.phase !== "none" && reanchorsThisRun < MAX_REANCHORS_PER_RUN) {
1330
1565
  lastReanchorAt = Date.now();
1331
1566
  machineTurnsSinceHuman = 0;
1567
+ reanchorsThisRun++;
1332
1568
  pendingCard = { text: cardFor(file.current), reason: "reanchor" };
1333
1569
  appendEvidence({ kind: "steer", gate: "reanchor", machineTurns: REANCHOR_AFTER_MACHINE_TURNS });
1334
1570
  }
@@ -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
 
@@ -35,6 +35,10 @@ export const MAX_IDENTICAL_STEERS = 1;
35
35
  * reported and the run is allowed to settle — this is a reporting boundary, not
36
36
  * a stop: the work is never halted, only the asking.
37
37
  */
38
+ /**
39
+ * A REPORTING number only — never a stop. It exists so the steer text can say
40
+ * "steer N" without inventing a bound; there is no cap on warranted work.
41
+ */
38
42
  export const MAX_STEERS = 12;
39
43
 
40
44
  /**
@@ -51,6 +55,39 @@ const PARK_PATTERNS = [
51
55
  /** A lint or a typecheck is not a test run; the floor must not be closable by one. */
52
56
  const CHECK_ONLY = /\b(eslint|tsc|typecheck|lint|prettier|biome|check)\b/;
53
57
 
58
+ /**
59
+ * Gaps an autonomous agent CANNOT close, because closing them means a human
60
+ * typing something ASF has no tool for (a slash command, a click, a key).
61
+ *
62
+ * This is the 2026-11-07 loop: the gate demanded "/asf health", which is a pi
63
+ * slash command. The agent has no tool that records a health verdict, so it
64
+ * could never clear the gap, and every settle asked again — an autonomous loop
65
+ * steered forever at a wall. A gap of that shape is a REPORTING item, never a
66
+ * blocker: the delivery report says it is unverified, and the run settles.
67
+ *
68
+ * Anything the agent CAN do (run tests, run the asf_health tool, verify in the
69
+ * browser, add a trace) stays a blocker. When you add a gate, it must have an
70
+ * agent-side action; if it does not, it belongs here.
71
+ */
72
+ const HUMAN_ONLY = /\/asf\s+\w+|type\s+\/|ask the (po|user|human)|needs? (your|the po's?) (input|approval|key)/i;
73
+
74
+ /** Can the agent close this gap itself? */
75
+ export function agentClosable(gap) {
76
+ return !HUMAN_ONLY.test(String(gap || ""));
77
+ }
78
+
79
+ /**
80
+ * Split gaps into what may BLOCK and what is only REPORTED. Every caller that
81
+ * builds a missing list goes through this, so a gate cannot quietly start
82
+ * blocking on something the agent has no hands for.
83
+ */
84
+ export function splitGaps(gaps = []) {
85
+ const blocking = [];
86
+ const reported = [];
87
+ for (const g of gaps) (agentClosable(g) ? blocking : reported).push(g);
88
+ return { blocking, reported };
89
+ }
90
+
54
91
  export function classifyPark(missing = []) {
55
92
  for (const m of missing) {
56
93
  for (const p of PARK_PATTERNS) {
@@ -92,7 +129,10 @@ export function decideSettlement(ledger, opts = {}) {
92
129
  } = opts;
93
130
 
94
131
  const facts = sessionFacts(ledger, { scale, webTouched });
95
- const missing = [...facts.missing];
132
+ // Every gap is collected here, then split ONCE at the end. Splitting earlier
133
+ // was a bug: gaps pushed after the split skipped the human-only filter and
134
+ // could block on something the agent has no hands for.
135
+ const gaps = [...facts.missing];
96
136
 
97
137
  // The floor for an active run: source changed ⇒ a *test* run, not a check.
98
138
  // 50% of the runs ASF will own (61 of 121) contained no validation at all.
@@ -100,7 +140,7 @@ export function decideSettlement(ledger, opts = {}) {
100
140
  const hasOnlyChecks = (ledger.entries || []).some(
101
141
  (e) => e.kind === "suite-run" && CHECK_ONLY.test(String(e.command || "")),
102
142
  );
103
- missing.push(
143
+ gaps.push(
104
144
  hasOnlyChecks
105
145
  ? "source changed but only a lint/typecheck ran — run the tests (a check does not close this)"
106
146
  : "source changed this run but no passing test run was recorded (run the tests that cover the change)",
@@ -110,7 +150,7 @@ export function decideSettlement(ledger, opts = {}) {
110
150
  // A spec marked met during this run must say what it delivers and where.
111
151
  // Specs met in earlier runs are not re-litigated here.
112
152
  if (untracedMet.length) {
113
- missing.push(
153
+ gaps.push(
114
154
  `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
155
  );
116
156
  }
@@ -120,13 +160,28 @@ export function decideSettlement(ledger, opts = {}) {
120
160
  const seen = new Set(
121
161
  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
162
  );
123
- if (!recorded) missing.push("the delivery report cannot be rendered: no health verdict was recorded this session");
163
+ if (!recorded) gaps.push("the delivery report cannot be rendered: no health verdict was recorded this session");
124
164
  if (webTouched && !seen.has("browser-verify") && !seen.has("browser-na")) {
125
- missing.push("the delivery report is missing its browser section");
165
+ gaps.push("the delivery report is missing its browser section");
126
166
  }
127
167
  }
128
168
 
129
- if (missing.length === 0) return { block: false, missing: [], report: null, fingerprint: "", steer: continuationsSoFar };
169
+ const split = splitGaps(gaps);
170
+ const missing = split.blocking;
171
+ const reportedOnly = split.reported;
172
+
173
+ if (missing.length === 0) {
174
+ return {
175
+ block: false,
176
+ missing: [],
177
+ reported: reportedOnly,
178
+ report: reportedOnly.length
179
+ ? `completion gate: ${reportedOnly.length} gap(s) need a human action ASF cannot take — reported as unverified, not blocking: ${reportedOnly.join("; ")}`
180
+ : null,
181
+ fingerprint: "",
182
+ steer: continuationsSoFar,
183
+ };
184
+ }
130
185
 
131
186
  const fingerprint = steerFingerprint(missing);
132
187
 
@@ -137,6 +192,7 @@ export function decideSettlement(ledger, opts = {}) {
137
192
  return {
138
193
  block: false,
139
194
  missing,
195
+ reported: reportedOnly,
140
196
  park,
141
197
  fingerprint,
142
198
  steer: continuationsSoFar,
@@ -144,46 +200,30 @@ export function decideSettlement(ledger, opts = {}) {
144
200
  };
145
201
  }
146
202
 
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
- }
203
+ // A REAL gap is never a stop. The 2026-11-07 loop was unwarranted (it steered
204
+ // over a pi slash command the agent has no tool for) — the cure is to remove
205
+ // unclosable and repeated-no-reason steers, NOT to cap warranted work. So this
206
+ // gate keeps asking for as long as the gap is real: it re-raises the same gap
207
+ // with an escalating count instead of settling, and it never claims an upper
208
+ // bound it does not have.
209
+ const attempt = continuationsSoFar + 1;
210
+ const repeat = seenSteers.includes(fingerprint);
211
+ const lead = repeat
212
+ ? `ASF completion gate: still open after ${attempt - 1} previous steer(s). Missing:`
213
+ : `ASF completion gate: this run cannot be declared done yet. Missing:`;
176
214
 
177
215
  return {
178
216
  block: true,
179
217
  missing,
218
+ reported: reportedOnly,
180
219
  fingerprint,
181
- steer: continuationsSoFar + 1,
220
+ steer: attempt,
182
221
  continuation:
183
- `ASF completion gate: this run cannot be declared done yet. Missing:\n- ${missing.join(
222
+ `${lead}\n- ${missing.join(
184
223
  "\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.`,
224
+ )}\n\nAddress exactly these and finish. Nothing here stops you, and there is no ` +
225
+ `ceiling on warranted work: this gap is re-raised until it is closed (steer ${attempt}). ` +
226
+ `Only an action ASF cannot take is ever parked or dropped.`,
187
227
  report: null,
188
228
  };
189
229
  }
@@ -194,13 +234,17 @@ export function decideSettlement(ledger, opts = {}) {
194
234
  */
195
235
  export function renderCompletionGate(decision) {
196
236
  const lines = ["## Completion gate"];
237
+ const reported = (decision && decision.reported) || [];
197
238
  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");
239
+ lines.push(reported.length
240
+ ? `- ${reported.length} gap(s) need a human action ASF has no tool for — reported, not blocking:` + reported.map((g) => `\n - ${g} (not verified)`).join("")
241
+ : "- all session facts recorded: health verdict, full-suite green run, browser verification or structured n/a, delivery-report sections");
199
242
  return lines.join("\n");
200
243
  }
201
- const why = decision.park ? ` (parked for the PO: ${decision.park.reason})` : decision.exhausted ? " (raised once, not re-requested — reported, not blocking)" : "";
244
+ const why = decision.park ? ` (parked for the PO: ${decision.park.reason})` : "";
202
245
  lines.push(`- ${decision.missing.length} evidence gap(s)${why}:`);
203
246
  for (const m of decision.missing) lines.push(` - ${m}`);
247
+ for (const r of reported) lines.push(` - ${r} (needs a human action; reported, not blocking)`);
204
248
  lines.push("- these are **not verified**; the delivery report must not present them as done");
205
249
  return lines.join("\n");
206
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",