pi-aia-asf 0.17.5 → 0.17.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,117 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.7] - 2026-11-09
11
+
12
+ ### Fixed — "waiting" is now illegal as a disposition
13
+
14
+ An agent working a claimed ticket ended its run with this on the reviewer's card:
15
+ *"Left In Progress at testing — deliberately not resolved, because the user-visible
16
+ outcome still isn't confirmed, and not parked, since you told me not to ask you for
17
+ anything."* Nothing blocked it. The run closed and the work stopped. That is a stop
18
+ wearing a status report, and it is the failure this whole process exists to prevent.
19
+
20
+ The agent did not misbehave; it had no legal state to stand in. The stop policy names
21
+ four things that may halt a run — plan approval, an irreversible action with no safe
22
+ path, an unobtainable credential, a deliverable-shaping either/or — and **"the other
23
+ side has not replied" is none of them**. A contract that forbids stopping without
24
+ naming what to do instead does not produce persistence, it produces paragraphs. And
25
+ the agent had correctly noticed that parking *looks like* asking, so a "don't ask me"
26
+ directive seemed to forbid the one move that was legal.
27
+
28
+ - **New gate: `lib/gates/tracked.mjs`.** Claims, resolutions and parks are read off the
29
+ tool *name* (`bmx_claim`, `submit_resolution`, `request_input`, …) — never off shell
30
+ text, because `git commit` is not a claim. A claimed item with no disposition is a
31
+ gap that keeps the run open, and the agent's own progress text is read for the
32
+ confession ("deliberately not resolved", "not parked", "waiting for their reply",
33
+ "it flips the moment…"), which raises it on the spot.
34
+ - **The three dispositions, all of which the agent can take itself**: park it where a
35
+ reviewer sees it (parking is a *marker*, not a question, so "don't ask me" never
36
+ forbids it); take the next action you control; or, if you are genuinely waiting on
37
+ someone else, make the wait end in one step — the test that will confirm the outcome,
38
+ the draft mail — and keep going. The gap text offers all three.
39
+ - The gap is worded so it cannot route itself into the report: it names no slash
40
+ command and never says "ask the PO", because a gap that reads like a human action is
41
+ filtered out by `splitGaps` and would make the guard decorative. Pinned by a test.
42
+ - **Contract text now lives where the agent reads it.** The no-stops policy existed only
43
+ in a source docstring; `06i-completeness.md` (always-read) now carries the rule and the
44
+ DoD has a line for it, with the case study in `06f-stability.md` §6. The always-read
45
+ load stayed inside its budget (75,953 / 76,000) — detail moved out to 06f rather than
46
+ the budget being raised.
47
+
48
+ ### Fixed — declaration files that lie about their module
49
+
50
+ `lib/gates/blockers.d.mts` still declared `RULE_TEXT` / `refusalStage` /
51
+ `__resetRefusals` after they moved to `refusals.mjs` (0.17.5); `lib/gates/settle.d.mts`
52
+ still declared `MAX_IDENTICAL_STEERS` after the cap was deleted; `lib/activation.d.mts`
53
+ still declared `SOURCE_EXTENSIONS` after it was removed. A `.ts` importing any of those
54
+ typechecks clean and gets `undefined` at runtime — the compiler is the tool that is
55
+ wrong. New suite **`test/test-declarations.mjs`** loads every `.mjs` beside each `.d.mts`
56
+ and fails on any declared export the module does not have. It found the third case on
57
+ its first run.
58
+
59
+ ### Fixed — the test runner could not fail
60
+
61
+ `test/harness.mjs` carried `const ok = globalThis.__tools = tools` where it meant to
62
+ capture the suite's verdict: `ok` was the tools array — always truthy — and the return
63
+ value of `run()` was discarded. A suite reporting failure by returning false printed its
64
+ FAILs and was still counted green, which is precisely the bug the comment above it
65
+ claimed was fixed. The verdict is now captured, and the new declarations suite depends
66
+ on it.
67
+
68
+ ### Also
69
+
70
+ - `06i-completeness.md` still described the old steering rule ("a gap is steered once,
71
+ never twice") after 0.16.0 removed the cap.
72
+
73
+ ## [0.17.6] - 2026-11-09
74
+
75
+ ### Fixed — a gate that crashes is not a gate that passed
76
+
77
+ Both tool handlers were fenced with `catch { return undefined }`, which had two consequences of
78
+ exactly the kind ASF bans:
79
+
80
+ - A failure inside the **suite recorder** lost the run silently, after which the publish and
81
+ completion floors demanded a run that could never register — an unclosable gap with no visible
82
+ cause.
83
+ - Because pi's own semantics for a crashing `tool_call` handler are *block*, returning "allow"
84
+ overrode them: anything that made the earlier code throw — a config read, the activation
85
+ observer — disabled the **irreversibility floor** for that call.
86
+
87
+ Now:
88
+
89
+ - `reportGateError(gate, e)` records a `gate-error` ledger entry and tells the agent once, as a
90
+ steer: *"That is not a pass — the check failed to execute."* Guidance, never a stop.
91
+ - When the gate dies **before** the blockers ran, `failSafeBlock(input)` re-runs just the rules
92
+ whose `failSafe` is `block`, from the raw tool input alone, with no context to fail on. A secret
93
+ in a tracked file is still refused when the gate itself is broken; ordinary work is never
94
+ stopped by an internal error.
95
+ - A fail-safe rule that *itself* throws refuses ("could not run") rather than reporting nothing
96
+ found.
97
+
98
+ Three assertions pin this, each mutation-tested: removing the report, the fail-closed call, or the
99
+ crash-refusal each fails a test.
100
+
101
+ - `lib/gates/blockers.d.mts` still declared `RULE_TEXT`, `refusalStage` and `__resetRefusals` after
102
+ they moved to `lib/gates/refusals.mjs` in 0.17.5 — a `.ts` could import them, typecheck, and get
103
+ `undefined` at runtime. The declarations now match the modules.
104
+
105
+ ### Removed
106
+
107
+ - Dead export `SOURCE_EXTENSIONS` from `lib/activation.mjs` (no importer).
108
+
109
+ ### Documented
110
+
111
+ - **The loaded copy is not the shipped copy** (07-release.md step 7 + hygiene rule): `pi` loads
112
+ extensions once at process start, so a session open across a release keeps enforcing the previous
113
+ version and quoting text that no longer exists in the source. Confirm which build is loaded — a
114
+ string unique to the new release, and the process start time — before concluding a released gate
115
+ is broken. That is the difference between "the fix did not work" and "the fix is not running", and
116
+ it cost hours chasing a phantom bug in 0.17.5.
117
+ - `06i-completeness.md` pointed its drift example at `lib/gates/ledger.mjs` / `deriveSuiteRun()`;
118
+ those moved to `lib/gates/suite-runs.mjs` in 0.17.5, and the doc — which is about not trusting
119
+ stale references — was itself stale.
120
+
10
121
  ## [0.17.5] - 2026-11-08
11
122
 
12
123
  ### Fixed — the publish floor could be satisfied by a green that proved nothing
@@ -12,5 +12,4 @@ export function activationDecision(o?: {
12
12
  activeRun?: { phase?: string; runId?: string } | null;
13
13
  passiveRunId?: string | null;
14
14
  }): { activate: boolean; reason?: string };
15
- export const SOURCE_EXTENSIONS: string[];
16
15
  export function delegatesUnguardedWrites(cmd: unknown): boolean;
@@ -177,5 +177,3 @@ export function activationDecision({ call, activeRun, passiveRunId = null } = {}
177
177
  return { activate: true };
178
178
  }
179
179
 
180
- /** Exported for the tests and for `/asf` diagnostics. */
181
- export const SOURCE_EXTENSIONS = SOURCE_EXT;
@@ -27,6 +27,30 @@ export function queueSteer(text: string) {
27
27
  pendingSteers.push(text);
28
28
  }
29
29
 
30
+ /**
31
+ * An internal gate error must never be able to look like a pass.
32
+ *
33
+ * Every ASF handler is fenced, because an observer must not break the work it
34
+ * watches. Fencing *without* reporting is exactly how the 2026-11-07 loop became
35
+ * possible: a gate demanded something, the code that would have shown why
36
+ * swallowed its own error, and the agent was steered forever toward an action
37
+ * that could not register. So a swallowed error is (a) recorded as evidence and
38
+ * (b) told to the agent once, as a steer — guidance, never a stop.
39
+ *
40
+ * The settle gate has a stronger channel (it returns the entry inline, so the
41
+ * agent sees it in the same turn); the tool handlers do not, so they use this.
42
+ */
43
+ export function reportGateError(gate: string, e: unknown) {
44
+ const message = String((e as Error | null)?.message || e || "unknown");
45
+ appendEvidence({ kind: "gate-error", gate, error: message.slice(0, 300) });
46
+ const cls = (e as { code?: string } | null)?.code || (e as Error | null)?.name || "unknown";
47
+ queueSteer(
48
+ `ASF: the ${gate} hit an internal error (${cls}: ${message.slice(0, 140)}) and did not run. ` +
49
+ `That is not a pass — the check failed to execute. Re-issue the action; if it repeats, ` +
50
+ `this is an ASF bug to report, not a rule to satisfy.`,
51
+ );
52
+ }
53
+
30
54
 
31
55
  export function appendEvidence(entry: Record<string, unknown>) {
32
56
  const res = ledger.append(entry);
@@ -163,6 +163,7 @@ async function judgeAndSettle(ctx: SettleContext, priorCount: number): Promise<S
163
163
  seenSteers: [...new Set([...(state.seenSteers || []), ...mem.seen])],
164
164
  sourceMutated: !!state.sourceMutated,
165
165
  testRunRecorded,
166
+ tracked: runtime.tracked,
166
167
  });
167
168
  runtime.lastSettleDecision = decision;
168
169
  appendEvidence({ kind: "gate-verdict", gate: "completion", fired: decision.missing.length > 0, missing: decision.missing });
@@ -15,13 +15,14 @@
15
15
 
16
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
17
  import { classifyToolCall, activationDecision } from "../../activation.mjs";
18
- import { evaluateBlockers, BLOCKERS } from "../../gates/blockers.mjs";
18
+ import { evaluateBlockers, BLOCKERS, failSafeBlock } from "../../gates/blockers.mjs";
19
19
  import { deriveSuiteRun } from "../../gates/suite-runs.mjs";
20
+ import { noteTrackedCall } from "../../gates/tracked.mjs";
20
21
  import { readProjectConfig } from "../../gates/config.mjs";
21
22
  import { projectName, runRoot } from "../paths.ts";
22
23
  import { loadState, saveState } from "../state.ts";
23
24
  import { openAutoRun, gitDirty, numberConfig } from "../run.ts";
24
- import { appendEvidence, queueSteer } from "../evidence.ts";
25
+ import { appendEvidence, queueSteer, reportGateError } from "../evidence.ts";
25
26
  import { gateStats, pendingSuiteRuns, suiteRuns, recordVerdict } from "../gate-state.ts";
26
27
  import { authority } from "../gate-state.ts";
27
28
  import { runtime, BLIND_EDIT_NUDGE_AFTER } from "../runtime.ts";
@@ -102,6 +103,28 @@ function observeWebTouch(input: Input): void {
102
103
  if (touched && /\.(tsx|jsx|html|vue|svelte|css)$/i.test(touched)) runtime.webTouched = true;
103
104
  }
104
105
 
106
+ /**
107
+ * What this call did to tracked work (a claimed ticket, an assigned issue).
108
+ *
109
+ * The gate needs to know that the session *owns* something, because the failure
110
+ * it prevents is invisible in the source tree: an agent that ends its run with a
111
+ * claimed item neither resolved nor parked has stopped, and written a paragraph
112
+ * about it instead. Claim / resolve / park are read off the tool's name; a
113
+ * narrated wait is read off the text the agent wrote for the reviewer.
114
+ */
115
+ function observeTrackedWork(toolName: string, input: Input): void {
116
+ const kind = noteTrackedCall(runtime.tracked, { toolName, input });
117
+ if (!kind) return;
118
+ appendEvidence({ kind: "tracked", action: kind, tool: toolName, item: String(input.ticket_id || input.ticketId || input.id || "") || null });
119
+ if (kind === "wait") {
120
+ queueSteer(
121
+ `ASF: you described tracked work as waiting — that is not a disposition ASF recognises, and the run cannot close on it. ` +
122
+ `Park it where the reviewer sees it (parking is a marker, not a question, so "do not ask me" never forbids it), take the next ` +
123
+ `action you control, or do the work that makes the wait end in one step and keep going.`,
124
+ );
125
+ }
126
+ }
127
+
105
128
  /** Gate switches the project turned off, as evaluateBlockers wants them. */
106
129
  function blockerSwitches(config: Record<string, unknown>): Record<string, boolean> {
107
130
  const enabled: Record<string, boolean> = {};
@@ -209,6 +232,7 @@ export function registerToolCallHandler(pi: ExtensionAPI): void {
209
232
  }
210
233
  observeSuiteAttempt(input, ev);
211
234
  observeWebTouch(input);
235
+ observeTrackedWork(toolName, input);
212
236
 
213
237
  const config = readProjectConfig(runRoot());
214
238
  const res = evaluateBlockers(toolName, input, {
@@ -230,10 +254,14 @@ export function registerToolCallHandler(pi: ExtensionAPI): void {
230
254
  if (park) await parkRun(park);
231
255
  if (!res.block) return undefined;
232
256
  return await handleBlock({ pi, res: res as Refusal["res"], park, config });
233
- } catch {
234
- // An observer must never break the thing it observes. The fail-safe for
235
- // the secret rule lives inside evaluateBlockers, where it is explicit.
236
- return undefined;
257
+ } catch (e) {
258
+ // An observer must never break the thing it observes — so this does not
259
+ // stop the work. But it must not look like a clean pass either, and it must
260
+ // not hand anyone a way past the irreversibility floor: an error raised
261
+ // before the blockers ran is re-checked against the rules that guard
262
+ // irreversible actions, from the raw tool input alone.
263
+ reportGateError("tool_call gate", e);
264
+ return failSafeBlock((event as { input?: Input } | null)?.input || {});
237
265
  }
238
266
  });
239
267
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { deriveSuiteRun } from "../../gates/suite-runs.mjs";
12
- import { appendEvidence } from "../evidence.ts";
12
+ import { appendEvidence, reportGateError } from "../evidence.ts";
13
13
  import { pendingSuiteRuns, suiteRuns } from "../gate-state.ts";
14
14
  import { runtime } from "../runtime.ts";
15
15
 
@@ -52,7 +52,11 @@ export function registerToolResultHandler(pi: ExtensionAPI): void {
52
52
  pendingSuiteRuns.delete(id);
53
53
  completeSuiteRun(ev, pending);
54
54
  return undefined;
55
- } catch {
55
+ } catch (e) {
56
+ // Swallowing this one silently is how the publish floor becomes an
57
+ // unclosable gap: the run happened, the recorder failed, and the gate then
58
+ // demands a run that can never register.
59
+ reportGateError("suite recorder", e);
56
60
  return undefined;
57
61
  }
58
62
  });
@@ -7,6 +7,8 @@
7
7
  * 2026-11-07 loop possible in the first place (state nobody owned).
8
8
  */
9
9
 
10
+ import { createTracked, type TrackedState } from "../gates/tracked.mjs";
11
+
10
12
  /** P5: machine turns before a re-anchor is due. */
11
13
  export const REANCHOR_AFTER_MACHINE_TURNS = 25;
12
14
  /** P5: and never more often than this, however many turns pass. */
@@ -40,6 +42,12 @@ export interface RuntimeState {
40
42
  lastScaleWasSmall: boolean;
41
43
  /** Re-anchoring is count-limited per run. */
42
44
  reanchorsThisRun: number;
45
+ /**
46
+ * Tracked work opened this session (a claimed ticket, an assigned issue) and
47
+ * whether it has a disposition. Session-scoped on purpose: a claim made before
48
+ * a restart is re-opened by the agent's own progress text if it is still dangling.
49
+ */
50
+ tracked: TrackedState;
43
51
  }
44
52
 
45
53
  export const runtime: RuntimeState = {
@@ -56,4 +64,5 @@ export const runtime: RuntimeState = {
56
64
  passiveHeartbeatWritten: false,
57
65
  lastScaleWasSmall: false,
58
66
  reanchorsThisRun: 0,
67
+ tracked: createTracked(),
59
68
  };
@@ -34,9 +34,8 @@ export function evaluateBlockers(
34
34
  verdicts: BlockerVerdict[];
35
35
  steers?: Array<{ gate: string; reason: string }>;
36
36
  };
37
- export const RULE_TEXT: Record<string, string>;
38
- export function refusalStage(gate: string, fingerprint: string): number;
39
- export function __resetRefusals(): void;
37
+ /** Re-run the fail-safe-block rules from the raw input alone, when the gate itself could not run. */
38
+ export function failSafeBlock(input: Record<string, unknown>): { block: true; reason: string } | undefined;
40
39
  export function blockSecretWrite(input: Record<string, unknown>, ctx?: Record<string, unknown>): BlockerVerdict;
41
40
  export function blockTestSubset(input: Record<string, unknown>, ctx?: Record<string, unknown>): BlockerVerdict;
42
41
  export function blockPushWithoutGreen(input: Record<string, unknown>, ctx?: Record<string, unknown>): BlockerVerdict;
@@ -160,6 +160,30 @@ export const BLOCKERS = [
160
160
  { name: "destructive-git-dirty-tree", run: blockDestructiveGitDirty, failSafe: "allow", switch: "gate.block.destructiveGit", criticality: "medium", bypassable: true, mode: "block" },
161
161
  ];
162
162
 
163
+ /**
164
+ * The floor that must not fail open.
165
+ *
166
+ * A fenced handler that returns "allow" on its own internal error hands anyone
167
+ * who can make the *other* code throw a way past the irreversibility floor — the
168
+ * secret rule would be disabled by a bug in a config read. So when the gate
169
+ * could not run, the rules whose `failSafe` is `block` are re-run from the raw
170
+ * tool input alone, with no context to fail on; and if even one of those throws,
171
+ * that is a refusal, not a pass.
172
+ */
173
+ export function failSafeBlock(input) {
174
+ for (const b of BLOCKERS) {
175
+ if (b.failSafe !== "block") continue;
176
+ let res;
177
+ try {
178
+ res = b.run(input, {});
179
+ } catch {
180
+ res = { fired: true, reason: `${b.name} could not run, and it guards an irreversible action.` };
181
+ }
182
+ if (res?.fired) return { block: true, reason: `${b.name}: ${res.reason || "refused"}` };
183
+ }
184
+ return undefined;
185
+ }
186
+
163
187
  function str(input, keys) {
164
188
  if (!input || typeof input !== "object") return null;
165
189
  for (const k of keys) {
@@ -25,10 +25,11 @@ export function decideSettlement(
25
25
  testRunRecorded?: boolean;
26
26
  reportFields?: string[];
27
27
  untracedMet?: string[];
28
+ /** Tracked work opened this session; dangling items are a gap. */
29
+ tracked?: import("./tracked.mjs").TrackedState;
28
30
  },
29
31
  ): SettleDecision;
30
32
  export function renderCompletionGate(decision: { missing: string[]; exhausted?: boolean; park?: ParkRequest }): string;
31
33
  export function classifyPark(missing?: string[]): ParkRequest | null;
32
34
  export function steerFingerprint(missing?: string[]): string;
33
- export const MAX_IDENTICAL_STEERS: number;
34
35
  export const MAX_STEERS: number;
@@ -11,6 +11,14 @@
11
11
  * credential ASF cannot get, or a deliverable-shaping either/or that cannot be
12
12
  * inferred from the artifacts. Everything else is a steer.
13
13
  *
14
+ * **And a fifth thing that is NOT a halt (PO, 2026-11-09): someone else has not
15
+ * replied.** After the guard shipped, an agent left a claimed ticket In Progress,
16
+ * neither resolved nor parked, and explained it was waiting and had not parked
17
+ * because it had been told not to ask anything. Four named halts with no rule for
18
+ * "the other side is slow" does not produce persistence — it produces a run that
19
+ * closes on a paragraph. Tracked work left dangling is therefore a gap like any
20
+ * other, steered until it has one of the three dispositions in `tracked.mjs`.
21
+ *
14
22
  * **What replaced the cap.** The old rule was `MAX_CONTINUATIONS = 2`, then the
15
23
  * gate declined to ask again. A fixed cap is the wrong instrument under "no
16
24
  * stops": two is far too few for an honest implement → test → fix → verify loop,
@@ -27,6 +35,7 @@
27
35
  */
28
36
 
29
37
  import { sessionFacts } from "./ledger.mjs";
38
+ import { danglingGaps } from "./tracked.mjs";
30
39
 
31
40
  /**
32
41
  * A REPORTING number only — never a stop, and no longer referenced by any
@@ -187,6 +196,9 @@ function collectGaps(ledger, opts) {
187
196
  ...validationGap(ledger, opts),
188
197
  ...traceGap(opts.untracedMet),
189
198
  ...reportGaps(ledger, opts),
199
+ // "Waiting" is not a disposition: tracked work left neither resolved nor
200
+ // parked is a stop wearing a status report, and it is steered like one.
201
+ ...danglingGaps(opts.tracked),
190
202
  ];
191
203
  }
192
204
 
@@ -0,0 +1,28 @@
1
+ /** Type surface for the tracked-work gate (`tracked.mjs`). */
2
+
3
+ export interface TrackedItem {
4
+ at: number;
5
+ by: string;
6
+ }
7
+
8
+ export interface TrackedState {
9
+ /** Open items by id. A key here is work with no disposition yet. */
10
+ open: Record<string, TrackedItem>;
11
+ /** Ids whose agent has narrated a wait instead of acting. */
12
+ waits: string[];
13
+ /** Every id ever opened, for the evidence trail. */
14
+ seen: string[];
15
+ }
16
+
17
+ export declare const DISPOSITIONS: string[];
18
+
19
+ export function createTracked(): TrackedState;
20
+
21
+ export function noteTrackedCall(
22
+ tracked: TrackedState,
23
+ call: { toolName?: string; input?: Record<string, unknown> },
24
+ ): "claim" | "resolve" | "park" | "wait" | null;
25
+
26
+ export function danglingGaps(tracked: TrackedState | undefined): string[];
27
+
28
+ export function trackedClear(tracked: TrackedState | undefined): boolean;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * "Waiting" is not a disposition.
3
+ *
4
+ * On 2026-11-09 an agent working a claimed ticket ended its run with this on the
5
+ * card: *"Left In Progress at testing — deliberately not resolved, because the
6
+ * user-visible outcome still isn't confirmed, and not parked, since you told me
7
+ * not to ask you for anything."* Nothing was blocked, nothing was parked, the
8
+ * run closed, and the work stopped. That is a stop wearing a status report.
9
+ *
10
+ * It was ASF's fault, not the agent's. The contract names four things that may
11
+ * halt a run — plan approval, an irreversible action with no safe path, an
12
+ * unobtainable credential, a deliverable-shaping either/or. "The other side has
13
+ * not replied" is none of them, so the agent had no legal state to stand in and
14
+ * improvised an illegal one. A contract that forbids stopping without naming
15
+ * what to do instead does not produce persistence; it produces narratives.
16
+ *
17
+ * So the illegal state is named, and the legal answers are exactly three. They
18
+ * are all actions, and the agent can take every one of them — which is what
19
+ * makes this gap steerable rather than the 2026-11-07 kind that loops forever.
20
+ */
21
+
22
+ /** The only three things an agent may do with work it cannot finish right now. */
23
+ export const DISPOSITIONS = [
24
+ "park it where a reviewer sees it (the needs-input queue) — parking is a marker, not a question, so a 'do not ask me' instruction never forbids it",
25
+ "take the next action you control, now, in this run",
26
+ "if you are genuinely waiting on someone else, do the work that makes the wait end in one step — the test that will confirm the outcome, the draft, the verification you can run today — and then keep going",
27
+ ];
28
+
29
+ /**
30
+ * Tool names that open a tracked item. Matched on the tool name, never on shell
31
+ * text: `bmx_claim`, `linear_claim`, `ticket_assign`. A bash command is not a
32
+ * claim, because `git commit` is not one either.
33
+ */
34
+ const CLAIM_RE = /(^|[_-])(claim|assign|take|acquire)([_-]|$)/i;
35
+ /** Tool names that close one. `submit_resolution`, `resolve_ticket`, `finish`… */
36
+ const RESOLVE_RE = /(^|[_-])(resolve|resolution|finish|complete|close|deliver)([_-]|$)/i;
37
+ /** Tool names that park one where a human sees it. */
38
+ const PARK_RE = /(^|[_-])(park|escalate|needs?[_-]?input|request[_-]?input|request[_-]?review|block[_-]?review|hand[_-]?off)([_-]|$)/i;
39
+ /** Tool names whose text the agent writes for a human to read. */
40
+ const NARRATE_RE = /(^|[_-])(progress|status|update|comment|note|report)([_-]|$)/i;
41
+
42
+ /**
43
+ * The confession. An agent narrating a wait has usually said it out loud, in the
44
+ * very message meant for the reviewer. Deliberately narrow: it must describe
45
+ * leaving work unfinished, not merely mention a test or a queue.
46
+ */
47
+ const WAITING_ALTERNATIVES = [
48
+ "(left|stays?|kept|remain(?:s|ing)?)\\s+(?:it\\s+)?(in\\s+progress|in\\s+the\\s+queue|unresolved|not\\s+resolved|open\\s+deliberately)",
49
+ "deliberately\\s+not\\s+(resolved|closed|parked|finished)",
50
+ "not\\s+parked",
51
+ "waiting\\s+(for|on)\\s+(the|their|your|a\\s+reply|them)",
52
+ "as\\s+soon\\s+as\\s+(they|he|she|it)\\s+repl(?:y|ies|ied)",
53
+ "it\\s+flips\\s+(the\\s+moment|when|once)",
54
+ "pend(?:ing|s)\\s+(their|the|his|her)\\s+repl(?:y|ies)",
55
+ "until\\s+(they|he|she|it)\\s+(repl(?:y|ies|ied)|answers?|responds?)",
56
+ "resume[sd]?\\s+(when|once|the\\s+moment)",
57
+ ];
58
+ const WAITING_RE = new RegExp(WAITING_ALTERNATIVES.join("|"), "i");
59
+
60
+ /** Identify the item a call is about, so one ticket is one entry. */
61
+ function itemId(input) {
62
+ if (!input || typeof input !== "object") return null;
63
+ for (const k of ["ticket_id", "ticketId", "id", "issue", "issue_id", "work_item_id"]) {
64
+ const v = input[k];
65
+ if (typeof v === "string" && v.trim()) return v.trim().slice(0, 60);
66
+ if (typeof v === "number") return String(v);
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function textOf(input) {
72
+ if (!input || typeof input !== "object") return "";
73
+ const parts = [];
74
+ for (const k of ["text", "body", "comment", "message", "summary", "reason", "note", "status"]) {
75
+ const v = input[k];
76
+ if (typeof v === "string") parts.push(v);
77
+ }
78
+ return parts.join("\n").slice(0, 4000);
79
+ }
80
+
81
+ /** Fresh tracked-work state. `open` maps an item id to what opened it. */
82
+ export function createTracked() {
83
+ return { open: {}, waits: [], seen: [] };
84
+ }
85
+
86
+ function openItem(tracked, id, toolName) {
87
+ if (tracked.open[id]) return;
88
+ tracked.open[id] = { at: Date.now(), by: toolName };
89
+ tracked.seen.push(id);
90
+ }
91
+
92
+ function closeItem(tracked, id) {
93
+ delete tracked.open[id];
94
+ }
95
+
96
+ /**
97
+ * Record what a tool call did to tracked work.
98
+ *
99
+ * Returns a short label for the evidence trail (`claim` / `resolve` / `park` /
100
+ * `wait` / null). Never throws on odd input: this is an observer on the hot path.
101
+ */
102
+ export function noteTrackedCall(tracked, { toolName = "", input = {} } = {}) {
103
+ if (!tracked || !toolName) return null;
104
+ const id = itemId(input) || `tool:${toolName}`;
105
+ if (PARK_RE.test(toolName)) {
106
+ closeItem(tracked, id);
107
+ return "park";
108
+ }
109
+ if (RESOLVE_RE.test(toolName)) {
110
+ closeItem(tracked, id);
111
+ return "resolve";
112
+ }
113
+ if (CLAIM_RE.test(toolName)) {
114
+ openItem(tracked, id, toolName);
115
+ return "claim";
116
+ }
117
+ if (NARRATE_RE.test(toolName) && WAITING_RE.test(textOf(input))) {
118
+ // Narrating a wait does not make it legal; it makes it visible to the gate.
119
+ openItem(tracked, id, toolName);
120
+ if (!tracked.waits.includes(id)) tracked.waits.push(id);
121
+ return "wait";
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Gaps for work left dangling at settle time.
128
+ *
129
+ * Wording matters: these gaps must survive `splitGaps`, so they never say "ask
130
+ * the PO", "needs your input" or a slash command — a gap that reads like a human
131
+ * action is routed to the report and stops blocking, which would make this guard
132
+ * decorative. It names only moves the agent can make itself.
133
+ */
134
+ export function danglingGaps(tracked) {
135
+ const ids = Object.keys(tracked && tracked.open ? tracked.open : {});
136
+ if (!ids.length) return [];
137
+ const how = DISPOSITIONS.map((d, i) => `${i + 1}) ${d}`).join(" ");
138
+ return ids.map((id) => {
139
+ const waited = tracked.waits.includes(id);
140
+ return (
141
+ `tracked work "${id}" is still open and this run is ending with it neither resolved nor parked` +
142
+ (waited ? " — and the agent has just narrated waiting for it, which is not a state ASF recognises" : "") +
143
+ `. Nothing here stops you. Do one of: ${how}. A run does not close while tracked work dangles.`
144
+ );
145
+ });
146
+ }
147
+
148
+ /** True when nothing is dangling (used by tests and by the run card). */
149
+ export function trackedClear(tracked) {
150
+ return Object.keys(tracked && tracked.open ? tracked.open : {}).length === 0;
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.17.5",
3
+ "version": "0.17.7",
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",
@@ -86,6 +86,60 @@ correlation IDs, and a runbook exist."*
86
86
  unbounded wait, retry, or result set.
87
87
  - **Phase 7 (verification):** DoD includes the stability line above.
88
88
 
89
+ ## 5. Your own artifacts: the drift check
90
+
91
+ `checkDrift` in `lib/gates/drift.mjs` reads this task's own artifacts (PLAN.md, the
92
+ spec tree, a design doc) and checks the files and symbols they name still exist.
93
+ Invoke it before claiming done:
94
+
95
+ ```bash
96
+ node --input-type=module -e "
97
+ import {checkDrift, renderDrift} from './lib/gates/drift.mjs';
98
+ import fs from 'node:fs';
99
+ const arts = ['PLAN.md'].filter(f => fs.existsSync(f)).map(path => ({path, text: fs.readFileSync(path,'utf8')}));
100
+ console.log(renderDrift(checkDrift({artifacts: arts, repoRoot: process.cwd(), fs})));
101
+ "
102
+ ```
103
+
104
+ Three things it deliberately does **not** do:
105
+
106
+ - It does not scan the whole repo's documentation. Repo-wide docs drift presumes the
107
+ documentation exists and is maintained, and products legitimately evolve — a check
108
+ that produces permanent noise gets switched off, which is worse than never having it.
109
+ - It does not treat "0 unresolved" as a pass on its own. `status: "no-claims"` means
110
+ the artifact named no files or symbols at all, and that is reported as
111
+ examined-nothing, never as clean (06b Rule 10: `scanned: 0` is never a pass).
112
+ - It does not replace the traceability matrix. Drift says the artifact still describes
113
+ the code; the matrix says the spec was actually delivered.
114
+
115
+ ## 6. The stop that looked like a status report (2026-11-09)
116
+
117
+ An agent working a claimed ticket ended its run with this on the reviewer's card:
118
+
119
+ > *"Left In Progress at testing — deliberately not resolved, because the user-visible
120
+ > outcome still isn't confirmed, and not parked, since you told me not to ask you for
121
+ > anything. It flips the moment their thread replies: Resume succeeds and I resolve it
122
+ > with mail to ClearStory; fails and I re-diagnose from the factory's own sentence."*
123
+
124
+ Nothing blocked it. No gate fired. The run closed and the work stopped. The reasoning
125
+ was internally consistent and completely wrong: the agent had been told not to ask
126
+ questions, had correctly worked out that parking *looks like* asking, and so had
127
+ chosen a fifth state that the contract did not name — "in progress, waiting" — and
128
+ explained it. Every clause of that sentence is a stop.
129
+
130
+ Why it was ASF's bug, not the agent's: the stop policy names four things that may halt
131
+ a run, and "the other side has not replied" is none of them. A contract that forbids
132
+ stopping without naming what to do instead does not produce persistence, it produces
133
+ paragraphs. So the state is now named and illegal, the three legal dispositions are
134
+ listed (06i, "Waiting is not a disposition"), and the gate enforces it: `lib/gates/tracked.mjs`
135
+ records claims, resolutions and parks off the tool names, reads the agent's own progress
136
+ text for the confession, and a claimed item with no disposition is a gap that keeps the
137
+ run open — steered, never stopped, and closable by the agent alone.
138
+
139
+ Two things this is **not**: it is not a ban on saying "blocked" honestly (say it, in the
140
+ report, with the disposition you took); and it is not a reason to resolve work that is
141
+ not done — a false `resolved` is the worse failure. Park it.
142
+
89
143
  ## Anti-patterns
90
144
 
91
145
  - ❌ Unbounded retries on non-idempotent writes
@@ -4,10 +4,9 @@
4
4
  every deliverable with real behaviour. **Skip when:** never. Small work needs only
5
5
  the Core rule and the DoD checklist at the bottom.
6
6
 
7
- Newer models can produce work that *looks* finished: a function with the right name
8
- whose body is a placeholder, a screen populated with invented records, a report that
9
- cites a limit nobody checked. Green tests do not disprove it — the tests were written
10
- against the stub. This reference is the standing answer.
7
+ Work can *look* finished: the right function name over a placeholder body, a screen of
8
+ invented records, a report citing a limit nobody checked. Green tests do not disprove
9
+ it — they were written against the stub.
11
10
 
12
11
  ## Core rule
13
12
 
@@ -60,31 +59,16 @@ goes in the final message in plain words — never discovered by the user throug
60
59
  ## Your own artifacts are part of the deliverable (task-scoped drift)
61
60
 
62
61
  The plan and the spec tree are artifacts too, and they make checkable claims: a PLAN.md
63
- that names `lib/gates/ledger.mjs` or `deriveSuiteRun()` is asserting those exist. When the
64
- code has moved and the artifact has not, the delivery report describes something that is
65
- not there — which is the same defect as a stub, just further from the user's eye.
66
-
67
- Before claiming done, run the drift check against **this task's own artifacts**:
68
-
69
- ```bash
70
- node --input-type=module -e "
71
- import {checkDrift, renderDrift} from './lib/gates/drift.mjs';
72
- import fs from 'node:fs';
73
- const arts = ['PLAN.md'].filter(f => fs.existsSync(f)).map(path => ({path, text: fs.readFileSync(path,'utf8')}));
74
- console.log(renderDrift(checkDrift({artifacts: arts, repoRoot: process.cwd(), fs})));
75
- "
76
- ```
77
-
78
- Three things this deliberately does **not** do:
79
-
80
- - It does not scan the whole repo's documentation. Repo-wide docs drift presumes the
81
- documentation exists and is maintained, and products legitimately evolve — a check that
82
- produces permanent noise gets switched off, which is worse than never having it.
83
- - It does not treat "0 unresolved" as a pass on its own. `status: "no-claims"` means the
84
- artifact named no files or symbols at all, and that is reported as examined-nothing,
85
- never as clean (06b Rule 10: `scanned: 0` is never a pass).
86
- - It does not replace the traceability matrix. Drift says the artifact still describes
87
- the code; the matrix says the spec was actually delivered.
62
+ naming `lib/gates/suite-runs.mjs` asserts that file exists. When the code moved and the
63
+ artifact did not, the delivery report describes something that is not there — the same
64
+ defect as a stub, further from the user's eye.
65
+
66
+ Before claiming done, run `checkDrift` from `lib/gates/drift.mjs` against **this
67
+ task's own artifacts** (PLAN.md, spec tree, design doc) and put its status in the
68
+ delivery report; the invocation is in 06f, section 5.
69
+
70
+ What the drift check deliberately does **not** do (and why a check that produces
71
+ permanent noise gets switched off) is in 06f, section 5.
88
72
 
89
73
  ## Active process (enforced by the harness)
90
74
 
@@ -93,14 +77,36 @@ Three things this deliberately does **not** do:
93
77
  scratch do not.
94
78
  - **Floor:** a passing test run that started after the last change (a lint is not
95
79
  a test); a spec met this run needs `trace.outcome` + `trace.codePath`.
96
- - A gap is steered once, never twice; unclosable gaps are reported **unverified**.
97
- Park only for: plan approval, irreversible step, credential, deliverable choice.
80
+ - A **warranted** gap (one you can close) is re-raised until closed — no ceiling on
81
+ real work. An **unwarranted** one (only a human, or a pi slash command, can close
82
+ it) is reported **unverified** and never blocks: that was the 2026-11-07 loop.
98
83
  - Always refused, even under `/asf passive`: secret in a tracked file, push without
99
84
  full-suite green, destructive git on a dirty tree.
100
85
  - `/asf passive` = this run only. `/asf abort` ends a run; deleting state does not.
101
86
 
87
+ ## Waiting is not a disposition
88
+
89
+ Four things may halt a run: plan approval, an irreversible step, a credential, a
90
+ deliverable choice. **"The other side has not replied" is not one of them.** Never
91
+ leave tracked work — a claimed ticket, an assigned issue — in an intermediate state
92
+ with an explanation of why it is stuck: that is a stop wearing a status report, and
93
+ the run may not close on it (06f carries the case that proved it).
94
+
95
+ Three dispositions, all yours to take:
96
+
97
+ 1. **Park it where a reviewer sees it** (needs-input). Parking is a *marker*, not a
98
+ question — "don't ask me anything" never forbids it.
99
+ 2. **Take the next action you control, now.** There is always one.
100
+ 3. **Waiting on someone else? Make the wait end in one step** — the test that will
101
+ confirm the outcome, the draft mail, the check you can run today — then keep going.
102
+
103
+ A claimed item with no disposition is a gap like any other; narrating a wait in a
104
+ progress message raises it on the spot.
105
+
102
106
  ## Definition of Done — completeness
103
107
 
108
+ - [ ] No claimed/tracked item left without a disposition: resolved, parked, or acted on.
109
+
104
110
  - [ ] No stub, unimplemented path, placeholder or fabricated data remains on a
105
111
  user-reachable path — or it is declared to the user with the reason.
106
112
  - [ ] Every part of the ask is reachable from where the user enters it; the delivery
@@ -63,6 +63,13 @@ in the absence of a user definition.
63
63
  - **Deployed app:** smoke-test in **production** (health endpoint, key user journey) — a deploy that breaks prod is not a successful release.
64
64
  - **Not applicable** (internal-only experiment, no runtime surface): say so explicitly in the report (06b Rule 11).
65
65
  - If the published artifact is broken: **fix forward** (new patch), never pretend the release succeeded.
66
+ - **The copy you are testing may not be the copy you shipped.** `pi` loads extensions once at
67
+ process start: a session that was already open keeps enforcing the *previous* version's
68
+ gates no matter what you publish, and its messages quote text that no longer exists in the
69
+ source. Before concluding a released gate is broken, confirm which build is loaded — compare
70
+ a string unique to the new release against the behaviour you saw, and check the process
71
+ start time (`ps -o lstart -p <pid>`). `/reload`, or a new session, is what picks up the new
72
+ code. This is the difference between "the fix did not work" and "the fix is not running".
66
73
 
67
74
  ## CI/CD offer
68
75
 
@@ -79,3 +86,4 @@ After a successful release (or for a new project), **offer** to set up a CI/CD p
79
86
  - The version in package.json, the git tag, and the npm release must match
80
87
  - If publishing fails partway, fix forward; never publish a second version with the same number
81
88
  - **Test on publish / in production, if applicable — always** (step 7)
89
+ - **Never diagnose a gate from a session that predates the release** — confirm the loaded build first (step 7)