pi-aia-asf 0.17.7 → 0.17.9

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,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.9] - 2026-11-10
11
+
12
+ ### Fixed — the health verdict now reports what it measured, not what it planned
13
+
14
+ `asf_health` recorded `scanned` as `rows.length`, and `rows` contains every metric
15
+ the gate **planned** — including the ones that never ran because a tool was not
16
+ installed. Three things followed, all reported from the same observation:
17
+
18
+ - the ledger's `scanned 0` rule ("examined nothing, not a pass") was unreachable
19
+ while any metric was planned, so a run that measured nothing was recorded as a
20
+ nine-metric audit;
21
+ - the tool's own summary line contradicted the report printed directly above it —
22
+ `NOT healthy — 8 metric(s) skipped` followed by `9 metric(s) measured`;
23
+ - the honest number already existed and was thrown away: `verdict()` returns
24
+ `ran`, counting pass+fail and deliberately **excluding** error rows, but
25
+ `runHealth()` never returned it.
26
+
27
+ `runHealth()` now returns `ran` and `skipped`, and the recorded count comes from
28
+ them. Counting `status !== "skipped"` was the tempting fix and is wrong: it counts
29
+ errored rows as measured, which puts the report, the ledger and the summary line
30
+ back out of agreement — the exact disagreement being fixed. A metric that does not
31
+ apply (no persistence in the project) is neither ran nor skipped.
32
+
33
+ ### Changed — every path that produces a health verdict records it
34
+
35
+ `/asf health` and the Gate 8 section of `/asf verify` printed the measurement and
36
+ left no trace, so a human could run the command, read HEALTHY, and leave the
37
+ completion gate's health gap open. Recording was the `asf_health` tool's private
38
+ privilege; it is now shared, with `by:` saying who produced the verdict
39
+ (`tool` / `command` / `verify`). The counting rules and the summary wording moved
40
+ to `lib/code-health/measure.mjs` — pure, dependency-free, and tested without a
41
+ harness — so the three callers cannot drift into contradicting each other again.
42
+
43
+ A verdict over zero metrics says so out loud: *"Nothing was actually measured — a
44
+ verdict over zero metrics is not a pass, and installing the missing tools is what
45
+ closes this properly."*
46
+
47
+
48
+ ## [0.17.8] - 2026-11-09
49
+
50
+ ### Added — the tracked-work vocabulary is the project's to widen
51
+
52
+ `gate.tracked.claim` / `.resolve` / `.park` / `.wait` in `.asf/config.json` add patterns
53
+ (string or array) to the built-in matcher set. They **widen, never replace**: a project
54
+ that typos `claim` would otherwise stop tracking its own claims, and a floor that a
55
+ stray character in a config file can switch off is not a floor. Non-string entries are
56
+ ignored rather than compiled in.
57
+
58
+ `lib/gates/tracked.mjs` now says plainly what it is — **a tripwire over tool names and
59
+ the agent's own prose, not an authority over ticket state**. It cannot see a tracker's
60
+ database and its vocabulary is a small illustrative set; a tool named `grabWorkItem` is
61
+ invisible until the project says so. The asymmetry is deliberate: a miss costs a run
62
+ that settles early, a false alarm costs one steer.
63
+
64
+ ### Fixed — a matcher that matched everything
65
+
66
+ `matchersFromConfig()` built the wait matcher as `alternatives + "|" + extras` even when
67
+ `extras` was empty, leaving a trailing `|`. An empty alternative matches the empty
68
+ string, so **every progress message in every project would have been read as a
69
+ confessed wait** — a gate that trips on everything is as useless as one that trips on
70
+ nothing, and much harder to notice. Every union is now guarded by a length check, and a
71
+ test pins that no matcher matches `""`, `"anything at all"`, or a config entry of
72
+ `""`/`undefined`/`null`. Caught by the suite that checks a benign progress message is
73
+ not a wait, and mutation-verified: restoring the dangling `|` fails two checks.
74
+
10
75
  ## [0.17.7] - 2026-11-09
11
76
 
12
77
  ### Fixed — "waiting" is now illegal as a disposition
@@ -35,7 +35,7 @@ export async function runHealth(projectRoot, args = [], deps = {}) {
35
35
  const config = await loadConfig(projectRoot, deps.readFile);
36
36
  if (!config.enabled) {
37
37
  const text = "Code Health Gate: disabled (enabled: false).";
38
- return { text, json: { enabled: false }, exitCode: 0, healthy: true, rows: [], config };
38
+ return { text, json: { enabled: false }, exitCode: 0, healthy: true, rows: [], ran: 0, skipped: 0, config };
39
39
  }
40
40
 
41
41
  const files = listSourceFiles(projectRoot, config.ignore);
@@ -50,29 +50,37 @@ export async function runHealth(projectRoot, args = [], deps = {}) {
50
50
  const gateBlocks = config.gate.mode === "block" && config.gate.scope !== "off";
51
51
  const exitCode = gateBlocks && !v.healthy ? 1 : 0;
52
52
 
53
- let text = formatReport(rows, config);
53
+ const text = applyBaselineFlags({ text: formatReport(rows, config), rows, root: projectRoot, flags });
54
54
 
55
+ const json = flags.json ? jsonReport(rows, config) : null;
56
+ // `ran` and `skipped` are verdict()'s own counts, and they leave here because
57
+ // they are the ONLY honest answer to "how much was actually measured" — see
58
+ // lib/code-health/measure.mjs. `rows.length` counts metrics PLANNED.
59
+ return { text, json, exitCode, healthy: v.healthy, rows, ran: v.ran, skipped: v.skipped, config };
60
+ }
61
+
62
+ /**
63
+ * `--update-baseline` and `--diff`, factored out of `runHealth` so the gate's
64
+ * main path stays readable and the metric accounting stays in one place.
65
+ */
66
+ function applyBaselineFlags({ text, rows, root, flags }) {
55
67
  if (flags.updateBaseline) {
56
68
  const payload = baselinePayload(rows);
57
- writeFileSync(join(projectRoot, BASELINE_FILE), JSON.stringify(payload, null, 2) + "\n", "utf-8");
58
- text += `\n\nBaseline updated: ${BASELINE_FILE}`;
59
- } else if (flags.diff) {
60
- const baselinePath = join(projectRoot, BASELINE_FILE);
61
- let baselineRows = [];
62
- if (existsSync(baselinePath)) {
63
- try {
64
- const base = JSON.parse(readFileSync(baselinePath, "utf-8"));
65
- baselineRows = Object.entries(base.metrics || {}).map(([key, m]) => ({ key, value: m.value, status: m.status }));
66
- } catch {
67
- /* unreadable baseline → no diff */
68
- }
69
+ writeFileSync(join(root, BASELINE_FILE), JSON.stringify(payload, null, 2) + "\n", "utf-8");
70
+ return `${text}\n\nBaseline updated: ${BASELINE_FILE}`;
71
+ }
72
+ if (!flags.diff) return text;
73
+ const baselinePath = join(root, BASELINE_FILE);
74
+ let baselineRows = [];
75
+ if (existsSync(baselinePath)) {
76
+ try {
77
+ const base = JSON.parse(readFileSync(baselinePath, "utf-8"));
78
+ baselineRows = Object.entries(base.metrics || {}).map(([key, m]) => ({ key, value: m.value, status: m.status }));
79
+ } catch {
80
+ /* unreadable baseline → no diff */
69
81
  }
70
- const changes = diffRows(rows, baselineRows);
71
- text += "\n\n" + formatDiff(changes);
72
82
  }
73
-
74
- const json = flags.json ? jsonReport(rows, config) : null;
75
- return { text, json, exitCode, healthy: v.healthy, rows, config };
83
+ return `${text}\n\n${formatDiff(diffRows(rows, baselineRows))}`;
76
84
  }
77
85
 
78
86
  export { BASELINE_FILE };
@@ -0,0 +1,25 @@
1
+ export interface HealthVerdictSource {
2
+ healthy?: boolean;
3
+ exitCode?: number;
4
+ ran?: number;
5
+ skipped?: number;
6
+ }
7
+
8
+ export interface HealthRecord {
9
+ verdict: string;
10
+ scanned: number;
11
+ skipped: number;
12
+ level: string;
13
+ recorded: boolean;
14
+ error: string;
15
+ }
16
+
17
+ export function measuredMetrics(res: HealthVerdictSource | null | undefined): number;
18
+ export function skippedMetrics(res: HealthVerdictSource | null | undefined): number;
19
+ export function verdictOf(res: HealthVerdictSource | null | undefined): string;
20
+ export function measureSummary(res: HealthVerdictSource | null | undefined, level: string): string;
21
+ export function healthRunEntry(
22
+ res: HealthVerdictSource | null | undefined,
23
+ level: string,
24
+ ): { kind: "health-run"; level: string; verdict: string; scanned: number };
25
+ export function recordLine(rec: HealthRecord): string;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * What a health verdict actually measured — the pure half.
3
+ *
4
+ * `scanned` is the number three readers must agree on: the ledger's
5
+ * "examined nothing, not a pass" rule, the report's own `HEALTHY — N metric(s)
6
+ * checked, M skipped` line, and the summary line the agent reads after the
7
+ * report. It used to be `rows.length`, and `rows` counts every metric PLANNED —
8
+ * including the ones that never ran because a tool was missing. So a project
9
+ * where eight of nine metrics could not run was recorded as a nine-metric
10
+ * audit, the ledger's zero-rule became unreachable, and the tool's summary
11
+ * contradicted the report printed directly above it.
12
+ *
13
+ * The honest number already existed: `verdict()` returns `ran`, which counts
14
+ * pass+fail and deliberately EXCLUDES error rows. Counting `status !==
15
+ * "skipped"` instead would count errors as measured and put the three readers
16
+ * back out of agreement — which is the bug this file exists to prevent.
17
+ *
18
+ * Pure and dependency-free on purpose: the recording glue lives beside the
19
+ * ledger, and this is what the tests can pin without a harness.
20
+ */
21
+
22
+ /**
23
+ * Metrics actually measured. Skipped, disabled and error rows are NOT counted.
24
+ *
25
+ * Both spellings are accepted because the two objects that travel through this
26
+ * file use different names for the same fact: the gate's verdict says `ran`,
27
+ * the recorded ledger entry says `scanned`. A summary that read only one of them
28
+ * would print "0 metric(s) measured" over an eight-metric audit — which is the
29
+ * exact contradiction this module exists to prevent, so it is read from
30
+ * whichever field the caller has.
31
+ */
32
+ export function measuredMetrics(res) {
33
+ if (Number.isFinite(res?.ran)) return Number(res.ran);
34
+ if (Number.isFinite(res?.scanned)) return Number(res.scanned);
35
+ return 0;
36
+ }
37
+
38
+ /** Metrics that were planned but never ran. */
39
+ export function skippedMetrics(res) {
40
+ return Number.isFinite(res?.skipped) ? Number(res.skipped) : 0;
41
+ }
42
+
43
+ /** pass/fail/warn — the same reading the exit code and the report use. */
44
+ export function verdictOf(res) {
45
+ if (res?.exitCode === 1) return "fail";
46
+ return res?.healthy ? "pass" : "warn";
47
+ }
48
+
49
+ /** The one wording, so no caller can invent a contradictory one. */
50
+ export function measureSummary(res, level) {
51
+ const skipped = skippedMetrics(res);
52
+ return `${measuredMetrics(res)} metric(s) measured${skipped ? `, ${skipped} skipped` : ""}, level ${level}`;
53
+ }
54
+
55
+ /** The ledger entry: `scanned` is what ran, never what was planned. */
56
+ export function healthRunEntry(res, level) {
57
+ return { kind: "health-run", level, verdict: verdictOf(res), scanned: measuredMetrics(res) };
58
+ }
59
+
60
+ /**
61
+ * What the reader is told after a recording.
62
+ *
63
+ * A verdict over zero metrics is spelled out as not-a-pass: the failure mode
64
+ * this guards against is an agent (or a human) reading a green-looking line that
65
+ * summarised a measurement of nothing at all.
66
+ */
67
+ export function recordLine(rec) {
68
+ const recorded = rec.recorded
69
+ ? "recorded as evidence; the completion gate's health gap is now closed."
70
+ : `NOT recorded (${rec.error})`;
71
+ const nothing = rec.scanned === 0
72
+ ? " Nothing was actually measured — a verdict over zero metrics is not a pass, " +
73
+ "and installing the missing tools is what closes this properly."
74
+ : "";
75
+ return `verdict: ${rec.verdict} (${measureSummary(rec, rec.level)}) — ${recorded}${nothing}`;
76
+ }
@@ -40,14 +40,19 @@ export async function cmdStatus(): Promise<string> {
40
40
  /** Code Health Gate: objective measurement at function / module / architecture level. */
41
41
  export async function cmdHealth(args: string[]): Promise<string> {
42
42
  const { runHealth } = await import("../../code-health/index.mjs");
43
+ const { recordHealthVerdict, recordLine } = await import("../health-record.ts");
43
44
  const res = await runHealth(runRoot(), args.slice(1));
45
+ // The command records what it measured. Before this, only the asf_health TOOL
46
+ // recorded: a human could run /asf health, read HEALTHY, and leave the
47
+ // completion gate's health gap open with no trace that anything was measured.
48
+ const rec = recordHealthVerdict(res, { by: "command" });
44
49
  const tail =
45
50
  res.exitCode === 1
46
51
  ? "\n\n✗ GATE FAILED — resolve the violations before delivery."
47
52
  : res.config?.gate?.mode === "warn" && !res.healthy
48
53
  ? "\n\n(warn mode: violations reported, gate not blocking)"
49
54
  : "";
50
- return res.text + tail;
55
+ return `${res.text}${tail}\n\n${recordLine(rec)}`;
51
56
  }
52
57
 
53
58
  /** "What enforces anything around browser testing?" — answered from the registry. */
@@ -55,8 +55,12 @@ async function traceabilitySection(): Promise<string[]> {
55
55
  /** Gate 8 — objective convolution measurement, on the real working tree. */
56
56
  async function healthSection(): Promise<string[]> {
57
57
  const { runHealth } = await import("../../code-health/index.mjs");
58
+ const { recordHealthVerdict, recordLine } = await import("../health-record.ts");
58
59
  const health = await runHealth(runRoot(), []);
59
- const lines = ["\nCODE HEALTH (Gate 8 — large work):", health.text];
60
+ // Same rule as the tool and the slash command: a measurement that is produced
61
+ // is a measurement that is recorded.
62
+ const rec = recordHealthVerdict(health, { by: "verify" });
63
+ const lines = ["\nCODE HEALTH (Gate 8 — large work):", health.text, recordLine(rec)];
60
64
  if (health.exitCode === 1) {
61
65
  lines.push("\n ✗ GATE NOT PASSED — resolve code-health violations before delivery.");
62
66
  } else if (!health.healthy && health.config?.gate?.mode === "warn") {
@@ -17,7 +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 } from "../../gates/tracked.mjs";
20
+ import { noteTrackedCall, matchersFromConfig } from "../../gates/tracked.mjs";
21
21
  import { readProjectConfig } from "../../gates/config.mjs";
22
22
  import { projectName, runRoot } from "../paths.ts";
23
23
  import { loadState, saveState } from "../state.ts";
@@ -112,8 +112,8 @@ function observeWebTouch(input: Input): void {
112
112
  * about it instead. Claim / resolve / park are read off the tool's name; a
113
113
  * narrated wait is read off the text the agent wrote for the reviewer.
114
114
  */
115
- function observeTrackedWork(toolName: string, input: Input): void {
116
- const kind = noteTrackedCall(runtime.tracked, { toolName, input });
115
+ function observeTrackedWork(toolName: string, input: Input, config: Record<string, unknown>): void {
116
+ const kind = noteTrackedCall(runtime.tracked, { toolName, input, matchers: matchersFromConfig(config) });
117
117
  if (!kind) return;
118
118
  appendEvidence({ kind: "tracked", action: kind, tool: toolName, item: String(input.ticket_id || input.ticketId || input.id || "") || null });
119
119
  if (kind === "wait") {
@@ -223,6 +223,9 @@ export function registerToolCallHandler(pi: ExtensionAPI): void {
223
223
  const ev = event as Event;
224
224
  const toolName = String(ev.toolName || "");
225
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());
226
229
 
227
230
  // An observer failure here must not block the work, so it is fenced.
228
231
  try {
@@ -232,9 +235,8 @@ export function registerToolCallHandler(pi: ExtensionAPI): void {
232
235
  }
233
236
  observeSuiteAttempt(input, ev);
234
237
  observeWebTouch(input);
235
- observeTrackedWork(toolName, input);
238
+ observeTrackedWork(toolName, input, config);
236
239
 
237
- const config = readProjectConfig(runRoot());
238
240
  const res = evaluateBlockers(toolName, input, {
239
241
  ctx: {
240
242
  suiteRuns,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Recording a health verdict — one definition, three callers.
3
+ *
4
+ * The completion gate asks for a recorded health verdict. It used to be that
5
+ * only the `asf_health` tool recorded one, while `/asf health` and `/asf verify`
6
+ * printed the same measurement and left no trace: a human could run the command,
7
+ * see HEALTHY, and the gate would stay open with nothing to show for it. Either
8
+ * a measurement is evidence or it is not — so every path that produces the
9
+ * verdict records it, and `by:` says who produced it.
10
+ *
11
+ * The counting rules and the wording are in `lib/code-health/measure.mjs`
12
+ * (pure, tested without a harness); this file only supplies the ledger, the
13
+ * evidence trail and the run's scale.
14
+ */
15
+
16
+ import { ledger } from "./gate-state.ts";
17
+ import { appendEvidence } from "./evidence.ts";
18
+ import { currentScale } from "./run.ts";
19
+ import { healthRunEntry, measuredMetrics, skippedMetrics } from "../code-health/measure.mjs";
20
+ import type { HealthVerdictSource, HealthRecord } from "../code-health/measure.d.mts";
21
+
22
+ export type { HealthVerdictSource, HealthRecord } from "../code-health/measure.d.mts";
23
+ export { measuredMetrics, measureSummary, recordLine } from "../code-health/measure.mjs";
24
+
25
+ const LEVELS = ["small", "standard", "major"];
26
+
27
+ /**
28
+ * Append the verdict to the ledger and the evidence trail.
29
+ *
30
+ * `level` falls back to the run's scale, so a bare `/asf health` still records
31
+ * at a level the gate can judge.
32
+ */
33
+ export function recordHealthVerdict(res: HealthVerdictSource | null | undefined, opts: { by: string; level?: string }): HealthRecord {
34
+ const level = LEVELS.includes(String(opts?.level)) ? String(opts.level) : currentScale();
35
+ const entry = healthRunEntry(res, level);
36
+ const appended = ledger.append(entry);
37
+ appendEvidence({ ...entry, by: opts.by });
38
+ return {
39
+ verdict: entry.verdict,
40
+ scanned: measuredMetrics(res as { ran?: number }),
41
+ skipped: skippedMetrics(res),
42
+ level,
43
+ recorded: !!appended.ok,
44
+ error: appended.ok ? "" : String(appended.error),
45
+ };
46
+ }
@@ -12,15 +12,17 @@
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { StringEnum } from "@earendil-works/pi-ai";
14
14
  import { Type } from "typebox";
15
- import { ledger } from "../gate-state.ts";
16
- import { appendEvidence } from "../evidence.ts";
17
15
  import { currentScale } from "../run.ts";
16
+ import { recordHealthVerdict, recordLine } from "../health-record.ts";
18
17
 
19
18
  interface HealthResult {
20
19
  text: string;
21
20
  healthy: boolean;
22
21
  exitCode: number;
23
22
  rows?: unknown[];
23
+ /** Metrics actually measured (pass+fail). Skipped and error rows are NOT in it. */
24
+ ran?: number;
25
+ skipped?: number;
24
26
  }
25
27
 
26
28
  async function runHealthGate(scope: string): Promise<HealthResult> {
@@ -33,20 +35,18 @@ async function runHealthGate(scope: string): Promise<HealthResult> {
33
35
  return (await runHealth(runRoot(), args)) as HealthResult;
34
36
  }
35
37
 
36
- /** A recorded verdict is the point: measuring without recording clears nothing. */
38
+ /**
39
+ * A recorded verdict is the point: measuring without recording clears nothing.
40
+ * The counting rules and the wording live in health-record.ts so the tool, the
41
+ * slash command and `/asf verify` cannot drift into disagreeing with each other.
42
+ */
37
43
  async function measureAndRecord(levelIn: string, scopeIn: string) {
38
44
  const res = await runHealthGate(scopeIn);
39
- const verdict = res.exitCode === 1 ? "fail" : res.healthy ? "pass" : "warn";
40
- const scanned = Array.isArray(res.rows) ? res.rows.length : 0;
41
45
  const level: string = ["small", "standard", "major"].includes(levelIn) ? levelIn : currentScale();
42
- const appended = ledger.append({ kind: "health-run", level, verdict, scanned });
43
- appendEvidence({ kind: "health-run", level, verdict, scanned, by: "tool" });
44
- const recorded = appended.ok
45
- ? "recorded as evidence; the completion gate's health gap is now closed."
46
- : `NOT recorded (${appended.error})`;
46
+ const rec = recordHealthVerdict(res, { by: "tool", level });
47
47
  return {
48
- text: `${res.text}\n\nverdict: ${verdict} (${scanned} metric(s) measured, level ${level}) — ${recorded}`,
49
- details: { verdict, scanned, level, recorded: appended.ok, by: "tool", error: "" },
48
+ text: `${res.text}\n\n${recordLine(rec)}`,
49
+ details: { verdict: rec.verdict, scanned: rec.scanned, level: rec.level, recorded: rec.recorded, by: "tool", error: rec.error },
50
50
  };
51
51
  }
52
52
 
@@ -16,11 +16,21 @@ export interface TrackedState {
16
16
 
17
17
  export declare const DISPOSITIONS: string[];
18
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
+
19
29
  export function createTracked(): TrackedState;
20
30
 
21
31
  export function noteTrackedCall(
22
32
  tracked: TrackedState,
23
- call: { toolName?: string; input?: Record<string, unknown> },
33
+ call: { toolName?: string; input?: Record<string, unknown>; matchers?: TrackedMatchers },
24
34
  ): "claim" | "resolve" | "park" | "wait" | null;
25
35
 
26
36
  export function danglingGaps(tracked: TrackedState | undefined): string[];
@@ -17,6 +17,13 @@
17
17
  * So the illegal state is named, and the legal answers are exactly three. They
18
18
  * are all actions, and the agent can take every one of them — which is what
19
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.
20
27
  */
21
28
 
22
29
  /** The only three things an agent may do with work it cannot finish right now. */
@@ -57,6 +64,44 @@ const WAITING_ALTERNATIVES = [
57
64
  ];
58
65
  const WAITING_RE = new RegExp(WAITING_ALTERNATIVES.join("|"), "i");
59
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
+
60
105
  /** Identify the item a call is about, so one ticket is one entry. */
61
106
  function itemId(input) {
62
107
  if (!input || typeof input !== "object") return null;
@@ -99,22 +144,23 @@ function closeItem(tracked, id) {
99
144
  * Returns a short label for the evidence trail (`claim` / `resolve` / `park` /
100
145
  * `wait` / null). Never throws on odd input: this is an observer on the hot path.
101
146
  */
102
- export function noteTrackedCall(tracked, { toolName = "", input = {} } = {}) {
147
+ export function noteTrackedCall(tracked, { toolName = "", input = {}, matchers } = {}) {
148
+ const m = matchers || matchersFromConfig();
103
149
  if (!tracked || !toolName) return null;
104
150
  const id = itemId(input) || `tool:${toolName}`;
105
- if (PARK_RE.test(toolName)) {
151
+ if (m.park.test(toolName)) {
106
152
  closeItem(tracked, id);
107
153
  return "park";
108
154
  }
109
- if (RESOLVE_RE.test(toolName)) {
155
+ if (m.resolve.test(toolName)) {
110
156
  closeItem(tracked, id);
111
157
  return "resolve";
112
158
  }
113
- if (CLAIM_RE.test(toolName)) {
159
+ if (m.claim.test(toolName)) {
114
160
  openItem(tracked, id, toolName);
115
161
  return "claim";
116
162
  }
117
- if (NARRATE_RE.test(toolName) && WAITING_RE.test(textOf(input))) {
163
+ if (NARRATE_RE.test(toolName) && m.wait.test(textOf(input))) {
118
164
  // Narrating a wait does not make it legal; it makes it visible to the gate.
119
165
  openItem(tracked, id, toolName);
120
166
  if (!tracked.waits.includes(id)) tracked.waits.push(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.17.7",
3
+ "version": "0.17.9",
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",
@@ -67,6 +67,20 @@ whose tool is not installed are skipped (or fail the gate when
67
67
  `/asf verify` (large work) runs the gate as **Gate 8** after the M1
68
68
  traceability matrix. Small work shows the report but never blocks.
69
69
 
70
+ All three paths — the `/asf health` command, `/asf verify`, and the
71
+ **`asf_health` tool** — record the verdict as evidence, so any of them closes the
72
+ completion gate's health gap. An autonomous run uses the tool: a slash command is
73
+ something the agent cannot type, and a gap it cannot close is a bug, not a gate.
74
+
75
+ **What `scanned` counts.** It is the number of metrics that actually ran
76
+ (pass/fail), never the number of metrics planned. Skipped metrics (tool not
77
+ installed, level off) and disabled ones (no persistence in the project) are not
78
+ in it, and neither are rows that errored. So `scanned: 0` means the gate examined
79
+ nothing — which the ledger prints as *"examined nothing, not a pass"* rather than
80
+ letting a green-looking line summarise a measurement of nothing at all. If every
81
+ metric is skipped, installing the toolchain is what closes the gap properly;
82
+ re-running the gate does not.
83
+
70
84
  ## 3. Configuration
71
85
 
72
86
  Optional `.asf-code-health.json` at the project root. Merged over the defaults
@@ -140,6 +140,18 @@ Two things this is **not**: it is not a ban on saying "blocked" honestly (say it
140
140
  report, with the disposition you took); and it is not a reason to resolve work that is
141
141
  not done — a false `resolved` is the worse failure. Park it.
142
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
+
143
155
  ## Anti-patterns
144
156
 
145
157
  - ❌ Unbounded retries on non-idempotent writes