pi-aia-asf 0.17.9 → 0.17.11

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/index.ts +6 -0
  3. package/lib/code-health/config.mjs +9 -4
  4. package/lib/code-health/index.mjs +11 -6
  5. package/lib/code-health/tools.d.mts +21 -0
  6. package/lib/decisions/consult.d.mts +46 -0
  7. package/lib/decisions/consult.mjs +184 -0
  8. package/lib/decisions/glob.d.mts +2 -0
  9. package/lib/decisions/glob.mjs +57 -0
  10. package/lib/decisions/parse.d.mts +11 -0
  11. package/lib/decisions/parse.mjs +138 -0
  12. package/lib/decisions/schema.d.mts +36 -0
  13. package/lib/decisions/schema.mjs +252 -0
  14. package/lib/decisions/store.d.mts +28 -0
  15. package/lib/decisions/store.mjs +195 -0
  16. package/lib/extension/browser-record.ts +70 -0
  17. package/lib/extension/commands/session.ts +5 -3
  18. package/lib/extension/gate-state.ts +8 -1
  19. package/lib/extension/handlers/observe-decisions.ts +78 -0
  20. package/lib/extension/handlers/observe-work.ts +111 -0
  21. package/lib/extension/handlers/observed.ts +8 -0
  22. package/lib/extension/handlers/settle.ts +35 -0
  23. package/lib/extension/handlers/tool-call.ts +47 -76
  24. package/lib/extension/handlers/tool-result.ts +17 -1
  25. package/lib/extension/paths.ts +18 -0
  26. package/lib/extension/run.ts +39 -1
  27. package/lib/extension/runtime.ts +24 -0
  28. package/lib/extension/state.ts +42 -18
  29. package/lib/extension/tools/browser.ts +65 -0
  30. package/lib/extension/tools/decisions.ts +251 -0
  31. package/lib/extension/tools/evidence-check.ts +134 -0
  32. package/lib/extension/types.ts +1 -1
  33. package/lib/gates/browser-evidence.d.mts +28 -0
  34. package/lib/gates/browser-evidence.mjs +101 -0
  35. package/lib/gates/config.d.mts +1 -1
  36. package/lib/gates/config.mjs +11 -4
  37. package/lib/gates/decisions.d.mts +36 -0
  38. package/lib/gates/decisions.mjs +75 -0
  39. package/lib/gates/evidence-coverage.d.mts +19 -0
  40. package/lib/gates/evidence-coverage.mjs +124 -0
  41. package/lib/gates/ledger.mjs +0 -4
  42. package/lib/gates/settle.d.mts +3 -2
  43. package/lib/gates/settle.mjs +8 -1
  44. package/lib/home.d.mts +18 -0
  45. package/lib/home.mjs +127 -0
  46. package/package.json +1 -1
  47. package/skills/aia-asf/references/06b-appendix.md +18 -0
  48. package/skills/aia-asf/references/06b-testing-qa.md +3 -1
  49. package/skills/aia-asf/references/06c-code-quality.md +5 -5
  50. package/skills/aia-asf/references/06e-code-health.md +31 -2
  51. package/skills/aia-asf/references/06i-completeness.md +6 -8
package/CHANGELOG.md CHANGED
@@ -7,6 +7,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.11] - 2026-11-10
11
+
12
+ ### Added — the coverage invariant is checkable from the installed package
13
+
14
+ `test/test-evidence-kinds.mjs` asserts that every evidence kind a gate requires has a
15
+ producer, but `test/` is not shipped — so a broken or half-updated install reproduced
16
+ the one bug that suite exists to catch, and the session inside it had no way to find
17
+ out. It could only be steered, at the same gap, forever. Three callers now ask the one
18
+ question, against one definition in `lib/gates/evidence-coverage.mjs`:
19
+
20
+ - `asf_evidence_check {kind?}` — the agent's own check. An unproducible **required**
21
+ kind is reported as a broken install rather than as work to keep attempting, because
22
+ no amount of effort records evidence nothing in the package can write.
23
+ - `scripts/verify-published.mjs` — a fourth release section, run against the copy npm
24
+ just installed. A tarball missing a producer now fails the release instead of
25
+ shipping the loop to every user while the repository stayed green. A section that
26
+ throws is a failed check, never a silent pass.
27
+ - `test/test-evidence-kinds.mjs` — asserts the answer, and asserts the shipped module
28
+ derives the same required set an independent derivation in the test does.
29
+
30
+ ### Fixed — the coverage checker could not say "no"
31
+
32
+ Writing the negatives proved it: five mutations of the new module initially passed the
33
+ suite, because every assertion asked only whether a producer was **found**. A checker
34
+ whose regex matches everything, or that counts the ledger as the producer of its own
35
+ kinds, is invisible to a suite like that — and `requiredOrphans: []` was invisible too.
36
+ It is now tested against fixture packages where the answer is known to be no: a kind
37
+ nothing writes, and a kind only the ledger mentions. The tool's two paths no session
38
+ hits on a good day (broken install, check that threw) were untested for the same reason
39
+ and are now driven directly.
40
+
41
+ ### Changed — the code-health config lives under `.asf/`
42
+
43
+ `.asf/code-health.json` is now read first, with the root `.asf-code-health.json` still
44
+ honoured so a project configured before the move does not wake up on defaults after an
45
+ upgrade. This was the last ASF-authored file still written to the project root, and
46
+ "everything ASF authors lives under `.asf/`" is one decision with one table of names:
47
+ the path now comes from `FILES` in `lib/home.mjs` rather than a literal in the reader,
48
+ which is how a file ends up written in one place and read from another.
49
+
50
+ ### Fixed — the shipped CHANGELOG said nothing
51
+
52
+ The 0.17.10 section was the release script's untouched placeholder, twice over: an
53
+ empty `### Added` above a second heading for the same version. `CHANGELOG.md` ships in
54
+ the tarball, so that is what a user reads. `test/test-antipatterns.mjs` now asserts the
55
+ shipped version has a section, that it is not the placeholder, that it has real prose,
56
+ and that no version is listed twice — making "Vague commits or CHANGELOG placeholders"
57
+ an enforced anti-pattern rather than a listed one.
58
+
59
+ Also: `test/harness.mjs` passes a jiti loader to suites, since a `.mjs` suite cannot
60
+ import ASF's own `.ts` modules and each suite spinning up its own loader is how two
61
+ different loaders start disagreeing; `lib/code-health/tools.d.mts` added (an imported
62
+ `.mjs` used from `.ts` needs a declaration or typecheck fails).
63
+
64
+ ## [0.17.10] - 2026-11-10
65
+
66
+ ### Added — the browser evidence the completion gate demands can now be produced
67
+
68
+ The gate required browser verification whenever a UI file was touched (in two places:
69
+ the ledger's completeness check and the delivery report), but the only registered
70
+ tools were `asf_health` and `asf_decisions`. Any session that edited a tsx/html/css
71
+ file was steered at a gap it had no hands for — the health loop, one layer down. Two
72
+ agent-callable tools now close it, mirroring `asf_health`:
73
+
74
+ - `asf_browser_verify {page, action, assertion}` — **refused** unless the session
75
+ actually drove a browser. ASF sees every `tool_call`, so a verification with no
76
+ browser in the room is a claim about a page nobody opened; `browser_diagnose` and
77
+ `browser_logs` do not count, because they report on the daemon, not on the surface.
78
+ All three fields are required: that is the minimum a reviewer can go and repeat.
79
+ - `asf_browser_na {reason}` — **refused** when the reason is a placeholder (`n/a`,
80
+ `none`, `not applicable`, one word). A structured n/a is worth having only if a
81
+ reviewer can disagree with it; otherwise it is an exit around the gate.
82
+
83
+ Recorded, not claimed: a tool that accepted "I verified it" would make the gate
84
+ decorative, and a decorative gate reads as coverage. The rules live in
85
+ `lib/gates/browser-evidence.mjs` (pure, testable without a browser) with a thin
86
+ extension adapter, mirroring the `measure.mjs` / `health-record.ts` split.
87
+
88
+ ### Fixed — five ledger kinds had no producer
89
+
90
+ `browser-verify`, `browser-na`, `clean-room`, `spec-transition` and `perf-scenario`
91
+ could be validated and rendered but never written. Now:
92
+
93
+ - `spec-transition` is **observed** from the real `update_spec_status` call, so the
94
+ delivery report renders actual transitions without anyone remembering to record them;
95
+ - `clean-room` is observed from the release pipeline (`npm run release` /
96
+ `verify-published.mjs`) together with its exit code — the definition-of-done line
97
+ about the shipped artifact no longer promises evidence nothing can produce;
98
+ - `perf-scenario` is **dropped**: no producer, no requirement, five mandatory fields
99
+ nobody could fill.
100
+
101
+ ### Added — the runner can no longer read a suite as green when it never checked
102
+
103
+ `test/harness.mjs` honoured only `export async function run()` returning false. A
104
+ suite exposing its verdict as the module's default export was ignored entirely —
105
+ which is how a new suite reported "51 passed" while enforcing nothing. The runner now
106
+ reads `run()`, a boolean default, or a boolean `ok`; a suite claiming `selfReports`
107
+ must contain a real failure channel (`process.exit(1)` or `node:test`) or it warns;
108
+ and a suite with none of these is named instead of passing silently. The whole class
109
+ is pinned by `test/test-evidence-kinds.mjs`: every ledger kind must have a producer in
110
+ the shipped package, and no kind a gate *requires* may be an orphan.
111
+
112
+ Also: `tool-call.ts` passed the 300-line ceiling and its work observers moved to
113
+ `lib/extension/handlers/observe-work.ts`, with pi's `Input`/`Event` shapes declared
114
+ once in `observed.ts` instead of three times; `String(undefined)` → `?? ""` in the
115
+ evidence builders (the literal "undefined" reads like a value and passes a presence
116
+ check); and a redundant guard in `placeholderReason` was deleted because no mutation
117
+ of it failed a test — a guard that cannot be failed is decoration, not protection.
118
+
10
119
  ## [0.17.9] - 2026-11-10
11
120
 
12
121
  ### Fixed — the health verdict now reports what it measured, not what it planned
package/index.ts CHANGED
@@ -17,6 +17,9 @@
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
18
  import { setPiRef } from "./lib/extension/pi-ref.ts";
19
19
  import { registerHealthTool } from "./lib/extension/tools/health.ts";
20
+ import { registerDecisionsTool } from "./lib/extension/tools/decisions.ts";
21
+ import { registerBrowserTools } from "./lib/extension/tools/browser.ts";
22
+ import { registerEvidenceCheckTool } from "./lib/extension/tools/evidence-check.ts";
20
23
  import { registerCommands } from "./lib/extension/commands/index.ts";
21
24
  import { registerToolCallHandler } from "./lib/extension/handlers/tool-call.ts";
22
25
  import { registerToolResultHandler } from "./lib/extension/handlers/tool-result.ts";
@@ -28,6 +31,9 @@ export { shouldPersist } from "./lib/extension/evidence.ts";
28
31
  export default function register(pi: ExtensionAPI): void {
29
32
  setPiRef(pi);
30
33
  registerHealthTool(pi);
34
+ registerDecisionsTool(pi);
35
+ registerBrowserTools(pi);
36
+ registerEvidenceCheckTool(pi);
31
37
  registerCommands(pi);
32
38
  registerToolCallHandler(pi);
33
39
  registerToolResultHandler(pi);
@@ -203,15 +203,20 @@ export function normalizeConfig(raw) {
203
203
  }
204
204
 
205
205
  /**
206
- * Load the project config: read `.asf-code-health.json` if present, normalize.
207
- * `readFile` is injectable for tests.
206
+ * Load the project config: `.asf/code-health.json`, falling back to the root
207
+ * `.asf-code-health.json` that older releases wrote.
208
+ *
209
+ * The path comes from the home table rather than a literal here, because "everything
210
+ * ASF authors lives under `.asf/`" is one decision with one table of names — a second
211
+ * place that hardcodes a location is how a file ends up written in one place and read
212
+ * from another. `readFile` is injectable for tests.
208
213
  */
209
214
  export async function loadConfig(projectRoot, readFile = null) {
210
215
  const fs = readFile || (await import("node:fs/promises")).readFile;
216
+ const { readPath } = await import("../home.mjs");
211
217
  let raw = null;
212
218
  try {
213
- const text = await fs(`${projectRoot}/.asf-code-health.json`, "utf-8");
214
- raw = JSON.parse(text);
219
+ raw = JSON.parse(await fs(readPath(projectRoot, "healthConfig"), "utf-8"));
215
220
  } catch {
216
221
  raw = null; // no file, or unreadable/invalid → defaults
217
222
  }
@@ -10,10 +10,12 @@ import { loadConfig } from "./config.mjs";
10
10
  import { detectLanguages, planMetrics, resolvePlans } from "./detect.mjs";
11
11
  import { listSourceFiles, detectTools, runAll } from "./run.mjs";
12
12
  import { evaluateRow, verdict, formatReport, jsonReport, diffRows, formatDiff, baselinePayload } from "./report.mjs";
13
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
14
- import { join } from "node:path";
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+ import { readPath, writePath } from "../home.mjs";
15
16
 
16
- const BASELINE_FILE = ".asf-code-health-baseline.json";
17
+ /** The path ASF reports and tests assert; derived from the home, never a second copy. */
18
+ export const BASELINE_FILE = writePath("", "baseline");
17
19
 
18
20
  /** Parse CLI args: --diff, --json, --update-baseline, --verbose. */
19
21
  export function parseArgs(args) {
@@ -66,11 +68,15 @@ export async function runHealth(projectRoot, args = [], deps = {}) {
66
68
  function applyBaselineFlags({ text, rows, root, flags }) {
67
69
  if (flags.updateBaseline) {
68
70
  const payload = baselinePayload(rows);
69
- writeFileSync(join(root, BASELINE_FILE), JSON.stringify(payload, null, 2) + "\n", "utf-8");
71
+ const file = writePath(root, "baseline");
72
+ mkdirSync(dirname(file), { recursive: true });
73
+ writeFileSync(file, JSON.stringify(payload, null, 2) + "\n", "utf-8");
70
74
  return `${text}\n\nBaseline updated: ${BASELINE_FILE}`;
71
75
  }
72
76
  if (!flags.diff) return text;
73
- const baselinePath = join(root, BASELINE_FILE);
77
+ // Home first, then the pre-home root file — a project that set a baseline
78
+ // before the home existed must still diff against its own ratchet.
79
+ const baselinePath = readPath(root, "baseline");
74
80
  let baselineRows = [];
75
81
  if (existsSync(baselinePath)) {
76
82
  try {
@@ -83,4 +89,3 @@ function applyBaselineFlags({ text, rows, root, flags }) {
83
89
  return `${text}\n\n${formatDiff(diffRows(rows, baselineRows))}`;
84
90
  }
85
91
 
86
- export { BASELINE_FILE };
@@ -0,0 +1,21 @@
1
+ /** Where ASF is installed — the base for resolving its own bundled toolchain. */
2
+ export const ASF_ROOT: string;
3
+
4
+ export interface ResolvedTools {
5
+ eslint?: string;
6
+ sonarjs?: string;
7
+ tsParser?: string;
8
+ jscpd?: string;
9
+ madge?: string;
10
+ depcruise?: string;
11
+ /** Where each tool was found: "asf" | "project" | "path" | undefined when missing. */
12
+ sources: Record<string, string | undefined>;
13
+ missing: string[];
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ /** Resolve the health toolchain ASF-first: ASF's install, then the project, then PATH. */
18
+ export function resolveTools(projectRoot: string, opts?: { asfRoot?: string }): ResolvedTools;
19
+
20
+ /** The required tools that could not be found; empty means the gate can measure. */
21
+ export function missingTools(tools: ResolvedTools): string[];
@@ -0,0 +1,46 @@
1
+ import type { DecisionRecord, DecisionProblem } from "./schema.d.mts";
2
+
3
+ export interface Register {
4
+ dir: string;
5
+ records: DecisionRecord[];
6
+ problems: DecisionProblem[];
7
+ }
8
+
9
+ export interface DecisionResult {
10
+ ok: boolean;
11
+ file?: string;
12
+ id?: string;
13
+ superseded?: string;
14
+ by?: string;
15
+ problems?: DecisionProblem[];
16
+ }
17
+
18
+ export interface AddArgs {
19
+ record: DecisionRecord;
20
+ body?: string;
21
+ config?: Record<string, unknown> | null;
22
+ planApproved?: boolean;
23
+ read?: DecisionRecord[];
24
+ }
25
+
26
+ export interface SupersedeArgs extends AddArgs {
27
+ id: string;
28
+ by?: string;
29
+ }
30
+
31
+ export function registerAt(root: string, config?: Record<string, unknown> | null): Register;
32
+ /**
33
+ * Project-relative paths: `applies_to` is written relative to the repo while a
34
+ * run records absolute ones, and the two must be compared on the same footing.
35
+ */
36
+ export function relativeTo(root: string, files: string[] | undefined): string[];
37
+ export function cardIndex(records: DecisionRecord[] | undefined, opts?: { max?: number; bytes?: number }): string[];
38
+ export function unreadGoverning(
39
+ records: DecisionRecord[] | undefined,
40
+ filePath: string,
41
+ read: string[] | undefined,
42
+ ): DecisionRecord[];
43
+ export function citedIds(text: string | undefined, records: DecisionRecord[] | undefined): string[];
44
+ export function addDecision(root: string, args: AddArgs): DecisionResult;
45
+ export function supersedeDecision(root: string, args: SupersedeArgs): DecisionResult;
46
+ export function refreshIndex(root: string, config?: Record<string, unknown> | null): { file: string; count: number };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The consult surface: what the handlers and the tool actually call.
3
+ *
4
+ * Kept separate from `store.mjs` because the store is about files and this is
5
+ * about a *run*: what is in context, what has been read, what has been cited,
6
+ * and what a new decision has to satisfy before it is allowed to exist. The
7
+ * rule it enforces is the cheap one that makes the whole register worth
8
+ * building — a decision only governs work if it was in front of the agent when
9
+ * that work was written.
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { decisionsDir, ensureHome } from "../home.mjs";
15
+ import { loadRegister, governing, indexForCard, writeRecord, updateRecord, nextNumber, writeIndex, fileNameFor } from "./store.mjs";
16
+ import { validateRegister } from "./schema.mjs";
17
+
18
+ const ID_RE = /ADR-\d{4}/g;
19
+
20
+ /** Whether a named enforcement check exists — relative to the measured project. */
21
+ function existsIn(root, p) {
22
+ try {
23
+ return fs.existsSync(path.join(root, String(p)));
24
+ } catch {
25
+ return true;
26
+ }
27
+ }
28
+
29
+ /** Read the register for a project. Reading never creates anything. */
30
+ export function registerAt(root, config) {
31
+ const dir = decisionsDir(root, config || {});
32
+ const { records, problems } = loadRegister(dir);
33
+ const exists = (p) => existsIn(root, p);
34
+ return { dir, records, problems: problems.concat(validateRegister(records, { exists })) };
35
+ }
36
+
37
+ /** The budgeted index for the run card. */
38
+ export function cardIndex(records, opts) {
39
+ return indexForCard(records, opts);
40
+ }
41
+
42
+ /**
43
+ * Which accepted decisions govern a file the agent is about to change and has
44
+ * not read this run. That set is the whole read-before-edit floor.
45
+ */
46
+ export function unreadGoverning(records, filePath, read) {
47
+ return governing(records, filePath).filter((r) => !(read || []).includes(String(r.id)));
48
+ }
49
+
50
+ /**
51
+ * Project-relative paths, because `applies_to` is written relative to the repo
52
+ * and the run records absolute ones. A path outside the project is left alone —
53
+ * it governs nothing, and silently dropping it would hide that fact.
54
+ */
55
+ export function relativeTo(root, files) {
56
+ return (files || [])
57
+ .filter((f) => typeof f === "string" && f.trim())
58
+ .map((f) => {
59
+ const s = String(f).replace(/\\/g, "/");
60
+ if (!path.isAbsolute(s)) return s;
61
+ const rel = path.relative(root, s);
62
+ return rel && !rel.startsWith("..") ? rel : s;
63
+ });
64
+ }
65
+
66
+ /** Which accepted decisions a piece of text cites. */
67
+ export function citedIds(text, records) {
68
+ const found = String(text || "").match(ID_RE) || [];
69
+ const known = new Set((records || []).filter((r) => r.status === "accepted").map((r) => String(r.id)));
70
+ return found.filter((id) => known.has(id));
71
+ }
72
+
73
+ /** What makes a record law rather than a suggestion. */
74
+ function hasAcceptanceEvidence(d) {
75
+ return Boolean(String(d.from_spec || "").trim() || String(d.user_instructed || "").trim() || String(d.plan || "").trim());
76
+ }
77
+
78
+ /**
79
+ * Default the status rather than refuse the record.
80
+ *
81
+ * A record with nothing behind it is a *proposal* — visible, never enforced —
82
+ * which is the honest default and keeps an unattended run from having to ask.
83
+ * Evidence (a spec id, the user's own words, an approved plan) is what promotes
84
+ * it to law, and the validator still refuses `accepted` without it, so a
85
+ * hand-written or imported record cannot skip the same bar.
86
+ */
87
+ function defaultStatus(d) {
88
+ if (d.status) return d.status;
89
+ if (String(d.origin || "") === "backfill" && Array.isArray(d.source) && d.source.length) return "accepted";
90
+ return hasAcceptanceEvidence(d) ? "accepted" : "proposed";
91
+ }
92
+
93
+ /**
94
+ * Record a new decision.
95
+ *
96
+ * An invalid record is **not written**: a register full of half-records is worse
97
+ * than a smaller one, because the half-records are the ones that get followed
98
+ * into the code. The caller gets the fix list instead.
99
+ */
100
+
101
+ /**
102
+ * The set a write is validated against: what is on disk, plus whatever the caller
103
+ * already loaded, minus the file this write replaces (it is being rewritten, not
104
+ * duplicated).
105
+ *
106
+ * Deduplicated by FILE, never by id — two files carrying the same id is exactly
107
+ * the corruption the validator has to be able to see, and a caller that passes the
108
+ * records it just loaded must not turn every record into a duplicate of itself.
109
+ */
110
+ function validationSet(dir, probe, read) {
111
+ const byFile = new Map();
112
+ for (const r of [...loadRegister(dir).records, ...(read || [])]) {
113
+ const key = String(r.__file || "");
114
+ if (key && key === String(probe.__file)) continue;
115
+ byFile.set(key || `#${byFile.size}`, r);
116
+ }
117
+ return [probe, ...byFile.values()];
118
+ }
119
+
120
+ export function addDecision(root, { record, body, config, planApproved, read }) {
121
+ ensureHome(root);
122
+ const dir = decisionsDir(root, config || {});
123
+ const data = { ...record };
124
+ data.status = defaultStatus(data);
125
+ if (!data.id) data.id = `ADR-${String(nextNumber(dir)).padStart(4, "0")}`;
126
+ if (!data.decided) data.decided = new Date().toISOString().slice(0, 10);
127
+ const probe = { ...data, body: body ?? data.body, __file: fileNameFor(data.id, data.title) };
128
+ const problems = validateRegister(validationSet(dir, probe, read), { planApproved: planApproved === true, exists: (p) => existsIn(root, p) });
129
+ // Only this record's own faults may refuse it. The register's existing
130
+ // records are passed in so collisions (duplicate ids, overlapping scopes,
131
+ // one-sided supersession) are caught — but a neighbour's formatting is not
132
+ // this write's problem, and refusing on it would make a register unwritable.
133
+ const blocking = problems.filter((p) => p.severity === "error" && (p.id === data.id || p.file === probe.__file || !p.id));
134
+ if (blocking.length) return { ok: false, problems: blocking, file: undefined };
135
+ const written = writeRecord(dir, data, body ?? data.body);
136
+ if (!written.created) return { ok: false, problems: [{ severity: "error", field: "id", message: `${written.file} already exists — a decision is superseded, never rewritten` }], file: written.file };
137
+ const refreshed = loadRegister(dir);
138
+ writeIndex(dir, refreshed.records);
139
+ return { ok: true, file: written.file, id: data.id, problems: problems.filter((p) => p.severity === "notice") };
140
+ }
141
+
142
+ /**
143
+ * Replace a decision. The old record keeps its history and gains
144
+ * `superseded_by`; the new one names it in `supersedes`. Both halves are written
145
+ * together, because a one-way link leaves two records looking current — which is
146
+ * exactly how an agent ends up following a decision that was reversed.
147
+ */
148
+ export function supersedeDecision(root, { by, id, record, body, config, planApproved, read }) {
149
+ const dir = decisionsDir(root, config || {});
150
+ const loaded = loadRegister(dir).records;
151
+ const older = loaded.find((r) => String(r.id) === String(id));
152
+ if (!older) return { ok: false, problems: [{ severity: "error", field: "id", message: `${id} is not in the register` }] };
153
+ // The id is decided BEFORE validation: the bidirectional check asks what the
154
+ // old record will say about the new one, and that cannot be answered after the
155
+ // new record has already been written. Validating the pair as it will exist is
156
+ // the only order in which a legitimate supersession can be written at all.
157
+ const newId = String(record.id || `ADR-${String(nextNumber(dir)).padStart(4, "0")}`);
158
+ // The register this function already opened is the authority for what exists —
159
+ // a caller that forgot to pass `read` (or passed a stale one) must not make a
160
+ // legitimate supersession look like a link to nothing.
161
+ const known = [...new Map([...loaded, ...(read || [])].map((r) => [String(r.id), r])).values()].map((r) =>
162
+ String(r.id) === String(id) ? { ...r, status: "superseded", superseded_by: newId } : r,
163
+ );
164
+ const created = addDecision(root, {
165
+ record: { ...record, id: newId, supersedes: [String(id)] },
166
+ body,
167
+ config,
168
+ planApproved,
169
+ read: known,
170
+ });
171
+ if (!created.ok) return created;
172
+ const back = updateRecord(dir, String(id), { status: "superseded", superseded_by: created.id });
173
+ if (!back.created) return { ok: false, problems: [{ severity: "error", field: "supersedes", message: `could not mark ${id} superseded (${back.reason})` }] };
174
+ const refreshed = loadRegister(dir);
175
+ writeIndex(dir, refreshed.records);
176
+ return { ok: true, file: back.file, id: created.id, superseded: String(id), by: String(by || "agent") };
177
+ }
178
+
179
+ /** Re-generate the index from the records (the records are the source). */
180
+ export function refreshIndex(root, config) {
181
+ const dir = decisionsDir(root, config || {});
182
+ const { records } = loadRegister(dir);
183
+ return { file: writeIndex(dir, records), count: records.length };
184
+ }
@@ -0,0 +1,2 @@
1
+ export function globToRe(glob: string): RegExp;
2
+ export function matchesAny(path: string, globs: string[] | undefined): boolean;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The tiny glob matcher a decision uses to name the code it governs.
3
+ *
4
+ * `applies_to: [lib/gates/**, test/test-gates.mjs]` is the field that turns a
5
+ * paragraph into something that can be *routed*: it is how ASF knows, at the
6
+ * moment a file is about to be edited, which decisions apply to that file.
7
+ *
8
+ * Written here rather than pulled from a package for the same reason as the
9
+ * front-matter parser — a gate's behaviour must not vary with what the measured
10
+ * project has installed. Supported: `*` (not across `/`), `**` (across `/`),
11
+ * `?`, and a trailing `/` meaning "everything under here".
12
+ */
13
+
14
+ function escapeRe(s) {
15
+ return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
16
+ }
17
+
18
+ /**
19
+ * Emit the pattern for a run of stars starting at `i`, and report how much of the
20
+ * glob it consumed. Pulled out of `globToRe` because the three star cases are where
21
+ * all the nesting lived; with them here, the compiler is a flat switch.
22
+ */
23
+ function starPattern(g, i) {
24
+ if (g[i + 1] !== "*") return { pattern: "[^/]*", skip: 1 };
25
+ // `**/` may match zero directories, so swallow the separator with it.
26
+ if (g[i + 2] === "/") return { pattern: "(?:.*/)?", skip: 3 };
27
+ return { pattern: ".*", skip: 2 };
28
+ }
29
+
30
+ /** Compile one glob into an anchored RegExp. */
31
+ export function globToRe(glob) {
32
+ let g = String(glob || "");
33
+ if (!g) return /^$/;
34
+ if (g.endsWith("/")) g += "**";
35
+ let out = "";
36
+ for (let i = 0; i < g.length; i++) {
37
+ const c = g[i];
38
+ if (c === "*") {
39
+ const star = starPattern(g, i);
40
+ out += star.pattern;
41
+ i += star.skip - 1;
42
+ continue;
43
+ }
44
+ if (c === "?") {
45
+ out += "[^/]";
46
+ continue;
47
+ }
48
+ out += escapeRe(c);
49
+ }
50
+ return new RegExp(`^${out}$`);
51
+ }
52
+
53
+ /** Does a project-relative path fall under any of these globs? */
54
+ export function matchesAny(path, globs) {
55
+ const p = String(path || "").replace(/^\.\//, "").replace(/^\/+/, "");
56
+ return (globs || []).some((g) => globToRe(g).test(p));
57
+ }
@@ -0,0 +1,11 @@
1
+ export interface ParsedFrontMatter {
2
+ data: Record<string, unknown>;
3
+ body: string;
4
+ errors: string[];
5
+ }
6
+ export function parseFrontMatter(text: string): ParsedFrontMatter;
7
+ export function serializeFrontMatter(
8
+ data: Record<string, unknown>,
9
+ body: string,
10
+ order?: string[],
11
+ ): string;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The front matter of a decision record.
3
+ *
4
+ * A documented subset of YAML — flat `key: value`, inline arrays, block lists,
5
+ * comments — parsed by ASF rather than by a YAML dependency. The reason is the
6
+ * same one that makes the health gate write its own eslint config and run
7
+ * `--no-config-lookup`: what a gate reads must not depend on a library the
8
+ * measured project happens to have installed.
9
+ *
10
+ * The second reason is the user. These files are edited by hand, verbatim, in
11
+ * whatever editor is open. So the parser is strict about what it understands and
12
+ * *loud* about what it does not: unsupported syntax is returned as an error,
13
+ * never skipped, because a record silently parsed as empty reads as "no
14
+ * decisions" — and an empty register is a gate that passes for the wrong reason.
15
+ */
16
+
17
+ const FM_DELIM = /^---\s*$/;
18
+
19
+ function stripComment(value) {
20
+ let out = "";
21
+ let quote = null;
22
+ for (let i = 0; i < value.length; i++) {
23
+ const c = value[i];
24
+ if (quote) {
25
+ out += c;
26
+ if (c === quote) quote = null;
27
+ continue;
28
+ }
29
+ if (c === '"' || c === "'") {
30
+ quote = c;
31
+ out += c;
32
+ continue;
33
+ }
34
+ if (c === "#" && (i === 0 || /\s/.test(value[i - 1]))) break;
35
+ out += c;
36
+ }
37
+ return out.trim();
38
+ }
39
+
40
+ function unquote(v) {
41
+ const t = v.trim();
42
+ if (t.length > 1 && (t[0] === '"' || t[0] === "'") && t.endsWith(t[0])) return t.slice(1, -1);
43
+ return t;
44
+ }
45
+
46
+ function parseScalar(v) {
47
+ const t = unquote(v);
48
+ if (t === "") return "";
49
+ if (t === "true") return true;
50
+ if (t === "false") return false;
51
+ if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
52
+ return t;
53
+ }
54
+
55
+ function parseInlineArray(v) {
56
+ const inner = v.trim().replace(/^\[/, "").replace(/\]$/, "").trim();
57
+ if (!inner) return [];
58
+ return inner.split(",").map((s) => parseScalar(s.trim())).filter((x) => x !== "");
59
+ }
60
+
61
+ /** Is this line something the subset cannot express (nested map, tabs, anchors)? */
62
+ function unsupported(line) {
63
+ if (/\t/.test(line)) return "tabs are not valid indentation";
64
+ if (/^\s+\S+:\s*$/.test(line) || /^\s+\S+:\s+\S/.test(line)) return "nested maps are not supported";
65
+ if (/^\s*[-*]\s+\S+:/.test(line)) return "maps inside lists are not supported";
66
+ if (/^\s*[&*]|:\s*[|>]\s*$/.test(line)) return "anchors and block scalars are not supported";
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * Apply one front-matter line to `data`. Returns an error string when the line is
72
+ * not something this parser understands, `null` when it was consumed happily, and
73
+ * the new current key when a `key:` opened a list. Kept separate from the loop so
74
+ * the four shapes a line can have (blank/comment, unsupported, bullet, pair) read
75
+ * as four branches instead of one nested pile.
76
+ */
77
+ function applyLine(line, data, key) {
78
+ const bad = unsupported(line);
79
+ if (bad) return { error: bad };
80
+ const bullet = /^\s*-\s+(.*)$/.exec(line);
81
+ if (bullet && key) {
82
+ if (!Array.isArray(data[key])) data[key] = [];
83
+ data[key].push(parseScalar(stripComment(bullet[1])));
84
+ return {};
85
+ }
86
+ const kv = /^([A-Za-z_][\w.-]*):\s*(.*)$/.exec(line);
87
+ if (!kv) return { error: `not a "key: value" pair — "${line.trim()}"`, key: null };
88
+ const next = kv[1];
89
+ const value = stripComment(kv[2]);
90
+ if (value === "") data[next] = [];
91
+ else if (value.startsWith("[")) data[next] = parseInlineArray(value);
92
+ else data[next] = parseScalar(value);
93
+ return { key: next };
94
+ }
95
+
96
+ /**
97
+ * Parse `---` front matter out of a markdown document.
98
+ * @returns {{data: Record<string, unknown>, body: string, errors: string[]}}
99
+ */
100
+ export function parseFrontMatter(text) {
101
+ const lines = String(text ?? "").replace(/\r\n/g, "\n").split("\n");
102
+ if (lines[0].trim() !== "---") {
103
+ return { data: {}, body: lines.join("\n"), errors: ["missing front matter (the file must start with ---)"] };
104
+ }
105
+ const end = lines.findIndex((l, i) => i > 0 && FM_DELIM.test(l));
106
+ if (end < 0) {
107
+ return { data: {}, body: "", errors: ["front matter is never closed (no second ---)"] };
108
+ }
109
+ const data = {};
110
+ const errors = [];
111
+ let key = null;
112
+ for (let i = 1; i < end; i++) {
113
+ const line = lines[i].replace(/\s+$/, "");
114
+ if (!line.trim() || line.trim().startsWith("#")) continue;
115
+ const out = applyLine(line, data, key);
116
+ if (out.error) {
117
+ errors.push(`front matter line ${i + 1}: ${out.error}`);
118
+ key = out.key === null ? null : key;
119
+ continue;
120
+ }
121
+ if (out.key) key = out.key;
122
+ }
123
+ return { data, body: lines.slice(end + 1).join("\n"), errors };
124
+ }
125
+
126
+ function emitValue(v) {
127
+ if (Array.isArray(v)) return `[${v.map(emitValue).join(", ")}]`;
128
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
129
+ const s = String(v ?? "");
130
+ return /[:#\[\]{}"']|^\s|\s$/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
131
+ }
132
+
133
+ /** Serialize a record back to front matter + body. Keys keep the declared order. */
134
+ export function serializeFrontMatter(data, body, order = []) {
135
+ const keys = order.length ? [...order.filter((k) => k in data), ...Object.keys(data).filter((k) => !order.includes(k))] : Object.keys(data);
136
+ const fm = keys.map((k) => `${k}: ${emitValue(data[k])}`);
137
+ return `---\n${fm.join("\n")}\n---\n\n${String(body ?? "").trim()}\n`;
138
+ }