pi-aia-asf 0.17.13 → 0.18.0

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,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.0] - 2026-11-10
11
+
12
+ ### Added — a run can be completed, and tests can no longer corrupt one
13
+
14
+ Two defects from the same blind spot: ASF tracked runs but had no honest way to finish
15
+ one, and nothing stopped a test suite from writing into the run state a real user reads.
16
+
17
+ **`asf_run_complete`.** A run used to close only when the completion gate happened to
18
+ pass at settle time, or when a human typed `/asf abort`. There was no action the agent
19
+ could take, so a run that had genuinely finished stayed open and swallowed the next
20
+ piece of work. This project carried a single run opened three days earlier that had
21
+ absorbed several releases and unrelated changes in its `filesTouched` — the gate was
22
+ judging today's work against Monday's evidence, so nothing settled and the loop never
23
+ closed. The user's instruction was to "properly complete and mark as completed every
24
+ run, so you judge the next run separately again".
25
+
26
+ - The tool asks the **same question the gate asks**: `evaluateRun()` is now the single
27
+ judge, called by both `judgeAndSettle` and the tool. Two implementations of "is this
28
+ run done" drift, and the drift reads as a run the agent closed that the gate still
29
+ counts as open.
30
+ - It **fails closed**. An unfinished run is refused with its gaps listed and stays open
31
+ with its evidence. A tool that closed runs on request would be an exit around the
32
+ completion gate, which is worse than the bug it fixes.
33
+ - `closeRun()` is the only writer of run history and the only clearer of `current`, and
34
+ it records `by` — `auto` from the gate, `asf_run_complete` from the agent — so a close
35
+ that happened outside the gate is visible in evidence.
36
+
37
+ ### Changed — the decision register follows the code
38
+
39
+ `asf_decisions` wrote to the session workspace. Working in a repository opened from a
40
+ parent directory (a home directory that is itself a checkout is the common case) put the
41
+ ADRs one level above the project they describe, where nothing would ever read them. The
42
+ register is durable knowledge *about* the code, so it now resolves through `measureRoot()`
43
+ like every other measurement. `currentRegister()` was deleted: it announced itself as
44
+ "exposed for the handlers" and had no callers.
45
+
46
+ ### Fixed — a test suite could not touch a live run
47
+
48
+ A suite that forgot to `chdir` wrote into the developer's real run state: `/etc/passwd`
49
+ and a scratch path appeared in the live run's `filesTouched`, and the reviewer saw them
50
+ in the run card. State is the one thing a test corrupts with no visible failure — the
51
+ assertion passes and the user's screen is wrong.
52
+
53
+ - `homeStateDir()` honours `ASF_STATE_ROOT`, and `test/harness.mjs` sets it to a fresh
54
+ temp directory before the extension loads. The whole suite is now provably inert
55
+ against real state, verified by diffing the state tree across a full `run-all`.
56
+ - `test-decisions.mjs` pins **both** behaviours — the real `.asf/state/<key>` layout when
57
+ unset, the override when set — so the isolation cannot silently defeat the assertion
58
+ it was added next to.
59
+
60
+ `scripts/verify-published.mjs` also states its own boundary: `scripts/` does not ship, so
61
+ a user cannot run it, which is fine only because every assertion it makes delegates to
62
+ shipped code (`lib/gates/evidence-coverage.mjs`, the same module behind
63
+ `asf_evidence_check`). A check added there that cannot be reproduced from an install is a
64
+ check only a releaser can run.
65
+
10
66
  ## [0.17.13] - 2026-11-10
11
67
 
12
68
  ### Changed — no scale means small (PO policy)
package/index.ts CHANGED
@@ -20,6 +20,7 @@ import { registerHealthTool } from "./lib/extension/tools/health.ts";
20
20
  import { registerDecisionsTool } from "./lib/extension/tools/decisions.ts";
21
21
  import { registerBrowserTools } from "./lib/extension/tools/browser.ts";
22
22
  import { registerEvidenceCheckTool } from "./lib/extension/tools/evidence-check.ts";
23
+ import { registerRunCompleteTool } from "./lib/extension/tools/run-complete.ts";
23
24
  import { registerCommands } from "./lib/extension/commands/index.ts";
24
25
  import { registerToolCallHandler } from "./lib/extension/handlers/tool-call.ts";
25
26
  import { registerToolResultHandler } from "./lib/extension/handlers/tool-result.ts";
@@ -34,6 +35,7 @@ export default function register(pi: ExtensionAPI): void {
34
35
  registerDecisionsTool(pi);
35
36
  registerBrowserTools(pi);
36
37
  registerEvidenceCheckTool(pi);
38
+ registerRunCompleteTool(pi);
37
39
  registerCommands(pi);
38
40
  registerToolCallHandler(pi);
39
41
  registerToolResultHandler(pi);
@@ -135,10 +135,21 @@ async function handleBlock(
135
135
  return { entries, continue: true };
136
136
  }
137
137
 
138
- /** An auto-opened run whose floor is met closes itself. */
139
- async function closeAutoRun(state: AsfState, project: string, file: ProjectStateFile): Promise<void> {
138
+ /**
139
+ * Close a run: archive it to history and clear `current`.
140
+ *
141
+ * The next piece of work then opens its own run and is judged on its own facts. A run
142
+ * that is never closed accretes everything the session does — this project once carried
143
+ * a single run opened three days earlier, with four releases and several unrelated
144
+ * changes inside its `filesTouched`, so the gate was judging today's work against
145
+ * Monday's evidence and nothing could settle.
146
+ *
147
+ * `by` records who closed it: the gate itself (`auto`) or the agent asking
148
+ * (`asf_run_complete`), so a run that closed without the gate passing is visible.
149
+ */
150
+ export async function closeRun(state: AsfState, file: ProjectStateFile, { project, by = "auto" }: { project: string; by?: string }): Promise<void> {
140
151
  file.history.push({ workType: state.workType || "auto", phase: state.phase, startedAt: state.startedAt || state.updatedAt, endedAt: new Date().toISOString() });
141
- appendEvidence({ kind: "run-close", runId: state.runId, auto: true });
152
+ appendEvidence({ kind: "run-close", runId: state.runId, auto: by === "auto", by });
142
153
  file.current = null;
143
154
  await saveState(project, file);
144
155
  }
@@ -179,8 +190,15 @@ function decisionFacts(state: AsfState) {
179
190
  }
180
191
  }
181
192
 
182
- async function judgeAndSettle(ctx: SettleContext, priorCount: number): Promise<SettleResult> {
183
- const { project, file, state, entries } = ctx;
193
+ /**
194
+ * Judge a run: the same decision the completion gate makes, in one place.
195
+ *
196
+ * Exported because the agent needs to ask the same question before it claims a run is
197
+ * finished (`asf_run_complete`). Two implementations of "is this run done" drift, and
198
+ * the drift shows up as a run the agent closed and the gate still thinks is open —
199
+ * which is how a run from three days ago was still being carried into new work.
200
+ */
201
+ export async function evaluateRun(project: string, state: AsfState) {
184
202
  // Only a passing test that STARTED after the last source change validates it.
185
203
  const since = typeof state.lastMutationAt === "number" ? state.lastMutationAt : 0;
186
204
  const testRunRecorded = suiteRuns.some((r) => r.exitCode === 0 && (r.at ?? 0) >= since);
@@ -189,7 +207,7 @@ async function judgeAndSettle(ctx: SettleContext, priorCount: number): Promise<S
189
207
  // stale, raced or hand-edited. max() means a counter can never go down.
190
208
  const mem = steerLedgerFor(`${project}|${state.runId || "run"}`);
191
209
  sanitizeSteerFields(state);
192
- const decision = decideSettlement(ledger, {
210
+ return decideSettlement(ledger, {
193
211
  untracedMet,
194
212
  scale: currentScale(state),
195
213
  webTouched: runtime.webTouched,
@@ -200,12 +218,18 @@ async function judgeAndSettle(ctx: SettleContext, priorCount: number): Promise<S
200
218
  tracked: runtime.tracked,
201
219
  decisionGaps: decisionFacts(state),
202
220
  });
221
+ }
222
+
223
+ async function judgeAndSettle(ctx: SettleContext, priorCount: number): Promise<SettleResult> {
224
+ const { project, file, state, entries } = ctx;
225
+ const mem = steerLedgerFor(`${project}|${state.runId || "run"}`);
226
+ const decision = await evaluateRun(project, state);
203
227
  runtime.lastSettleDecision = decision;
204
228
  appendEvidence({ kind: "gate-verdict", gate: "completion", fired: decision.missing.length > 0, missing: decision.missing });
205
229
 
206
230
  if (decision.park) return await handlePark(ctx, decision.park as { reason: string; question: string });
207
231
  if (decision.block) return await handleBlock({ ...ctx, mem }, decision as BlockRequest);
208
- if (decision.missing.length === 0 && state.auto) await closeAutoRun(state, project, file);
232
+ if (decision.missing.length === 0 && state.auto) await closeRun(state, file, { project });
209
233
  // A continuation for guidance alone is also bounded: an agent that keeps
210
234
  // producing new distinct steers must not be kept alive by its own advice.
211
235
  if (entries.length > priorCount && steerContinuationsAllowed(mem)) return { entries, continue: true };
@@ -21,6 +21,7 @@ import { registerAt, addDecision, supersedeDecision, relativeTo } from "../../de
21
21
  import { matchesAny } from "../../decisions/glob.mjs";
22
22
  import { readProjectConfig } from "../../gates/config.mjs";
23
23
  import { runRoot, projectName } from "../paths.ts";
24
+ import { measureRoot } from "../run.ts";
24
25
  import { loadState } from "../state.ts";
25
26
  import { runtime } from "../runtime.ts";
26
27
  import { appendEvidence } from "../evidence.ts";
@@ -43,9 +44,12 @@ async function planApproved(): Promise<boolean> {
43
44
  }
44
45
  }
45
46
 
46
- function config(): Record<string, unknown> {
47
+ function config(root: string): Record<string, unknown> {
47
48
  try {
48
- return readProjectConfig(runRoot());
49
+ // The register is durable knowledge ABOUT the code, so it follows the code the
50
+ // run is changing — not the session workspace, which for a repo opened from a
51
+ // parent directory would put the ADRs one level above the project they describe.
52
+ return readProjectConfig(root);
49
53
  } catch {
50
54
  return {};
51
55
  }
@@ -88,14 +92,14 @@ function renderProblems(problems: { severity: string; file?: string; field?: str
88
92
  }
89
93
 
90
94
  async function listAction(root: string): Promise<ActionOut> {
91
- const { dir, records, problems } = registerAt(root, config());
95
+ const { dir, records, problems } = registerAt(root, config(root));
92
96
  const errors = problems.filter((p) => p.severity === "error");
93
97
  const text = `${dir}\n\n${renderList(records)}${errors.length ? `\n\n${renderProblems(errors)}` : ""}`;
94
98
  return { text, ok: errors.length === 0, count: records.length };
95
99
  }
96
100
 
97
101
  async function getAction(root: string, id: string): Promise<ActionOut> {
98
- const { records } = registerAt(root, config());
102
+ const { records } = registerAt(root, config(root));
99
103
  const found = records.find((r) => String(r.id).toLowerCase() === id.toLowerCase());
100
104
  if (!found) return { text: `No ${id} in the register.`, ok: false, count: 0 };
101
105
  if (!runtime.decisionsRead.includes(String(found.id))) runtime.decisionsRead.push(String(found.id));
@@ -106,7 +110,7 @@ async function getAction(root: string, id: string): Promise<ActionOut> {
106
110
  }
107
111
 
108
112
  async function matchAction(root: string, filePath: string): Promise<ActionOut> {
109
- const { records } = registerAt(root, config());
113
+ const { records } = registerAt(root, config(root));
110
114
  const hit = records.filter((r) => r.status === "accepted" && matches(r, filePath));
111
115
  const text = hit.length
112
116
  ? `${filePath} is governed by:\n${renderList(hit)}\n\nRead them with action=get before changing the file.`
@@ -121,11 +125,11 @@ function matches(r: DecisionRecord, filePath: string): boolean {
121
125
 
122
126
  async function recordAction(root: string, p: Record<string, unknown>): Promise<ActionOut> {
123
127
  const approved = await planApproved();
124
- const { records } = registerAt(root, config());
128
+ const { records } = registerAt(root, config(root));
125
129
  const out = addDecision(root, {
126
130
  record: recordFrom(p),
127
131
  body: str(p.body),
128
- config: config(),
132
+ config: config(root),
129
133
  planApproved: approved,
130
134
  read: records,
131
135
  });
@@ -140,7 +144,7 @@ async function recordAction(root: string, p: Record<string, unknown>): Promise<A
140
144
 
141
145
  async function supersedeAction(root: string, p: Record<string, unknown>): Promise<ActionOut> {
142
146
  const approved = await planApproved();
143
- const { records } = registerAt(root, config());
147
+ const { records } = registerAt(root, config(root));
144
148
  // The `id` parameter names the decision being REPLACED; the record built from
145
149
  // the same params must not carry it, or the new decision is written with the old
146
150
  // decision's id and the register ends up with two claimants for one number.
@@ -150,7 +154,7 @@ async function supersedeAction(root: string, p: Record<string, unknown>): Promis
150
154
  by: "agent",
151
155
  record: recordFrom(recordParams),
152
156
  body: str(p.body),
153
- config: config(),
157
+ config: config(root),
154
158
  planApproved: approved,
155
159
  read: records,
156
160
  });
@@ -161,7 +165,7 @@ async function supersedeAction(root: string, p: Record<string, unknown>): Promis
161
165
  }
162
166
 
163
167
  async function checkAction(root: string): Promise<ActionOut> {
164
- const { dir, records, problems } = registerAt(root, config());
168
+ const { dir, records, problems } = registerAt(root, config(root));
165
169
  const errors = problems.filter((p) => p.severity === "error");
166
170
  const notices = problems.filter((p) => p.severity === "notice");
167
171
  // What the completion floor believes about THIS run. Without it the citation rules
@@ -228,7 +232,7 @@ export function registerDecisionsTool(pi: ExtensionAPI): void {
228
232
  promptSnippet: "Consult or record the project's architecture decisions",
229
233
  parameters: Type.Object(DECISION_PARAMS),
230
234
  async execute(_toolCallId: string, params: Record<string, unknown>) {
231
- const root = runRoot();
235
+ const root = await measureRoot();
232
236
  const action = str(params?.action);
233
237
  try {
234
238
  return toolResult(action, await runAction(root, action, params));
@@ -245,7 +249,3 @@ export function registerDecisionsTool(pi: ExtensionAPI): void {
245
249
  });
246
250
  }
247
251
 
248
- /** Exposed for the handlers: the register as the rest of ASF sees it. */
249
- export function currentRegister() {
250
- return registerAt(runRoot(), config());
251
- }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * asf_run_complete — the agent's way to finish a run.
3
+ *
4
+ * A run used to close only when the completion gate happened to pass at settle time,
5
+ * or when a human typed `/asf abort`. Neither is an action the agent can take, so a run
6
+ * that had genuinely finished stayed open and swallowed the next piece of work: this
7
+ * project carried one run opened three days earlier, holding four releases and several
8
+ * unrelated changes in its `filesTouched`, and the gate judged today's work against
9
+ * Monday's evidence. Nothing settled, and the loop never closed.
10
+ *
11
+ * It asks the SAME question the gate asks (`evaluateRun`), so the tool and the gate
12
+ * cannot disagree about what "done" means. And it fails closed: an unfinished run is
13
+ * refused with the gaps listed. A tool that closed runs on request would be an exit
14
+ * around the completion gate, which is worse than the bug it fixes.
15
+ */
16
+
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { Type } from "typebox";
19
+ import { projectName } from "../paths.ts";
20
+ import { loadState } from "../state.ts";
21
+ import { evaluateRun, closeRun } from "../handlers/settle.ts";
22
+
23
+ /** One details shape for every branch: branches that disagree about which fields
24
+ * exist make the host read `error` as required and reject the tool. */
25
+ function details() {
26
+ return {
27
+ action: "run-complete",
28
+ ok: false,
29
+ closed: false,
30
+ error: "",
31
+ runId: "",
32
+ note: "",
33
+ missing: [] as string[],
34
+ };
35
+ }
36
+
37
+ async function completeRun(note: string) {
38
+ const project = projectName();
39
+ const file = await loadState(project);
40
+ const state = file.current;
41
+ if (!state || state.phase === "none") {
42
+ return {
43
+ text: "No active ASF run to complete — the next piece of work opens a fresh one.",
44
+ res: { ...details(), error: "no-run" },
45
+ };
46
+ }
47
+
48
+ const decision = await evaluateRun(project, state);
49
+ const missing = decision.missing || [];
50
+ if (missing.length > 0) {
51
+ // Refusing is the whole point: this is the gate's own verdict, not a formality.
52
+ return {
53
+ text:
54
+ `Run ${state.runId} is NOT complete — the definition of done still has ${missing.length} gap(s):\n` +
55
+ missing.map((m: string) => `- ${m}`).join("\n") +
56
+ `\n\nClose those and call it again. The run stays open and keeps its evidence.`,
57
+ res: { ...details(), runId: state.runId || "", missing },
58
+ };
59
+ }
60
+
61
+ const runId = state.runId || "run";
62
+ await closeRun(state, file, { project, by: "asf_run_complete" });
63
+ return {
64
+ text:
65
+ `Run ${runId} complete (${state.phase}${state.scale ? `, ${state.scale}` : ""})` +
66
+ `${note ? ` — ${note}` : ""}. It is archived to history and the next piece of work ` +
67
+ `opens a fresh run, judged on its own facts.`,
68
+ res: { ...details(), ok: true, closed: true, runId, note },
69
+ };
70
+ }
71
+
72
+ export function registerRunCompleteTool(pi: ExtensionAPI): void {
73
+ pi.registerTool({
74
+ name: "asf_run_complete",
75
+ label: "ASF Complete Run",
76
+ description:
77
+ "Finish the current ASF run so the NEXT piece of work is judged on its own facts. " +
78
+ "Call it when the work this run was opened for is genuinely done — tests run, evidence " +
79
+ "recorded, released if it was a release. It asks the same question the completion gate " +
80
+ "asks, so it cannot be used to get around the gate: an unfinished run is refused with " +
81
+ "the gaps listed, and the run stays open.",
82
+ promptSnippet: "Mark the current run complete (only if its definition of done is met)",
83
+ parameters: Type.Object({
84
+ note: Type.Optional(
85
+ Type.String({ description: "One line on what the run delivered; recorded with the closure." }),
86
+ ),
87
+ }),
88
+
89
+ async execute(_toolCallId: string, params: { note?: unknown }) {
90
+ const note = typeof params?.note === "string" ? params.note.trim() : "";
91
+ const { text, res } = await completeRun(note);
92
+ return { content: [{ type: "text" as const, text }], details: res };
93
+ },
94
+ });
95
+ }
package/lib/home.mjs CHANGED
@@ -49,8 +49,20 @@ export function asfDir(root) {
49
49
  return path.join(root, ASF_DIR);
50
50
  }
51
51
 
52
- /** Where the run state lives now. `key` is the project key (git-root based). */
52
+ /**
53
+ * Where the run state lives now. `key` is the project key (git-root based).
54
+ *
55
+ * `ASF_STATE_ROOT` redirects the whole tree. It exists for one reason: a suite that
56
+ * forgets to `chdir` was writing into the developer's LIVE run state, and the garbage
57
+ * (a `/etc/passwd` entry in `filesTouched`) showed up in the run card the reviewer
58
+ * reads. State is the one piece of ASF that a test can corrupt without noticing, so
59
+ * the test harness points it at a temp directory and no suite can reach the real one.
60
+ */
53
61
  export function homeStateDir(root, key) {
62
+ const override = process.env.ASF_STATE_ROOT;
63
+ // The key is derived from the git root, so it is already unique per project;
64
+ // nesting the root under the override again would only make the path unreadable.
65
+ if (typeof override === "string" && override.trim()) return path.join(path.resolve(override), key);
54
66
  return path.join(asfDir(root), STATE_SUBDIR, key);
55
67
  }
56
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.17.13",
3
+ "version": "0.18.0",
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",