pi-aia-asf 0.17.5 → 0.17.6

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,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.6] - 2026-11-09
11
+
12
+ ### Fixed — a gate that crashes is not a gate that passed
13
+
14
+ Both tool handlers were fenced with `catch { return undefined }`, which had two consequences of
15
+ exactly the kind ASF bans:
16
+
17
+ - A failure inside the **suite recorder** lost the run silently, after which the publish and
18
+ completion floors demanded a run that could never register — an unclosable gap with no visible
19
+ cause.
20
+ - Because pi's own semantics for a crashing `tool_call` handler are *block*, returning "allow"
21
+ overrode them: anything that made the earlier code throw — a config read, the activation
22
+ observer — disabled the **irreversibility floor** for that call.
23
+
24
+ Now:
25
+
26
+ - `reportGateError(gate, e)` records a `gate-error` ledger entry and tells the agent once, as a
27
+ steer: *"That is not a pass — the check failed to execute."* Guidance, never a stop.
28
+ - When the gate dies **before** the blockers ran, `failSafeBlock(input)` re-runs just the rules
29
+ whose `failSafe` is `block`, from the raw tool input alone, with no context to fail on. A secret
30
+ in a tracked file is still refused when the gate itself is broken; ordinary work is never
31
+ stopped by an internal error.
32
+ - A fail-safe rule that *itself* throws refuses ("could not run") rather than reporting nothing
33
+ found.
34
+
35
+ Three assertions pin this, each mutation-tested: removing the report, the fail-closed call, or the
36
+ crash-refusal each fails a test.
37
+
38
+ - `lib/gates/blockers.d.mts` still declared `RULE_TEXT`, `refusalStage` and `__resetRefusals` after
39
+ they moved to `lib/gates/refusals.mjs` in 0.17.5 — a `.ts` could import them, typecheck, and get
40
+ `undefined` at runtime. The declarations now match the modules.
41
+
42
+ ### Removed
43
+
44
+ - Dead export `SOURCE_EXTENSIONS` from `lib/activation.mjs` (no importer).
45
+
46
+ ### Documented
47
+
48
+ - **The loaded copy is not the shipped copy** (07-release.md step 7 + hygiene rule): `pi` loads
49
+ extensions once at process start, so a session open across a release keeps enforcing the previous
50
+ version and quoting text that no longer exists in the source. Confirm which build is loaded — a
51
+ string unique to the new release, and the process start time — before concluding a released gate
52
+ is broken. That is the difference between "the fix did not work" and "the fix is not running", and
53
+ it cost hours chasing a phantom bug in 0.17.5.
54
+ - `06i-completeness.md` pointed its drift example at `lib/gates/ledger.mjs` / `deriveSuiteRun()`;
55
+ those moved to `lib/gates/suite-runs.mjs` in 0.17.5, and the doc — which is about not trusting
56
+ stale references — was itself stale.
57
+
10
58
  ## [0.17.5] - 2026-11-08
11
59
 
12
60
  ### Fixed — the publish floor could be satisfied by a green that proved nothing
@@ -177,5 +177,3 @@ export function activationDecision({ call, activeRun, passiveRunId = null } = {}
177
177
  return { activate: true };
178
178
  }
179
179
 
180
- /** Exported for the tests and for `/asf` diagnostics. */
181
- export const SOURCE_EXTENSIONS = SOURCE_EXT;
@@ -27,6 +27,30 @@ export function queueSteer(text: string) {
27
27
  pendingSteers.push(text);
28
28
  }
29
29
 
30
+ /**
31
+ * An internal gate error must never be able to look like a pass.
32
+ *
33
+ * Every ASF handler is fenced, because an observer must not break the work it
34
+ * watches. Fencing *without* reporting is exactly how the 2026-11-07 loop became
35
+ * possible: a gate demanded something, the code that would have shown why
36
+ * swallowed its own error, and the agent was steered forever toward an action
37
+ * that could not register. So a swallowed error is (a) recorded as evidence and
38
+ * (b) told to the agent once, as a steer — guidance, never a stop.
39
+ *
40
+ * The settle gate has a stronger channel (it returns the entry inline, so the
41
+ * agent sees it in the same turn); the tool handlers do not, so they use this.
42
+ */
43
+ export function reportGateError(gate: string, e: unknown) {
44
+ const message = String((e as Error | null)?.message || e || "unknown");
45
+ appendEvidence({ kind: "gate-error", gate, error: message.slice(0, 300) });
46
+ const cls = (e as { code?: string } | null)?.code || (e as Error | null)?.name || "unknown";
47
+ queueSteer(
48
+ `ASF: the ${gate} hit an internal error (${cls}: ${message.slice(0, 140)}) and did not run. ` +
49
+ `That is not a pass — the check failed to execute. Re-issue the action; if it repeats, ` +
50
+ `this is an ASF bug to report, not a rule to satisfy.`,
51
+ );
52
+ }
53
+
30
54
 
31
55
  export function appendEvidence(entry: Record<string, unknown>) {
32
56
  const res = ledger.append(entry);
@@ -15,13 +15,13 @@
15
15
 
16
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
17
  import { classifyToolCall, activationDecision } from "../../activation.mjs";
18
- import { evaluateBlockers, BLOCKERS } from "../../gates/blockers.mjs";
18
+ import { evaluateBlockers, BLOCKERS, failSafeBlock } from "../../gates/blockers.mjs";
19
19
  import { deriveSuiteRun } from "../../gates/suite-runs.mjs";
20
20
  import { readProjectConfig } from "../../gates/config.mjs";
21
21
  import { projectName, runRoot } from "../paths.ts";
22
22
  import { loadState, saveState } from "../state.ts";
23
23
  import { openAutoRun, gitDirty, numberConfig } from "../run.ts";
24
- import { appendEvidence, queueSteer } from "../evidence.ts";
24
+ import { appendEvidence, queueSteer, reportGateError } from "../evidence.ts";
25
25
  import { gateStats, pendingSuiteRuns, suiteRuns, recordVerdict } from "../gate-state.ts";
26
26
  import { authority } from "../gate-state.ts";
27
27
  import { runtime, BLIND_EDIT_NUDGE_AFTER } from "../runtime.ts";
@@ -230,10 +230,14 @@ export function registerToolCallHandler(pi: ExtensionAPI): void {
230
230
  if (park) await parkRun(park);
231
231
  if (!res.block) return undefined;
232
232
  return await handleBlock({ pi, res: res as Refusal["res"], park, config });
233
- } catch {
234
- // An observer must never break the thing it observes. The fail-safe for
235
- // the secret rule lives inside evaluateBlockers, where it is explicit.
236
- return undefined;
233
+ } catch (e) {
234
+ // An observer must never break the thing it observes — so this does not
235
+ // stop the work. But it must not look like a clean pass either, and it must
236
+ // not hand anyone a way past the irreversibility floor: an error raised
237
+ // before the blockers ran is re-checked against the rules that guard
238
+ // irreversible actions, from the raw tool input alone.
239
+ reportGateError("tool_call gate", e);
240
+ return failSafeBlock((event as { input?: Input } | null)?.input || {});
237
241
  }
238
242
  });
239
243
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { deriveSuiteRun } from "../../gates/suite-runs.mjs";
12
- import { appendEvidence } from "../evidence.ts";
12
+ import { appendEvidence, reportGateError } from "../evidence.ts";
13
13
  import { pendingSuiteRuns, suiteRuns } from "../gate-state.ts";
14
14
  import { runtime } from "../runtime.ts";
15
15
 
@@ -52,7 +52,11 @@ export function registerToolResultHandler(pi: ExtensionAPI): void {
52
52
  pendingSuiteRuns.delete(id);
53
53
  completeSuiteRun(ev, pending);
54
54
  return undefined;
55
- } catch {
55
+ } catch (e) {
56
+ // Swallowing this one silently is how the publish floor becomes an
57
+ // unclosable gap: the run happened, the recorder failed, and the gate then
58
+ // demands a run that can never register.
59
+ reportGateError("suite recorder", e);
56
60
  return undefined;
57
61
  }
58
62
  });
@@ -34,9 +34,8 @@ export function evaluateBlockers(
34
34
  verdicts: BlockerVerdict[];
35
35
  steers?: Array<{ gate: string; reason: string }>;
36
36
  };
37
- export const RULE_TEXT: Record<string, string>;
38
- export function refusalStage(gate: string, fingerprint: string): number;
39
- export function __resetRefusals(): void;
37
+ /** Re-run the fail-safe-block rules from the raw input alone, when the gate itself could not run. */
38
+ export function failSafeBlock(input: Record<string, unknown>): { block: true; reason: string } | undefined;
40
39
  export function blockSecretWrite(input: Record<string, unknown>, ctx?: Record<string, unknown>): BlockerVerdict;
41
40
  export function blockTestSubset(input: Record<string, unknown>, ctx?: Record<string, unknown>): BlockerVerdict;
42
41
  export function blockPushWithoutGreen(input: Record<string, unknown>, ctx?: Record<string, unknown>): BlockerVerdict;
@@ -160,6 +160,30 @@ export const BLOCKERS = [
160
160
  { name: "destructive-git-dirty-tree", run: blockDestructiveGitDirty, failSafe: "allow", switch: "gate.block.destructiveGit", criticality: "medium", bypassable: true, mode: "block" },
161
161
  ];
162
162
 
163
+ /**
164
+ * The floor that must not fail open.
165
+ *
166
+ * A fenced handler that returns "allow" on its own internal error hands anyone
167
+ * who can make the *other* code throw a way past the irreversibility floor — the
168
+ * secret rule would be disabled by a bug in a config read. So when the gate
169
+ * could not run, the rules whose `failSafe` is `block` are re-run from the raw
170
+ * tool input alone, with no context to fail on; and if even one of those throws,
171
+ * that is a refusal, not a pass.
172
+ */
173
+ export function failSafeBlock(input) {
174
+ for (const b of BLOCKERS) {
175
+ if (b.failSafe !== "block") continue;
176
+ let res;
177
+ try {
178
+ res = b.run(input, {});
179
+ } catch {
180
+ res = { fired: true, reason: `${b.name} could not run, and it guards an irreversible action.` };
181
+ }
182
+ if (res?.fired) return { block: true, reason: `${b.name}: ${res.reason || "refused"}` };
183
+ }
184
+ return undefined;
185
+ }
186
+
163
187
  function str(input, keys) {
164
188
  if (!input || typeof input !== "object") return null;
165
189
  for (const k of keys) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.17.5",
3
+ "version": "0.17.6",
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",
@@ -60,7 +60,7 @@ goes in the final message in plain words — never discovered by the user throug
60
60
  ## Your own artifacts are part of the deliverable (task-scoped drift)
61
61
 
62
62
  The plan and the spec tree are artifacts too, and they make checkable claims: a PLAN.md
63
- that names `lib/gates/ledger.mjs` or `deriveSuiteRun()` is asserting those exist. When the
63
+ that names `lib/gates/suite-runs.mjs` or `deriveSuiteRun()` is asserting those exist. When the
64
64
  code has moved and the artifact has not, the delivery report describes something that is
65
65
  not there — which is the same defect as a stub, just further from the user's eye.
66
66
 
@@ -63,6 +63,13 @@ in the absence of a user definition.
63
63
  - **Deployed app:** smoke-test in **production** (health endpoint, key user journey) — a deploy that breaks prod is not a successful release.
64
64
  - **Not applicable** (internal-only experiment, no runtime surface): say so explicitly in the report (06b Rule 11).
65
65
  - If the published artifact is broken: **fix forward** (new patch), never pretend the release succeeded.
66
+ - **The copy you are testing may not be the copy you shipped.** `pi` loads extensions once at
67
+ process start: a session that was already open keeps enforcing the *previous* version's
68
+ gates no matter what you publish, and its messages quote text that no longer exists in the
69
+ source. Before concluding a released gate is broken, confirm which build is loaded — compare
70
+ a string unique to the new release against the behaviour you saw, and check the process
71
+ start time (`ps -o lstart -p <pid>`). `/reload`, or a new session, is what picks up the new
72
+ code. This is the difference between "the fix did not work" and "the fix is not running".
66
73
 
67
74
  ## CI/CD offer
68
75
 
@@ -79,3 +86,4 @@ After a successful release (or for a new project), **offer** to set up a CI/CD p
79
86
  - The version in package.json, the git tag, and the npm release must match
80
87
  - If publishing fails partway, fix forward; never publish a second version with the same number
81
88
  - **Test on publish / in production, if applicable — always** (step 7)
89
+ - **Never diagnose a gate from a session that predates the release** — confirm the loaded build first (step 7)