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 +90 -0
- package/lib/activation.d.mts +0 -1
- package/lib/extension/handlers/settle.ts +1 -0
- package/lib/extension/handlers/tool-call.ts +27 -1
- package/lib/extension/runtime.ts +9 -0
- package/lib/gates/settle.d.mts +2 -1
- package/lib/gates/settle.mjs +12 -0
- package/lib/gates/tracked.d.mts +38 -0
- package/lib/gates/tracked.mjs +197 -0
- package/package.json +1 -1
- package/skills/aia-asf/references/06f-stability.md +66 -0
- package/skills/aia-asf/references/06i-completeness.md +37 -31
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
|
package/lib/activation.d.mts
CHANGED
|
@@ -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,
|
package/lib/extension/runtime.ts
CHANGED
|
@@ -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
|
};
|
package/lib/gates/settle.d.mts
CHANGED
|
@@ -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;
|
package/lib/gates/settle.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Before claiming done, run
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
97
|
-
|
|
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
|