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 +84 -0
- package/index.ts +251 -24
- package/lib/code-health/files.mjs +117 -0
- package/lib/code-health/model.mjs +5 -1
- package/lib/code-health/run.mjs +100 -50
- package/lib/code-health/tools.mjs +140 -0
- package/lib/gates/blockers.mjs +4 -1
- package/lib/gates/ledger.mjs +2 -2
- package/lib/gates/settle.mjs +84 -46
- package/package.json +8 -1
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 {
|
|
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,
|
|
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
|
|
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
|
}
|
|
@@ -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 ||
|
|
378
|
+
const list = files || listSourceFiles(root, null);
|
|
375
379
|
const findings = [];
|
|
376
380
|
const errors = [];
|
|
377
381
|
let scanned = 0;
|
package/lib/code-health/run.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
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
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
159
|
+
gaps.push("the delivery report is missing its browser section");
|
|
126
160
|
}
|
|
127
161
|
}
|
|
128
162
|
|
|
129
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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:
|
|
214
|
+
steer: attempt,
|
|
182
215
|
continuation:
|
|
183
|
-
|
|
216
|
+
`${lead}\n- ${missing.join(
|
|
184
217
|
"\n- ",
|
|
185
|
-
)}\n\nAddress exactly these and finish. Nothing here stops you
|
|
186
|
-
`
|
|
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(
|
|
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})` :
|
|
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.
|
|
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
|
}
|