pi-aia-asf 0.15.1 → 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 +48 -0
- package/index.ts +250 -23
- package/lib/gates/blockers.mjs +4 -1
- package/lib/gates/ledger.mjs +2 -2
- package/lib/gates/settle.mjs +85 -41
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,54 @@ 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
|
+
|
|
10
58
|
## [0.15.1] - 2026-11-06
|
|
11
59
|
|
|
12
60
|
### Fixed
|
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 {
|
|
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";
|
|
@@ -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
|
|
225
|
-
|
|
226
|
-
|
|
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[]
|
|
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.
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
}
|
package/lib/gates/blockers.mjs
CHANGED
|
@@ -82,7 +82,10 @@ export function blockTestSubset(input, ctx = {}) {
|
|
|
82
82
|
scanned: 1,
|
|
83
83
|
rule: "test-subset-without-impact",
|
|
84
84
|
reason:
|
|
85
|
-
|
|
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
|
};
|
package/lib/gates/ledger.mjs
CHANGED
|
@@ -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("
|
|
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("
|
|
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
|
|
package/lib/gates/settle.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
165
|
+
gaps.push("the delivery report is missing its browser section");
|
|
126
166
|
}
|
|
127
167
|
}
|
|
128
168
|
|
|
129
|
-
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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:
|
|
220
|
+
steer: attempt,
|
|
182
221
|
continuation:
|
|
183
|
-
|
|
222
|
+
`${lead}\n- ${missing.join(
|
|
184
223
|
"\n- ",
|
|
185
|
-
)}\n\nAddress exactly these and finish. Nothing here stops you
|
|
186
|
-
`
|
|
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(
|
|
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})` :
|
|
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.
|
|
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",
|