pi-aia-asf 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/index.ts +266 -30
- 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,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.16.1] - 2026-11-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- (describe changes for 0.16.1)
|
|
15
|
+
|
|
16
|
+
## [0.16.0] - 2026-11-07
|
|
17
|
+
|
|
18
|
+
### Fixed — the 2026-11-07 endless completion-gate loop
|
|
19
|
+
|
|
20
|
+
The gate could steer forever for **no reason the agent could act on**. Two defects
|
|
21
|
+
compounded: it demanded `/asf health`, a pi slash command an extension has no tool
|
|
22
|
+
to invoke (so the gap could never close), and the "never the same steer twice" rule
|
|
23
|
+
keyed on a fingerprint that concurrent saves kept wiping, so the identical ask was
|
|
24
|
+
re-issued indefinitely. Both are now structurally impossible.
|
|
25
|
+
|
|
26
|
+
- **A gate can only block on something the agent can close.** `splitGaps()` sorts
|
|
27
|
+
every gap into blocking (agent-closable) and reported (needs a human), and it
|
|
28
|
+
runs **once, after all gaps are assembled** — splitting earlier let gaps pushed
|
|
29
|
+
afterwards skip the filter. A human-only gap is rendered as "not verified / not
|
|
30
|
+
blocking" and never resumes the run.
|
|
31
|
+
- **`asf_health` tool.** The health gate's only demand is now an agent-side tool
|
|
32
|
+
call, which records a verdict in the ledger. No gate asks for a slash command.
|
|
33
|
+
- **A warranted loop has no ceiling.** A real, agent-closable gap is steered for as
|
|
34
|
+
long as it stays open; the old `exhausted` paths that settled the run with gaps
|
|
35
|
+
still open are gone. Repeats are worded as repeats ("still open after N previous
|
|
36
|
+
steers") instead of pretending to be new information, and no message claims a
|
|
37
|
+
bound ("of at most 12") the gate does not have.
|
|
38
|
+
- **The steer count is in-process memory, and state.json is only its record.** A
|
|
39
|
+
corrupt, raced or hand-edited file can no longer reset the count or erase a
|
|
40
|
+
fingerprint; `Math.max` on read plus a monotonic merge on write make it
|
|
41
|
+
non-decreasing.
|
|
42
|
+
- **Saves are serialized per project and merged under the lock.** Eleven paths
|
|
43
|
+
read-modify-write the state file; the write is now queued per project and
|
|
44
|
+
re-reads the file inside the lock, merging the monotonic fields (steer count,
|
|
45
|
+
fingerprints, touched files, last mutation). Write-to-temp-then-`rename` means a
|
|
46
|
+
reader never sees a half-written file.
|
|
47
|
+
- **A corrupt state file can no longer disable the gate.** Wrong-typed fields used
|
|
48
|
+
to throw inside the settle handler, and the catch-all returned `undefined` —
|
|
49
|
+
which settled the run as if the gate had passed. Fields are coerced on load and
|
|
50
|
+
on both sides of a merge, and an internal error is now surfaced once as
|
|
51
|
+
`asf-gate-error` (deduped by error class, since an I/O message carries a random
|
|
52
|
+
tmp filename) instead of being swallowed.
|
|
53
|
+
- **A park is asked once per (run, reason)**, and guidance-only continuations are
|
|
54
|
+
bounded per run, so no secondary nudge can hold a session open on its own.
|
|
55
|
+
|
|
56
|
+
Every guard above is pinned by a test that fails when the guard is removed.
|
|
57
|
+
|
|
58
|
+
## [0.15.1] - 2026-11-06
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **Specs are read under the same project key as run state.** 0.15.0 moved run state
|
|
63
|
+
to the git-root key but still read pi-vigilant's spec store by cwd basename. From
|
|
64
|
+
a subdirectory, the trace floor for specs met in the run therefore looked in the
|
|
65
|
+
wrong store and found nothing. pi-vigilant 0.9.0 writes the store under the root
|
|
66
|
+
key, and ASF reads it there. It falls back to the basename location, so an older
|
|
67
|
+
pi-vigilant does not leave ASF unable to see specs. Both suites pin the same
|
|
68
|
+
key vectors.
|
|
69
|
+
|
|
10
70
|
## [0.15.0] - 2026-11-06
|
|
11
71
|
|
|
12
72
|
### Added
|
package/index.ts
CHANGED
|
@@ -12,13 +12,15 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import {
|
|
15
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
16
|
+
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
|
|
16
17
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
import { dirname, basename } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
import { homedir } from "node:os";
|
|
21
22
|
import { cpus, loadavg } from "node:os";
|
|
23
|
+
import { Type } from "typebox";
|
|
22
24
|
import { execFileSync } from "node:child_process";
|
|
23
25
|
|
|
24
26
|
import { createLedger, deriveSuiteRun, renderLedger } from "./lib/gates/ledger.mjs";
|
|
@@ -143,11 +145,10 @@ function stateFileFor(project: string): string {
|
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
/**
|
|
146
|
-
* The
|
|
147
|
-
*
|
|
148
|
-
* vigilant writes them; only ASF's OWN run state moved to the root-based key.
|
|
148
|
+
* The pre-0.15 key (cwd basename). Used only to find run state written by an
|
|
149
|
+
* older ASF, and spec memory written by pi-vigilant < 0.9.
|
|
149
150
|
*/
|
|
150
|
-
function
|
|
151
|
+
function legacyKey(): string {
|
|
151
152
|
try {
|
|
152
153
|
return basename(process.cwd()) || "default";
|
|
153
154
|
} catch {
|
|
@@ -155,12 +156,22 @@ function sharedSpecKey(): string {
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The spec store for this project. pi-vigilant >= 0.9 writes it under the same
|
|
161
|
+
* root-based key ASF uses (pinned vectors in both suites); older versions wrote
|
|
162
|
+
* it under the basename, which is read as a fallback so an un-upgraded
|
|
163
|
+
* pi-vigilant does not make ASF go blind to specs.
|
|
164
|
+
*/
|
|
165
|
+
async function loadProjectSpecs(): Promise<SpecItem[] | null> {
|
|
166
|
+
return (await loadSpecs(projectName())) ?? (await loadSpecs(legacyKey()));
|
|
167
|
+
}
|
|
168
|
+
|
|
158
169
|
async function loadState(project: string): Promise<ProjectStateFile> {
|
|
159
170
|
const file = stateFileFor(project);
|
|
160
171
|
if (!existsSync(file)) {
|
|
161
172
|
// One-time migration: a run opened under the old basename key is adopted,
|
|
162
173
|
// not orphaned — an upgrade must never silently end an active run.
|
|
163
|
-
const legacy = stateFileFor(
|
|
174
|
+
const legacy = stateFileFor(legacyKey());
|
|
164
175
|
if (legacy !== file && existsSync(legacy)) {
|
|
165
176
|
try {
|
|
166
177
|
const data = JSON.parse(await readFile(legacy, "utf-8")) as ProjectStateFile;
|
|
@@ -180,6 +191,7 @@ async function loadState(project: string): Promise<ProjectStateFile> {
|
|
|
180
191
|
if (!data || typeof data !== "object") return restoreFromMirror(project, { current: null, history: [] }, "corrupt");
|
|
181
192
|
if (!data.history) data.history = [];
|
|
182
193
|
if (!data.current) return restoreFromMirror(project, data, "cleared");
|
|
194
|
+
sanitizeSteerFields(data.current);
|
|
183
195
|
return data;
|
|
184
196
|
} catch {
|
|
185
197
|
return restoreFromMirror(project, { current: null, history: [] }, "corrupt");
|
|
@@ -209,12 +221,137 @@ async function restoreFromMirror(project: string, data: ProjectStateFile, how: s
|
|
|
209
221
|
*/
|
|
210
222
|
const runMirror = new Map<string, AsfState>();
|
|
211
223
|
|
|
224
|
+
/**
|
|
225
|
+
* How much each run has been steered, held IN THIS PROCESS.
|
|
226
|
+
*
|
|
227
|
+
* The 2026-11-07 loop: the bound on identical steers lived only in state.json.
|
|
228
|
+
* A concurrent writer (the tool_call handler saves the same file on every source
|
|
229
|
+
* mutation) could put an older copy back, and the counter restarted — so the
|
|
230
|
+
* same gap was re-asked forever and the autonomous loop never ended. The state
|
|
231
|
+
* file is now a *record*; this map is the *authority* for this session. A run's
|
|
232
|
+
* steer count can go up, never down, and an identical steer is never issued
|
|
233
|
+
* twice in the same process no matter what the file says.
|
|
234
|
+
*/
|
|
235
|
+
const steerMemory = new Map<string, { count: number; seen: Set<string>; guidance: number }>();
|
|
236
|
+
/** (runId|reason) already parked in this process — a park is asked once. */
|
|
237
|
+
const parkedAlready = new Set<string>();
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Guidance-only continuations (card, steers) are capped per session. The
|
|
241
|
+
* completion gate has its own bound; this one covers the paths that continue a
|
|
242
|
+
* run merely to say something.
|
|
243
|
+
*/
|
|
244
|
+
const MAX_GUIDANCE_CONTINUATIONS = 24;
|
|
245
|
+
/**
|
|
246
|
+
* Per-RUN, not per-process: a long session runs many runs, and each deserves
|
|
247
|
+
* its cards. A run kept alive purely by its own advice is the pathology.
|
|
248
|
+
*/
|
|
249
|
+
function steerContinuationsAllowed(mem: { guidance: number }): boolean {
|
|
250
|
+
if (mem.guidance >= MAX_GUIDANCE_CONTINUATIONS) {
|
|
251
|
+
appendEvidence({ kind: "steer", gate: "guidance-cap", suppressed: true });
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
mem.guidance++;
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
function steerLedgerFor(key: string): { count: number; seen: Set<string>; guidance: number } {
|
|
258
|
+
let m = steerMemory.get(key);
|
|
259
|
+
if (!m) {
|
|
260
|
+
m = { count: 0, seen: new Set<string>(), guidance: 0 };
|
|
261
|
+
if (steerMemory.size > 64) steerMemory.clear();
|
|
262
|
+
steerMemory.set(key, m);
|
|
263
|
+
}
|
|
264
|
+
return m;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Coerce a loaded run state's steer fields — a hand-edited or raced file must not disable the bound. */
|
|
268
|
+
/**
|
|
269
|
+
* Coerce the fields the gate reads into the types it assumes. A hand-edited or
|
|
270
|
+
* older-version file must never be able to (a) throw inside the handler or
|
|
271
|
+
* (b) make every later save fail — so this runs on load AND on both sides of a
|
|
272
|
+
* merge, not only on the steer fields it was originally written for.
|
|
273
|
+
*/
|
|
274
|
+
function sanitizeSteerFields(state: AsfState | null | undefined): void {
|
|
275
|
+
if (!state) return;
|
|
276
|
+
if (typeof state.steers !== "number" || !Number.isFinite(state.steers) || state.steers < 0) state.steers = 0;
|
|
277
|
+
if (!Array.isArray(state.seenSteers)) state.seenSteers = [];
|
|
278
|
+
state.seenSteers = (state.seenSteers as unknown[]).filter((x): x is string => typeof x === "string" && x.trim().length > 0);
|
|
279
|
+
if (!Array.isArray(state.filesTouched)) state.filesTouched = [];
|
|
280
|
+
else state.filesTouched = (state.filesTouched as unknown[]).filter((f): f is string => typeof f === "string" && f.trim().length > 0).slice(-10);
|
|
281
|
+
if (typeof state.lastMutationAt !== "number" || !Number.isFinite(state.lastMutationAt)) state.lastMutationAt = 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Saves are SERIALIZED per project.
|
|
286
|
+
*
|
|
287
|
+
* Eleven code paths save the same file (activation, settle, park, close, abort,
|
|
288
|
+
* restore…). Each one read-modify-writes it, so two overlapping saves let an
|
|
289
|
+
* older copy land last — which is how the 2026-11-07 loop survived its own
|
|
290
|
+
* bound: the settle handler wrote the steer counter, a source-mutation save put
|
|
291
|
+
* an older copy back, and the counter restarted. Writes are now queued per
|
|
292
|
+
* project, so a save cannot interleave with another.
|
|
293
|
+
*/
|
|
294
|
+
const saveQueue = new Map<string, Promise<void>>();
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Monotonic fields: once a run has been steered, or has touched a file, an older
|
|
298
|
+
* copy of the state must never erase that. Eleven code paths read-modify-write
|
|
299
|
+
* this file; the queue serializes the WRITES, and this merge closes the window
|
|
300
|
+
* the queue cannot: a caller that loaded the state before another caller saved
|
|
301
|
+
* it still writes a stale snapshot. Re-reading inside the lock and merging the
|
|
302
|
+
* monotonic fields makes a lost update impossible for the fields that matter to
|
|
303
|
+
* the completion gate.
|
|
304
|
+
*/
|
|
305
|
+
function mergeMonotonic(incoming: ProjectStateFile, onDisk: ProjectStateFile | null): ProjectStateFile {
|
|
306
|
+
const a = incoming.current;
|
|
307
|
+
const b = onDisk?.current;
|
|
308
|
+
// Both sides are sanitized: the on-disk copy can be hand-edited or written by
|
|
309
|
+
// an older version, and spreading a non-array field here threw inside the
|
|
310
|
+
// settle handler — which then settled the run silently (a gate bypass).
|
|
311
|
+
sanitizeSteerFields(a);
|
|
312
|
+
sanitizeSteerFields(b);
|
|
313
|
+
if (!a || !b) return incoming;
|
|
314
|
+
if (a.runId !== b.runId) return incoming; // a different run: no merge
|
|
315
|
+
a.steers = Math.max(Number(a.steers) || 0, Number(b.steers) || 0);
|
|
316
|
+
a.seenSteers = [...new Set([...(a.seenSteers || []), ...(b.seenSteers || [])])].filter((x) => typeof x === "string" && x.trim()).slice(-50);
|
|
317
|
+
// Chronological: the on-disk list is older, the incoming one carries the new
|
|
318
|
+
// entry. Appending the incoming entries AFTER the on-disk ones keeps
|
|
319
|
+
// newest-last, which is what the slice(-10) below trims.
|
|
320
|
+
const touched = [...(b.filesTouched || [])];
|
|
321
|
+
for (const f of a.filesTouched || []) if (!touched.includes(f)) touched.push(f);
|
|
322
|
+
a.filesTouched = touched.slice(-10);
|
|
323
|
+
if (typeof b.lastMutationAt === "number") a.lastMutationAt = Math.max(Number(a.lastMutationAt) || 0, b.lastMutationAt);
|
|
324
|
+
if (!a.parked && b.parked) a.parked = b.parked;
|
|
325
|
+
return incoming;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function readStateFile(file: string): Promise<ProjectStateFile | null> {
|
|
329
|
+
try {
|
|
330
|
+
const parsed = JSON.parse(await readFile(file, "utf-8")) as ProjectStateFile;
|
|
331
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function writeStateFile(project: string, data: ProjectStateFile): Promise<void> {
|
|
338
|
+
const file = stateFileFor(project);
|
|
339
|
+
await mkdir(join(STATE_DIR, project), { recursive: true });
|
|
340
|
+
const merged = mergeMonotonic(data, await readStateFile(file));
|
|
341
|
+
// Write-then-rename: a reader never sees a half-written file.
|
|
342
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
343
|
+
await writeFile(tmp, JSON.stringify(merged, null, 2), "utf-8");
|
|
344
|
+
await rename(tmp, file);
|
|
345
|
+
}
|
|
346
|
+
|
|
212
347
|
async function saveState(project: string, data: ProjectStateFile): Promise<void> {
|
|
213
348
|
if (data.current) runMirror.set(project, JSON.parse(JSON.stringify(data.current)));
|
|
214
349
|
else runMirror.delete(project);
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
350
|
+
const snapshot = JSON.parse(JSON.stringify(data)) as ProjectStateFile;
|
|
351
|
+
const prev = saveQueue.get(project) || Promise.resolve();
|
|
352
|
+
const next = prev.then(() => writeStateFile(project, snapshot), () => writeStateFile(project, snapshot));
|
|
353
|
+
saveQueue.set(project, next.catch(() => undefined));
|
|
354
|
+
await next;
|
|
218
355
|
}
|
|
219
356
|
|
|
220
357
|
// ─── Spec-memory read (shared with pi-vigilant) ────────────────────────────
|
|
@@ -364,7 +501,7 @@ const suiteRuns: Array<{ scope: string; exitCode: number; command: string; durat
|
|
|
364
501
|
/** Memoised dirty-tree answer, refreshed at most once per tool call. */
|
|
365
502
|
let dirtyCache: { at: number; dirty: boolean } | null = null;
|
|
366
503
|
|
|
367
|
-
let lastSettleDecision: { missing: string[]
|
|
504
|
+
let lastSettleDecision: { missing: string[] } | null = null;
|
|
368
505
|
let webTouched = false;
|
|
369
506
|
let impactStated = false;
|
|
370
507
|
|
|
@@ -393,9 +530,12 @@ const deliveredSteers = new Set<string>();
|
|
|
393
530
|
* information. Events (gate-block, suite-run, transitions) are never deduped:
|
|
394
531
|
* they are what a trace study measures.
|
|
395
532
|
*/
|
|
396
|
-
const EVENT_KINDS = new Set(["run-restored", "gate-block", "suite-run", "spec-transition", "browser-verify", "browser-na", "health-run", "perf-scenario", "run-open", "run-close", "park", "steer", "passive"]);
|
|
533
|
+
const EVENT_KINDS = new Set(["gate-error", "run-restored", "gate-block", "suite-run", "spec-transition", "browser-verify", "browser-na", "health-run", "perf-scenario", "run-open", "run-close", "park", "steer", "passive"]);
|
|
397
534
|
const persistedVerdicts = new Set<string>();
|
|
398
535
|
let passiveHeartbeatWritten = false;
|
|
536
|
+
/** Re-anchoring is time-rate-limited AND count-limited per run. */
|
|
537
|
+
const MAX_REANCHORS_PER_RUN = 5;
|
|
538
|
+
let reanchorsThisRun = 0;
|
|
399
539
|
|
|
400
540
|
function verdictSignature(entry: Record<string, unknown>): string {
|
|
401
541
|
const { ts, at, timestamp, ...rest } = entry as Record<string, unknown> & { ts?: unknown; at?: unknown; timestamp?: unknown };
|
|
@@ -566,6 +706,67 @@ let lastScaleWasSmall = false;
|
|
|
566
706
|
|
|
567
707
|
export default function register(pi: ExtensionAPI): void {
|
|
568
708
|
piRef = pi;
|
|
709
|
+
|
|
710
|
+
// ── asf_health: the agent's own way to produce a health verdict ───────────
|
|
711
|
+
// The completion gate requires a recorded health verdict, and until 0.15.2 the
|
|
712
|
+
// ONLY way one was recorded was the ledger recognising a bash command that
|
|
713
|
+
// looked like `/asf health` — a pi slash command the agent cannot type. An
|
|
714
|
+
// autonomous run was therefore steered forever at a gap it had no hands for
|
|
715
|
+
// (the 2026-11-07 loop). Any gate that blocks must have an agent-side action;
|
|
716
|
+
// this is that action for the health gate.
|
|
717
|
+
pi.registerTool({
|
|
718
|
+
name: "asf_health",
|
|
719
|
+
label: "ASF Health",
|
|
720
|
+
description:
|
|
721
|
+
"Run ASF's code health gate (function/module/architecture) on this project and " +
|
|
722
|
+
"record the verdict as evidence. This is the agent-side equivalent of /asf health " +
|
|
723
|
+
"and it is what clears the completion gate's health gap — call it instead of " +
|
|
724
|
+
"waiting for a human to type /asf health.",
|
|
725
|
+
promptSnippet: "Run the code health gate and record its verdict",
|
|
726
|
+
parameters: Type.Object({
|
|
727
|
+
level: Type.Optional(
|
|
728
|
+
StringEnum(["small", "standard", "major"] as const, {
|
|
729
|
+
description: "Health level to record; defaults to the run's scale.",
|
|
730
|
+
}),
|
|
731
|
+
),
|
|
732
|
+
scope: Type.Optional(
|
|
733
|
+
Type.String({ description: "Optional scope passed to the health gate (e.g. 'function')." }),
|
|
734
|
+
),
|
|
735
|
+
}),
|
|
736
|
+
async execute(_toolCallId: string, params: { level?: unknown; scope?: unknown }) {
|
|
737
|
+
try {
|
|
738
|
+
const levelIn = typeof params?.level === "string" ? params.level : "";
|
|
739
|
+
const scopeIn = typeof params?.scope === "string" ? params.scope : "";
|
|
740
|
+
const { runHealth } = await import("./lib/code-health/index.mjs");
|
|
741
|
+
const args = scopeIn ? [`--scope=${scopeIn}`] : [];
|
|
742
|
+
const res = await runHealth(process.cwd(), args);
|
|
743
|
+
const verdict = res.exitCode === 1 ? "fail" : res.healthy ? "pass" : "warn";
|
|
744
|
+
const scanned = Array.isArray(res.rows) ? res.rows.length : 0;
|
|
745
|
+
const level: string = ["small", "standard", "major"].includes(levelIn) ? levelIn : currentScale();
|
|
746
|
+
const appended = ledger.append({ kind: "health-run", level, verdict, scanned });
|
|
747
|
+
appendEvidence({ kind: "health-run", level, verdict, scanned, by: "tool" });
|
|
748
|
+
return {
|
|
749
|
+
content: [
|
|
750
|
+
{
|
|
751
|
+
type: "text",
|
|
752
|
+
text:
|
|
753
|
+
`${res.text}\n\nverdict: ${verdict} (${scanned} metric(s) measured, level ${level}) — ` +
|
|
754
|
+
(appended.ok
|
|
755
|
+
? "recorded as evidence; the completion gate's health gap is now closed."
|
|
756
|
+
: `NOT recorded (${appended.error})`),
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
details: { verdict, scanned, level, recorded: appended.ok, by: "tool", error: "" },
|
|
760
|
+
};
|
|
761
|
+
} catch (e) {
|
|
762
|
+
return {
|
|
763
|
+
content: [{ type: "text", text: `asf_health failed: ${(e && (e as Error).message) || String(e)}` }],
|
|
764
|
+
details: { verdict: "unknown", scanned: 0, level: "standard", recorded: false, by: "tool", error: String((e as Error)?.message || e) },
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
|
|
569
770
|
// Startup check: warn once per session if deps are missing (unless disabled)
|
|
570
771
|
// The extension loads at startup; log to console so it surfaces in logs.
|
|
571
772
|
|
|
@@ -665,7 +866,7 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
665
866
|
];
|
|
666
867
|
|
|
667
868
|
if (scale === "large") {
|
|
668
|
-
const specs = await
|
|
869
|
+
const specs = await loadProjectSpecs();
|
|
669
870
|
lines.push("\nSPEC-TO-CODE TRACEABILITY (M1 — large work):");
|
|
670
871
|
if (!specs || specs.length === 0) {
|
|
671
872
|
lines.push(
|
|
@@ -1171,7 +1372,7 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
1171
1372
|
passiveHeartbeatWritten = true;
|
|
1172
1373
|
appendEvidence({ kind: "passive", by: "no-run" });
|
|
1173
1374
|
}
|
|
1174
|
-
return (steerEntries.length > priorCount ? { entries: steerEntries, continue: true } : undefined) as never;
|
|
1375
|
+
return (steerEntries.length > priorCount && steerContinuationsAllowed(steerLedgerFor(`${project}|no-run`)) ? { entries: steerEntries, continue: true } : undefined) as never;
|
|
1175
1376
|
}
|
|
1176
1377
|
|
|
1177
1378
|
// Only a passing test that STARTED after the last source change validates
|
|
@@ -1183,7 +1384,7 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
1183
1384
|
let untracedMet: string[] = [];
|
|
1184
1385
|
try {
|
|
1185
1386
|
const runStart = Date.parse(state.startedAt || "") || 0;
|
|
1186
|
-
const specs = (await
|
|
1387
|
+
const specs = (await loadProjectSpecs()) || [];
|
|
1187
1388
|
untracedMet = specs
|
|
1188
1389
|
.filter((sp) => sp.status === "met")
|
|
1189
1390
|
.filter((sp) => {
|
|
@@ -1195,12 +1396,16 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
1195
1396
|
} catch {
|
|
1196
1397
|
/* spec memory unreadable: this item cannot be judged, the rest still is */
|
|
1197
1398
|
}
|
|
1399
|
+
// The in-process ledger is the authority; the file is a record that may be
|
|
1400
|
+
// stale, raced or hand-edited. max() means a counter can never go down.
|
|
1401
|
+
const mem = steerLedgerFor(`${project}|${state.runId || "run"}`);
|
|
1402
|
+
sanitizeSteerFields(state);
|
|
1198
1403
|
const decision = decideSettlement(ledger, {
|
|
1199
1404
|
untracedMet,
|
|
1200
1405
|
scale: currentScale(state),
|
|
1201
1406
|
webTouched,
|
|
1202
|
-
continuationsSoFar: state.steers || 0,
|
|
1203
|
-
seenSteers: state.seenSteers || [],
|
|
1407
|
+
continuationsSoFar: Math.max(state.steers || 0, mem.count),
|
|
1408
|
+
seenSteers: [...new Set([...(state.seenSteers || []), ...mem.seen])],
|
|
1204
1409
|
sourceMutated: !!state.sourceMutated,
|
|
1205
1410
|
testRunRecorded,
|
|
1206
1411
|
});
|
|
@@ -1213,22 +1418,36 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
1213
1418
|
});
|
|
1214
1419
|
|
|
1215
1420
|
if (decision.park) {
|
|
1421
|
+
// Park the SAME reason once. An autonomous run has no PO waiting to
|
|
1422
|
+
// answer, so re-parking every settle would be the same endless loop in
|
|
1423
|
+
// a different costume: ask, get nothing, settle, ask again.
|
|
1424
|
+
const parkKey = `${state.runId || "run"}|${decision.park.reason}`;
|
|
1425
|
+
if (parkedAlready.has(parkKey)) {
|
|
1426
|
+
appendEvidence({ kind: "park-suppressed", reason: decision.park.reason });
|
|
1427
|
+
} else {
|
|
1428
|
+
parkedAlready.add(parkKey);
|
|
1429
|
+
steerEntries.push({
|
|
1430
|
+
type: "custom_message",
|
|
1431
|
+
customType: "asf-park",
|
|
1432
|
+
content: `ASF: this needs input ASF cannot supply — ${decision.park.question}\nAsk the PO through the project's input channel (for ticket work: park the ticket), then continue with anything that does not depend on it.`,
|
|
1433
|
+
display: true,
|
|
1434
|
+
details: { park: decision.park },
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1216
1437
|
state.parked = { reason: decision.park.reason, question: decision.park.question, at: new Date().toISOString() };
|
|
1217
1438
|
await saveState(project, file);
|
|
1218
1439
|
appendEvidence({ kind: "park", reason: decision.park.reason, question: decision.park.question });
|
|
1219
|
-
steerEntries.
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
display: true,
|
|
1224
|
-
details: { park: decision.park },
|
|
1225
|
-
});
|
|
1226
|
-
return { entries: steerEntries, continue: true } as never;
|
|
1440
|
+
const parkedEntry = steerEntries.find((e) => e.customType === "asf-park");
|
|
1441
|
+
// Only continue while there is something NEW to say; a repeat park is
|
|
1442
|
+
// recorded and the run settles.
|
|
1443
|
+
return (parkedEntry ? { entries: steerEntries, continue: true } : undefined) as never;
|
|
1227
1444
|
}
|
|
1228
1445
|
|
|
1229
1446
|
if (decision.block) {
|
|
1230
|
-
|
|
1231
|
-
|
|
1447
|
+
mem.count += 1;
|
|
1448
|
+
if (decision.fingerprint) mem.seen.add(decision.fingerprint);
|
|
1449
|
+
state.steers = Math.max(typeof decision.steer === "number" ? decision.steer : 0, mem.count);
|
|
1450
|
+
state.seenSteers = [...new Set([...(state.seenSteers || []), ...(decision.fingerprint ? [decision.fingerprint] : [])])].slice(-50);
|
|
1232
1451
|
state.updatedAt = new Date().toISOString();
|
|
1233
1452
|
await saveState(project, file);
|
|
1234
1453
|
// The continuation MUST carry content. pi commits `entries` and only
|
|
@@ -1256,9 +1475,25 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
1256
1475
|
file.current = null;
|
|
1257
1476
|
await saveState(project, file);
|
|
1258
1477
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1478
|
+
// A continuation for guidance alone (card/steers) is also bounded: an
|
|
1479
|
+
// agent that keeps producing new distinct steers must not be kept alive
|
|
1480
|
+
// by its own advice.
|
|
1481
|
+
const wantsContinue = steerEntries.length > priorCount && steerContinuationsAllowed(mem);
|
|
1482
|
+
return (wantsContinue ? { entries: steerEntries, continue: true } : undefined) as never;
|
|
1483
|
+
} catch (e) {
|
|
1484
|
+
// An internal error is NOT a pass. Silently returning undefined let the
|
|
1485
|
+
// run settle with its evidence gaps unexamined — the gate simply vanished.
|
|
1486
|
+
// The error is recorded, surfaced once (deduped like every other steer),
|
|
1487
|
+
// and the run is left to settle only after the agent has been told.
|
|
1488
|
+
const msg = `ASF: the completion gate hit an internal error and could not judge this run: ${(e && (e as Error).message) || String(e)}. Record it and continue; the gate did not pass, it failed to run.`;
|
|
1489
|
+
appendEvidence({ kind: "gate-error", error: String((e && (e as Error).message) || e).slice(0, 300) });
|
|
1490
|
+
// Keyed on the error CLASS, not the message: an I/O error's message carries
|
|
1491
|
+
// a random tmp filename, so keying on it would make every occurrence look
|
|
1492
|
+
// new and the gate would apologise forever.
|
|
1493
|
+
const key = `gate-error|${(e && (e as { code?: string }).code) || (e && (e as Error).name) || "unknown"}`;
|
|
1494
|
+
if (deliveredSteers.has(key)) return undefined;
|
|
1495
|
+
deliveredSteers.add(key);
|
|
1496
|
+
return { entries: [{ type: "custom_message", customType: "asf-gate-error", content: msg, display: true }], continue: true } as never;
|
|
1262
1497
|
}
|
|
1263
1498
|
});
|
|
1264
1499
|
|
|
@@ -1326,9 +1561,10 @@ export default function register(pi: ExtensionAPI): void {
|
|
|
1326
1561
|
machineTurnsSinceHuman++;
|
|
1327
1562
|
if (machineTurnsSinceHuman >= REANCHOR_AFTER_MACHINE_TURNS && Date.now() - lastReanchorAt >= REANCHOR_MIN_INTERVAL_MS) {
|
|
1328
1563
|
const file = await loadState(projectName());
|
|
1329
|
-
if (file.current && file.current.phase !== "none") {
|
|
1564
|
+
if (file.current && file.current.phase !== "none" && reanchorsThisRun < MAX_REANCHORS_PER_RUN) {
|
|
1330
1565
|
lastReanchorAt = Date.now();
|
|
1331
1566
|
machineTurnsSinceHuman = 0;
|
|
1567
|
+
reanchorsThisRun++;
|
|
1332
1568
|
pendingCard = { text: cardFor(file.current), reason: "reanchor" };
|
|
1333
1569
|
appendEvidence({ kind: "steer", gate: "reanchor", machineTurns: REANCHOR_AFTER_MACHINE_TURNS });
|
|
1334
1570
|
}
|
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",
|