pi-aia-asf 0.17.6 → 0.17.8

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,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.8] - 2026-11-09
11
+
12
+ ### Added — the tracked-work vocabulary is the project's to widen
13
+
14
+ `gate.tracked.claim` / `.resolve` / `.park` / `.wait` in `.asf/config.json` add patterns
15
+ (string or array) to the built-in matcher set. They **widen, never replace**: a project
16
+ that typos `claim` would otherwise stop tracking its own claims, and a floor that a
17
+ stray character in a config file can switch off is not a floor. Non-string entries are
18
+ ignored rather than compiled in.
19
+
20
+ `lib/gates/tracked.mjs` now says plainly what it is — **a tripwire over tool names and
21
+ the agent's own prose, not an authority over ticket state**. It cannot see a tracker's
22
+ database and its vocabulary is a small illustrative set; a tool named `grabWorkItem` is
23
+ invisible until the project says so. The asymmetry is deliberate: a miss costs a run
24
+ that settles early, a false alarm costs one steer.
25
+
26
+ ### Fixed — a matcher that matched everything
27
+
28
+ `matchersFromConfig()` built the wait matcher as `alternatives + "|" + extras` even when
29
+ `extras` was empty, leaving a trailing `|`. An empty alternative matches the empty
30
+ string, so **every progress message in every project would have been read as a
31
+ confessed wait** — a gate that trips on everything is as useless as one that trips on
32
+ nothing, and much harder to notice. Every union is now guarded by a length check, and a
33
+ test pins that no matcher matches `""`, `"anything at all"`, or a config entry of
34
+ `""`/`undefined`/`null`. Caught by the suite that checks a benign progress message is
35
+ not a wait, and mutation-verified: restoring the dangling `|` fails two checks.
36
+
37
+ ## [0.17.7] - 2026-11-09
38
+
39
+ ### Fixed — "waiting" is now illegal as a disposition
40
+
41
+ An agent working a claimed ticket ended its run with this on the reviewer's card:
42
+ *"Left In Progress at testing — deliberately not resolved, because the user-visible
43
+ outcome still isn't confirmed, and not parked, since you told me not to ask you for
44
+ anything."* Nothing blocked it. The run closed and the work stopped. That is a stop
45
+ wearing a status report, and it is the failure this whole process exists to prevent.
46
+
47
+ The agent did not misbehave; it had no legal state to stand in. The stop policy names
48
+ four things that may halt a run — plan approval, an irreversible action with no safe
49
+ path, an unobtainable credential, a deliverable-shaping either/or — and **"the other
50
+ side has not replied" is none of them**. A contract that forbids stopping without
51
+ naming what to do instead does not produce persistence, it produces paragraphs. And
52
+ the agent had correctly noticed that parking *looks like* asking, so a "don't ask me"
53
+ directive seemed to forbid the one move that was legal.
54
+
55
+ - **New gate: `lib/gates/tracked.mjs`.** Claims, resolutions and parks are read off the
56
+ tool *name* (`bmx_claim`, `submit_resolution`, `request_input`, …) — never off shell
57
+ text, because `git commit` is not a claim. A claimed item with no disposition is a
58
+ gap that keeps the run open, and the agent's own progress text is read for the
59
+ confession ("deliberately not resolved", "not parked", "waiting for their reply",
60
+ "it flips the moment…"), which raises it on the spot.
61
+ - **The three dispositions, all of which the agent can take itself**: park it where a
62
+ reviewer sees it (parking is a *marker*, not a question, so "don't ask me" never
63
+ forbids it); take the next action you control; or, if you are genuinely waiting on
64
+ someone else, make the wait end in one step — the test that will confirm the outcome,
65
+ the draft mail — and keep going. The gap text offers all three.
66
+ - The gap is worded so it cannot route itself into the report: it names no slash
67
+ command and never says "ask the PO", because a gap that reads like a human action is
68
+ filtered out by `splitGaps` and would make the guard decorative. Pinned by a test.
69
+ - **Contract text now lives where the agent reads it.** The no-stops policy existed only
70
+ in a source docstring; `06i-completeness.md` (always-read) now carries the rule and the
71
+ DoD has a line for it, with the case study in `06f-stability.md` §6. The always-read
72
+ load stayed inside its budget (75,953 / 76,000) — detail moved out to 06f rather than
73
+ the budget being raised.
74
+
75
+ ### Fixed — declaration files that lie about their module
76
+
77
+ `lib/gates/blockers.d.mts` still declared `RULE_TEXT` / `refusalStage` /
78
+ `__resetRefusals` after they moved to `refusals.mjs` (0.17.5); `lib/gates/settle.d.mts`
79
+ still declared `MAX_IDENTICAL_STEERS` after the cap was deleted; `lib/activation.d.mts`
80
+ still declared `SOURCE_EXTENSIONS` after it was removed. A `.ts` importing any of those
81
+ typechecks clean and gets `undefined` at runtime — the compiler is the tool that is
82
+ wrong. New suite **`test/test-declarations.mjs`** loads every `.mjs` beside each `.d.mts`
83
+ and fails on any declared export the module does not have. It found the third case on
84
+ its first run.
85
+
86
+ ### Fixed — the test runner could not fail
87
+
88
+ `test/harness.mjs` carried `const ok = globalThis.__tools = tools` where it meant to
89
+ capture the suite's verdict: `ok` was the tools array — always truthy — and the return
90
+ value of `run()` was discarded. A suite reporting failure by returning false printed its
91
+ FAILs and was still counted green, which is precisely the bug the comment above it
92
+ claimed was fixed. The verdict is now captured, and the new declarations suite depends
93
+ on it.
94
+
95
+ ### Also
96
+
97
+ - `06i-completeness.md` still described the old steering rule ("a gap is steered once,
98
+ never twice") after 0.16.0 removed the cap.
99
+
10
100
  ## [0.17.6] - 2026-11-09
11
101
 
12
102
  ### Fixed — a gate that crashes is not a gate that passed
@@ -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;
@@ -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 });
@@ -17,6 +17,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
17
  import { classifyToolCall, activationDecision } from "../../activation.mjs";
18
18
  import { evaluateBlockers, BLOCKERS, failSafeBlock } from "../../gates/blockers.mjs";
19
19
  import { deriveSuiteRun } from "../../gates/suite-runs.mjs";
20
+ import { noteTrackedCall, matchersFromConfig } 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";
@@ -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, config: Record<string, unknown>): void {
116
+ const kind = noteTrackedCall(runtime.tracked, { toolName, input, matchers: matchersFromConfig(config) });
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> = {};
@@ -200,6 +223,9 @@ export function registerToolCallHandler(pi: ExtensionAPI): void {
200
223
  const ev = event as Event;
201
224
  const toolName = String(ev.toolName || "");
202
225
  const input = ev.input || {};
226
+ // Read once, hand it to whoever needs it: the gate below and the tracked-work
227
+ // matcher both ask, and a second read is a second answer to the same question.
228
+ const config = readProjectConfig(runRoot());
203
229
 
204
230
  // An observer failure here must not block the work, so it is fenced.
205
231
  try {
@@ -209,8 +235,8 @@ export function registerToolCallHandler(pi: ExtensionAPI): void {
209
235
  }
210
236
  observeSuiteAttempt(input, ev);
211
237
  observeWebTouch(input);
238
+ observeTrackedWork(toolName, input, config);
212
239
 
213
- const config = readProjectConfig(runRoot());
214
240
  const res = evaluateBlockers(toolName, input, {
215
241
  ctx: {
216
242
  suiteRuns,
@@ -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
  };
@@ -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,38 @@
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 interface TrackedMatchers {
20
+ claim: RegExp;
21
+ resolve: RegExp;
22
+ park: RegExp;
23
+ wait: RegExp;
24
+ }
25
+
26
+ /** Built-in matchers, widened (never replaced) by `gate.tracked.*` in the project config. */
27
+ export function matchersFromConfig(config?: Record<string, unknown>): TrackedMatchers;
28
+
29
+ export function createTracked(): TrackedState;
30
+
31
+ export function noteTrackedCall(
32
+ tracked: TrackedState,
33
+ call: { toolName?: string; input?: Record<string, unknown>; matchers?: TrackedMatchers },
34
+ ): "claim" | "resolve" | "park" | "wait" | null;
35
+
36
+ export function danglingGaps(tracked: TrackedState | undefined): string[];
37
+
38
+ export function trackedClear(tracked: TrackedState | undefined): boolean;
@@ -0,0 +1,197 @@
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
+ * What this is: a **tripwire over tool names and the agent's own prose**, not an
22
+ * authority over ticket state. It cannot see a tracker's database, and its
23
+ * vocabulary is a small illustrative set, widened per project through
24
+ * `gate.tracked.*`. A tool named `grabWorkItem` is invisible to it until someone
25
+ * says so. That is acceptable because the cost of a miss is a run that settles
26
+ * early; the cost of a false alarm is one steer.
27
+ */
28
+
29
+ /** The only three things an agent may do with work it cannot finish right now. */
30
+ export const DISPOSITIONS = [
31
+ "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",
32
+ "take the next action you control, now, in this run",
33
+ "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",
34
+ ];
35
+
36
+ /**
37
+ * Tool names that open a tracked item. Matched on the tool name, never on shell
38
+ * text: `bmx_claim`, `linear_claim`, `ticket_assign`. A bash command is not a
39
+ * claim, because `git commit` is not one either.
40
+ */
41
+ const CLAIM_RE = /(^|[_-])(claim|assign|take|acquire)([_-]|$)/i;
42
+ /** Tool names that close one. `submit_resolution`, `resolve_ticket`, `finish`… */
43
+ const RESOLVE_RE = /(^|[_-])(resolve|resolution|finish|complete|close|deliver)([_-]|$)/i;
44
+ /** Tool names that park one where a human sees it. */
45
+ const PARK_RE = /(^|[_-])(park|escalate|needs?[_-]?input|request[_-]?input|request[_-]?review|block[_-]?review|hand[_-]?off)([_-]|$)/i;
46
+ /** Tool names whose text the agent writes for a human to read. */
47
+ const NARRATE_RE = /(^|[_-])(progress|status|update|comment|note|report)([_-]|$)/i;
48
+
49
+ /**
50
+ * The confession. An agent narrating a wait has usually said it out loud, in the
51
+ * very message meant for the reviewer. Deliberately narrow: it must describe
52
+ * leaving work unfinished, not merely mention a test or a queue.
53
+ */
54
+ const WAITING_ALTERNATIVES = [
55
+ "(left|stays?|kept|remain(?:s|ing)?)\\s+(?:it\\s+)?(in\\s+progress|in\\s+the\\s+queue|unresolved|not\\s+resolved|open\\s+deliberately)",
56
+ "deliberately\\s+not\\s+(resolved|closed|parked|finished)",
57
+ "not\\s+parked",
58
+ "waiting\\s+(for|on)\\s+(the|their|your|a\\s+reply|them)",
59
+ "as\\s+soon\\s+as\\s+(they|he|she|it)\\s+repl(?:y|ies|ied)",
60
+ "it\\s+flips\\s+(the\\s+moment|when|once)",
61
+ "pend(?:ing|s)\\s+(their|the|his|her)\\s+repl(?:y|ies)",
62
+ "until\\s+(they|he|she|it)\\s+(repl(?:y|ies|ied)|answers?|responds?)",
63
+ "resume[sd]?\\s+(when|once|the\\s+moment)",
64
+ ];
65
+ const WAITING_RE = new RegExp(WAITING_ALTERNATIVES.join("|"), "i");
66
+
67
+ /**
68
+ * The matchers as one value, so a project can widen them.
69
+ *
70
+ * `gate.tracked.claim` / `.resolve` / `.park` / `.wait` in the project config each
71
+ * add patterns to the built-in set (a string, or an array of strings). They widen,
72
+ * never replace: a project that forgets `claim` would stop tracking its own claims,
73
+ * and a gate that can be switched off by a typo in a config file is not a floor.
74
+ */
75
+ export function matchersFromConfig(config = {}) {
76
+ const extra = (key) => {
77
+ const v = config[`gate.tracked.${key}`];
78
+ const list = Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
79
+ return list.filter((x) => typeof x === "string" && x.trim()).map((x) => x.trim());
80
+ };
81
+ const build = (base, key) => {
82
+ const add = extra(key);
83
+ return add.length ? new RegExp(`${base.source}|${add.map(escapeRe).join("|")}`, "i") : base;
84
+ };
85
+ // The empty alternative is the trap here: `a|b|` matches the empty string, so a
86
+ // dangling `|` turns "does this text confess a wait" into "yes, always" — a gate
87
+ // that trips on everything is as useless as one that trips on nothing, and it is
88
+ // far harder to notice. Every union below is guarded by `add.length`.
89
+ const waitAdd = extra("wait");
90
+ const waitSource = waitAdd.length
91
+ ? `${WAITING_ALTERNATIVES.join("|")}|${waitAdd.map(escapeRe).join("|")}`
92
+ : WAITING_ALTERNATIVES.join("|");
93
+ return {
94
+ claim: build(CLAIM_RE, "claim"),
95
+ resolve: build(RESOLVE_RE, "resolve"),
96
+ park: build(PARK_RE, "park"),
97
+ wait: new RegExp(waitSource, "i"),
98
+ };
99
+ }
100
+
101
+ function escapeRe(source) {
102
+ return source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
103
+ }
104
+
105
+ /** Identify the item a call is about, so one ticket is one entry. */
106
+ function itemId(input) {
107
+ if (!input || typeof input !== "object") return null;
108
+ for (const k of ["ticket_id", "ticketId", "id", "issue", "issue_id", "work_item_id"]) {
109
+ const v = input[k];
110
+ if (typeof v === "string" && v.trim()) return v.trim().slice(0, 60);
111
+ if (typeof v === "number") return String(v);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function textOf(input) {
117
+ if (!input || typeof input !== "object") return "";
118
+ const parts = [];
119
+ for (const k of ["text", "body", "comment", "message", "summary", "reason", "note", "status"]) {
120
+ const v = input[k];
121
+ if (typeof v === "string") parts.push(v);
122
+ }
123
+ return parts.join("\n").slice(0, 4000);
124
+ }
125
+
126
+ /** Fresh tracked-work state. `open` maps an item id to what opened it. */
127
+ export function createTracked() {
128
+ return { open: {}, waits: [], seen: [] };
129
+ }
130
+
131
+ function openItem(tracked, id, toolName) {
132
+ if (tracked.open[id]) return;
133
+ tracked.open[id] = { at: Date.now(), by: toolName };
134
+ tracked.seen.push(id);
135
+ }
136
+
137
+ function closeItem(tracked, id) {
138
+ delete tracked.open[id];
139
+ }
140
+
141
+ /**
142
+ * Record what a tool call did to tracked work.
143
+ *
144
+ * Returns a short label for the evidence trail (`claim` / `resolve` / `park` /
145
+ * `wait` / null). Never throws on odd input: this is an observer on the hot path.
146
+ */
147
+ export function noteTrackedCall(tracked, { toolName = "", input = {}, matchers } = {}) {
148
+ const m = matchers || matchersFromConfig();
149
+ if (!tracked || !toolName) return null;
150
+ const id = itemId(input) || `tool:${toolName}`;
151
+ if (m.park.test(toolName)) {
152
+ closeItem(tracked, id);
153
+ return "park";
154
+ }
155
+ if (m.resolve.test(toolName)) {
156
+ closeItem(tracked, id);
157
+ return "resolve";
158
+ }
159
+ if (m.claim.test(toolName)) {
160
+ openItem(tracked, id, toolName);
161
+ return "claim";
162
+ }
163
+ if (NARRATE_RE.test(toolName) && m.wait.test(textOf(input))) {
164
+ // Narrating a wait does not make it legal; it makes it visible to the gate.
165
+ openItem(tracked, id, toolName);
166
+ if (!tracked.waits.includes(id)) tracked.waits.push(id);
167
+ return "wait";
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Gaps for work left dangling at settle time.
174
+ *
175
+ * Wording matters: these gaps must survive `splitGaps`, so they never say "ask
176
+ * the PO", "needs your input" or a slash command — a gap that reads like a human
177
+ * action is routed to the report and stops blocking, which would make this guard
178
+ * decorative. It names only moves the agent can make itself.
179
+ */
180
+ export function danglingGaps(tracked) {
181
+ const ids = Object.keys(tracked && tracked.open ? tracked.open : {});
182
+ if (!ids.length) return [];
183
+ const how = DISPOSITIONS.map((d, i) => `${i + 1}) ${d}`).join(" ");
184
+ return ids.map((id) => {
185
+ const waited = tracked.waits.includes(id);
186
+ return (
187
+ `tracked work "${id}" is still open and this run is ending with it neither resolved nor parked` +
188
+ (waited ? " — and the agent has just narrated waiting for it, which is not a state ASF recognises" : "") +
189
+ `. Nothing here stops you. Do one of: ${how}. A run does not close while tracked work dangles.`
190
+ );
191
+ });
192
+ }
193
+
194
+ /** True when nothing is dangling (used by tests and by the run card). */
195
+ export function trackedClear(tracked) {
196
+ return Object.keys(tracked && tracked.open ? tracked.open : {}).length === 0;
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.17.6",
3
+ "version": "0.17.8",
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,72 @@ 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
+
143
+ It is a **tripwire over tool names and the agent's own prose, not an authority over
144
+ ticket state** — it cannot see a tracker's database, and its vocabulary is a small
145
+ illustrative set. A project widens it (never narrows it) in `.asf/config.json`:
146
+
147
+ ```json
148
+ { "gate.tracked.claim": "grabWorkItem", "gate.tracked.wait": ["still stuck on"] }
149
+ ```
150
+
151
+ Keys: `gate.tracked.claim`, `.resolve`, `.park`, `.wait`. A miss costs a run that
152
+ settles early; a false alarm costs one steer. That asymmetry is why the defaults lean
153
+ toward catching.
154
+
89
155
  ## Anti-patterns
90
156
 
91
157
  - ❌ 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/suite-runs.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