pi-aia-asf 0.17.11 → 0.17.13

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,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.13] - 2026-11-10
11
+
12
+ ### Changed — no scale means small (PO policy)
13
+
14
+ PO decision: "If no scale, assume small." `currentScale()` used to judge a run with no
15
+ recorded scale as **standard**, unless an earlier `/asf small` in the same process had
16
+ flipped a global flag. That meant two things. First, the same unscaled run got a
17
+ different verdict depending on what a previous run had done. Second, the run card
18
+ printed the raw field ("unset", or "small") while the gate demanded standard's
19
+ `/asf health` and full suite. The stuck sessions this produced were unreadable from the
20
+ card alone.
21
+
22
+ - `currentScale()` now returns small for an unset scale. Explicit `large` still judges
23
+ major and explicit `standard` still judges standard, so the change cannot widen.
24
+ - The default in `sessionFacts` and `decideSettlement` is small too, so a caller that
25
+ forgets to pass a scale gets the floor rather than a ceremony nobody chose. The floor
26
+ itself still holds: changed source still needs a real test run.
27
+ - The run card, `/asf status` and `/asf verify` print the scale the gate judges by.
28
+ Unset reads "unset — judged small", never a bare "unset".
29
+ - `runtime.lastScaleWasSmall` and `markSmallScale()` are deleted. With small as the
30
+ default the flag decided nothing, and a field that reads as load-bearing but is not
31
+ is how this disagreement started.
32
+
33
+ The reachable scale-less path is pinned: `/asf verify` calls `setPhase` without a scale,
34
+ and `setPhase` only writes the field when given one. `test/test-scale-policy.mjs` covers
35
+ that path end to end, along with the judged scale, settlement and the card. Five
36
+ mutations, one per layer, are each caught.
37
+
38
+ ## [0.17.12] - 2026-11-10
39
+
40
+ ### Fixed — the health gate measured the wrong project
41
+
42
+ `runRoot()` is the git root of the session's cwd. That is right for where run state
43
+ lives and wrong for what to measure, and a session launched from a home directory that
44
+ is itself a git repo (common: `pi` started from $HOME with checkouts underneath) had
45
+ every health gate measure the parent. It did: `asf_health` reported 363 model findings
46
+ from an unrelated project checked out under $HOME, and eslint, madge and jscpd each
47
+ timed out at 120s scanning a tree nobody was working in — a verdict of NOT healthy on a
48
+ repository that is, in fact, healthy.
49
+
50
+ A gate that measures the wrong directory is worse than no gate: it blocks on findings
51
+ in code the run must not touch, or waves through the one project that is actually
52
+ unhealthy. `measureRoot()` now answers from what the run already knows — the git root of
53
+ the most recently touched file — and is used by the three paths that measure
54
+ (`asf_health`, `/asf health`, `/asf verify` Gate 8). It is deliberately **not** used to
55
+ locate state: a root that moved mid-run would orphan the run's own ledger, so
56
+ `runRoot()` still follows the session for that.
57
+
58
+ A touched path outside the workspace (a scratch dir, an unrelated checkout) does not
59
+ move the measurement either — the session root is the safe answer. `test/test-measure-root.mjs`
60
+ pins both on throwaway repos rather than this machine's $HOME, and both guards are
61
+ mutation-caught.
62
+
10
63
  ## [0.17.11] - 2026-11-10
11
64
 
12
65
  ### Added — the coverage invariant is checkable from the installed package
@@ -9,7 +9,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
9
9
  import { setPhase, runApprove } from "./session.ts";
10
10
  import { cmdVerify } from "./verify.ts";
11
11
  import { cmdStatus, cmdHealth, cmdRules, cmdConfig, cmdReport, cmdGates, cmdCard } from "./inspect.ts";
12
- import { cmdPassive, cmdAbort, helpText, markSmallScale } from "./run-control.ts";
12
+ import { cmdPassive, cmdAbort, helpText } from "./run-control.ts";
13
13
 
14
14
  type Command = (args: string[], ctx: ExtensionCommandContext) => Promise<string> | string;
15
15
 
@@ -18,10 +18,7 @@ const COMMANDS: Record<string, Command> = {
18
18
  feature: (_a, ctx) => setPhase(ctx, { phase: "intake", workType: "feature", scale: "large" }),
19
19
  bugfix: (_a, ctx) => setPhase(ctx, { phase: "intake", workType: "major-bugfix", scale: "large" }),
20
20
  refactor: (_a, ctx) => setPhase(ctx, { phase: "intake", workType: "refactor", scale: "large" }),
21
- small: (_a, ctx) => {
22
- markSmallScale();
23
- return setPhase(ctx, { phase: "implementation", scale: "small" });
24
- },
21
+ small: (_a, ctx) => setPhase(ctx, { phase: "implementation", scale: "small" }),
25
22
  status: () => cmdStatus(),
26
23
  verify: (_a, ctx) => cmdVerify(ctx),
27
24
  health: (a) => cmdHealth(a),
@@ -7,7 +7,7 @@ import { projectName, runRoot } from "../paths.ts";
7
7
  import { loadState } from "../state.ts";
8
8
  import { ledger, authority, gateStats } from "../gate-state.ts";
9
9
  import { runtime } from "../runtime.ts";
10
- import { cardFor, collectMeasurements, extensionDir } from "../run.ts";
10
+ import { cardFor, collectMeasurements, currentScale, extensionDir, measureRoot } from "../run.ts";
11
11
  import { renderLedger } from "../../gates/ledger.mjs";
12
12
  import { renderAuthority } from "../../gates/authority.mjs";
13
13
  import { renderCompletionGate } from "../../gates/settle.mjs";
@@ -22,12 +22,12 @@ export async function cmdStatus(): Promise<string> {
22
22
  if (!state.current) return "No active ASF session.";
23
23
  const cur = state.current;
24
24
  const floor =
25
- cur.scale === "small" ? " (floor: a real test run and evidence; no plan/approval gates)" : "";
25
+ currentScale(cur) === "small" ? " (floor: a real test run and evidence; no plan/approval gates)" : "";
26
26
  const auto = cur.auto ? " — opened automatically by software work" : "";
27
27
  return (
28
28
  `ASF status (${project}):\n` +
29
29
  ` work type: ${cur.workType || "unset"}\n` +
30
- ` scale: ${cur.scale || "unset"}${floor}${auto}\n` +
30
+ ` scale: ${cur.scale ? cur.scale : `unset — judged ${currentScale(cur)}`}${floor}${auto}\n` +
31
31
  ` phase: ${cur.phase}\n` +
32
32
  ` plan approved: ${cur.planApproved ? "yes" : "no"}\n` +
33
33
  (cur.passiveRunId && cur.passiveRunId === cur.runId ? ` passive: this run only\n` : "") +
@@ -41,7 +41,7 @@ export async function cmdStatus(): Promise<string> {
41
41
  export async function cmdHealth(args: string[]): Promise<string> {
42
42
  const { runHealth } = await import("../../code-health/index.mjs");
43
43
  const { recordHealthVerdict, recordLine } = await import("../health-record.ts");
44
- const res = await runHealth(runRoot(), args.slice(1));
44
+ const res = await runHealth(await measureRoot(), args.slice(1));
45
45
  // The command records what it measured. Before this, only the asf_health TOOL
46
46
  // recorded: a human could run /asf health, read HEALTHY, and leave the
47
47
  // completion gate's health gap open with no trace that anything was measured.
@@ -10,7 +10,6 @@ import { newRunId } from "../run.ts";
10
10
  import { dependencySummary } from "../deps.ts";
11
11
  import { setPhase } from "./session.ts";
12
12
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
- import { runtime } from "../runtime.ts";
14
13
 
15
14
  export async function cmdPassive(): Promise<string> {
16
15
  const project = projectName();
@@ -52,7 +51,3 @@ export function helpText(): string {
52
51
  );
53
52
  }
54
53
 
55
- /** `small` is a scale the next run inherits; recorded here, not in the switch. */
56
- export function markSmallScale(): void {
57
- runtime.lastScaleWasSmall = true;
58
- }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { projectName, runRoot } from "../paths.ts";
9
+ import { measureRoot } from "../run.ts";
9
10
  import { loadState } from "../state.ts";
10
11
  import { loadProjectSpecs, validateTrace } from "../specs.ts";
11
12
  import { QA_CHECKLIST, type SpecItem } from "../types.ts";
@@ -56,7 +57,7 @@ async function traceabilitySection(): Promise<string[]> {
56
57
  async function healthSection(): Promise<string[]> {
57
58
  const { runHealth } = await import("../../code-health/index.mjs");
58
59
  const { recordHealthVerdict, recordLine } = await import("../health-record.ts");
59
- const health = await runHealth(runRoot(), []);
60
+ const health = await runHealth(await measureRoot(), []);
60
61
  // Same rule as the tool and the slash command: a measurement that is produced
61
62
  // is a measurement that is recorded.
62
63
  const rec = recordHealthVerdict(health, { by: "verify" });
@@ -88,10 +89,9 @@ function scaleLine(scale: string | undefined): string {
88
89
  const suffix =
89
90
  scale === "large"
90
91
  ? " — mechanical traceability gate ACTIVE"
91
- : scale === "small"
92
- ? " (floor only — no mechanical traceability gate)"
93
- : "";
94
- return `Scale: ${scale || "unset"}${suffix}`;
92
+ : " (floor only — no mechanical traceability gate)";
93
+ // Unset is judged small (PO policy); say so instead of printing a bare "unset".
94
+ return `Scale: ${scale || "unset — judged small"}${suffix}`;
95
95
  }
96
96
 
97
97
  export async function cmdVerify(ctx: ExtensionCommandContext): Promise<string> {
@@ -4,16 +4,16 @@
4
4
  */
5
5
 
6
6
  import { execFileSync } from "node:child_process";
7
+ import { runRoot, projectName } from "./paths.ts";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { dirname } from "node:path";
9
- import { runRoot } from "./paths.ts";
10
+ import { projectRoot as projectRootOf } from "../project.mjs";
10
11
  import { ensureHome } from "../home.mjs";
11
12
  import { cpus, loadavg } from "node:os";
12
13
  import { renderRunCard, cardSummary, CARD_CEILING_BYTES } from "../run-card.mjs";
13
14
  import { ledger, suiteRuns, gateStats } from "./gate-state.ts";
14
15
  import { appendEvidence } from "./evidence.ts";
15
16
  import { loadState, saveState } from "./state.ts";
16
- import { projectName } from "./paths.ts";
17
17
  import { runtime } from "./runtime.ts";
18
18
  import { __resetRefusals } from "../gates/refusals.mjs";
19
19
  import { cardIndex, registerAt } from "../decisions/consult.mjs";
@@ -62,7 +62,10 @@ export function cardFor(state: AsfState | null, nextStep?: string, remedy?: stri
62
62
  : `advance the ${state.phase} phase`);
63
63
  const card = renderRunCard({
64
64
  phase: state.phase,
65
- scale: state.scale,
65
+ // The scale the gate judges by, not the raw field: an unset scale is judged
66
+ // small, and a card that printed "unset" (or the raw value) while the gate held
67
+ // the run to another scale is how a stuck run became unreadable.
68
+ scale: currentScale(state),
66
69
  workType: state.workType,
67
70
  objective: state.objective,
68
71
  openDod,
@@ -118,6 +121,43 @@ export async function openAutoRun(trigger: string, path: string | null): Promise
118
121
  return state;
119
122
  }
120
123
 
124
+ /**
125
+ * The project the gates should MEASURE.
126
+ *
127
+ * `runRoot()` is the git root of the session's cwd, which is right for where state
128
+ * lives and wrong for what to measure: a session started in a parent repository
129
+ * (a home directory that is itself a git repo is the common case) would have every
130
+ * gate measure that parent. It did — `asf_health` reported 363 model findings from an
131
+ * unrelated project checked out underneath $HOME, and every measurement tool then
132
+ * timed out scanning a tree nobody was working in. A gate that measures the wrong
133
+ * directory is worse than no gate: it blocks on findings in code the run must not
134
+ * touch, or waves through the one project that is actually unhealthy.
135
+ *
136
+ * The run already knows what it changed, so the answer is taken from there: the git
137
+ * root of the most recently touched file wins. It is deliberately NOT used to locate
138
+ * state — a root that moved mid-run would orphan the run's own ledger — only to
139
+ * decide what a measurement looks at.
140
+ */
141
+ export async function measureRoot(): Promise<string> {
142
+ const fallback = runRoot();
143
+ try {
144
+ const state = await loadState(projectName());
145
+ const touched = (state?.current?.filesTouched || []) as string[];
146
+ for (let i = touched.length - 1; i >= 0; i--) {
147
+ const f = touched[i];
148
+ if (!f || !f.includes("/")) continue;
149
+ const root = projectRootOf(dirname(f));
150
+ // Only a root that CONTAINS the session root counts as a better answer: a
151
+ // stray touched path outside the workspace must not drag the measurement to
152
+ // someone's scratch directory.
153
+ if (root && (root === fallback || root.startsWith(fallback + "/"))) return root;
154
+ }
155
+ } catch {
156
+ /* measurement falls back to the session root; a broken guess is worse than none */
157
+ }
158
+ return fallback;
159
+ }
160
+
121
161
  /** Where this extension is installed — the declaration file ships beside it. */
122
162
 
123
163
  export function extensionDir(): string {
@@ -166,10 +206,24 @@ export function numberConfig(config: Record<string, unknown>, key: string, fallb
166
206
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
167
207
  }
168
208
 
169
- /** Small work is held to the reduced DoD — the scale rule is the trigger here. */
209
+ /**
210
+ * The scale every gate judges a run by.
211
+ *
212
+ * PO policy: "If no scale, assume small." A run nobody scaled — auto-opened, or one
213
+ * whose scale was never written (`/asf verify` calls setPhase without one) — is held
214
+ * to the small floor: a real test run and evidence, not the health audit and full
215
+ * suite that standard demands. Only an explicit reading widens it. This used to fall
216
+ * back to standard unless an earlier `/asf small` had flipped a process-wide flag, so
217
+ * the same unscaled run was judged differently depending on what an earlier run in
218
+ * the same process had done — and the card, which printed the raw field, said "small"
219
+ * while the gate demanded standard's ceremony.
220
+ */
170
221
  export function currentScale(state?: AsfState | null): "small" | "standard" | "major" {
171
- if (state?.scale === "small") return "small";
172
- if (state?.scale === "large") return "major";
173
- return runtime.lastScaleWasSmall ? "small" : "standard";
222
+ // Compared as strings: "standard" is not an AsfScale today, but a state file may
223
+ // carry it (hand-edited, or written by a newer ASF), and it must keep its meaning.
224
+ const raw = String(state?.scale ?? "");
225
+ if (raw === "large") return "major";
226
+ if (raw === "standard") return "standard";
227
+ return "small";
174
228
  }
175
229
 
@@ -39,7 +39,6 @@ export interface RuntimeState {
39
39
  */
40
40
  lastMutationAt: number;
41
41
  passiveHeartbeatWritten: boolean;
42
- lastScaleWasSmall: boolean;
43
42
  /** Re-anchoring is count-limited per run. */
44
43
  reanchorsThisRun: number;
45
44
  /**
@@ -81,7 +80,6 @@ export const runtime: RuntimeState = {
81
80
  blindNudged: false,
82
81
  lastMutationAt: 0,
83
82
  passiveHeartbeatWritten: false,
84
- lastScaleWasSmall: false,
85
83
  reanchorsThisRun: 0,
86
84
  tracked: createTracked(),
87
85
  decisionsRead: [],
@@ -27,12 +27,12 @@ interface HealthResult {
27
27
 
28
28
  async function runHealthGate(scope: string): Promise<HealthResult> {
29
29
  const { runHealth } = await import("../../code-health/index.mjs");
30
- const { runRoot } = await import("../paths.ts");
30
+ const { measureRoot } = await import("../run.ts");
31
31
  const args = scope ? [`--scope=${scope}`] : [];
32
- // Measure the RUN's project, not process.cwd(): a session started in $HOME
33
- // (or any parent) would otherwise measure everything under it — 960 phantom
34
- // findings from site-packages, on a repo that is actually healthy.
35
- return (await runHealth(runRoot(), args)) as HealthResult;
32
+ // Measure the project the RUN is about, not process.cwd() and not the session
33
+ // cwd's git root: a session started in $HOME (which is itself a repo here) would
34
+ // otherwise measure every checkout underneath it.
35
+ return (await runHealth(await measureRoot(), args)) as HealthResult;
36
36
  }
37
37
 
38
38
  /**
@@ -128,7 +128,9 @@ export function createLedger() {
128
128
  * The four session facts P41 checks. A spec says what the deliverable must
129
129
  * have; only the harness knows what actually happened this session.
130
130
  */
131
- export function sessionFacts(ledger, { scale = "standard", webTouched = false } = {}) {
131
+ // No scale means small (PO policy) — at this layer too, so a caller that forgets to
132
+ // pass one gets the floor rather than a ceremony nobody chose.
133
+ export function sessionFacts(ledger, { scale = "small", webTouched = false } = {}) {
132
134
  const missing = [];
133
135
 
134
136
  // `/asf health` is a delivery-time check for standard/major runs. An
@@ -211,7 +211,7 @@ function collectGaps(ledger, opts) {
211
211
 
212
212
  export function decideSettlement(ledger, opts = {}) {
213
213
  const { webTouched = false, continuationsSoFar = 0, seenSteers = [] } = opts;
214
- const gaps = collectGaps(ledger, { scale: "standard", webTouched, untracedMet: [], reportFields: [], ...opts });
214
+ const gaps = collectGaps(ledger, { scale: "small", webTouched, untracedMet: [], reportFields: [], ...opts });
215
215
 
216
216
  const split = splitGaps(gaps);
217
217
  const missing = split.blocking;
package/lib/run-card.mjs CHANGED
@@ -30,7 +30,7 @@ function clip(s, n) {
30
30
  /**
31
31
  * @param {object} o
32
32
  * @param {string} o.phase the run's current phase
33
- * @param {string} o.scale small | large
33
+ * @param {string} o.scale the JUDGED scale: small | standard | major
34
34
  * @param {string} [o.workType]
35
35
  * @param {string} [o.objective] one line: what this run is for
36
36
  * @param {string[]} [o.openDod] DoD lines still unticked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.17.11",
3
+ "version": "0.17.13",
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",