pi-aia-asf 0.17.8 → 0.17.10
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 +99 -0
- package/index.ts +4 -0
- package/lib/code-health/index.mjs +36 -23
- package/lib/code-health/measure.d.mts +25 -0
- package/lib/code-health/measure.mjs +76 -0
- package/lib/decisions/consult.d.mts +46 -0
- package/lib/decisions/consult.mjs +184 -0
- package/lib/decisions/glob.d.mts +2 -0
- package/lib/decisions/glob.mjs +57 -0
- package/lib/decisions/parse.d.mts +11 -0
- package/lib/decisions/parse.mjs +138 -0
- package/lib/decisions/schema.d.mts +36 -0
- package/lib/decisions/schema.mjs +252 -0
- package/lib/decisions/store.d.mts +28 -0
- package/lib/decisions/store.mjs +195 -0
- package/lib/extension/browser-record.ts +70 -0
- package/lib/extension/commands/inspect.ts +6 -1
- package/lib/extension/commands/session.ts +5 -3
- package/lib/extension/commands/verify.ts +5 -1
- package/lib/extension/gate-state.ts +8 -1
- package/lib/extension/handlers/observe-decisions.ts +78 -0
- package/lib/extension/handlers/observe-work.ts +111 -0
- package/lib/extension/handlers/observed.ts +8 -0
- package/lib/extension/handlers/settle.ts +35 -0
- package/lib/extension/handlers/tool-call.ts +47 -76
- package/lib/extension/handlers/tool-result.ts +17 -1
- package/lib/extension/health-record.ts +46 -0
- package/lib/extension/paths.ts +18 -0
- package/lib/extension/run.ts +39 -1
- package/lib/extension/runtime.ts +24 -0
- package/lib/extension/state.ts +42 -18
- package/lib/extension/tools/browser.ts +65 -0
- package/lib/extension/tools/decisions.ts +251 -0
- package/lib/extension/tools/health.ts +12 -12
- package/lib/extension/types.ts +1 -1
- package/lib/gates/browser-evidence.d.mts +28 -0
- package/lib/gates/browser-evidence.mjs +101 -0
- package/lib/gates/config.d.mts +1 -1
- package/lib/gates/config.mjs +11 -4
- package/lib/gates/decisions.d.mts +36 -0
- package/lib/gates/decisions.mjs +75 -0
- package/lib/gates/ledger.mjs +0 -4
- package/lib/gates/settle.d.mts +3 -2
- package/lib/gates/settle.mjs +8 -1
- package/lib/home.d.mts +18 -0
- package/lib/home.mjs +126 -0
- package/package.json +1 -1
- package/skills/aia-asf/references/06b-appendix.md +18 -0
- package/skills/aia-asf/references/06b-testing-qa.md +3 -1
- package/skills/aia-asf/references/06c-code-quality.md +5 -5
- package/skills/aia-asf/references/06e-code-health.md +14 -0
- package/skills/aia-asf/references/06i-completeness.md +6 -8
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.10] - 2026-11-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- (describe changes for 0.17.10)
|
|
15
|
+
|
|
16
|
+
## [0.18.0] - 2026-11-10
|
|
17
|
+
|
|
18
|
+
### Added — the browser evidence the completion gate demands can now be produced
|
|
19
|
+
|
|
20
|
+
The gate required browser verification whenever a UI file was touched (in two places:
|
|
21
|
+
the ledger's completeness check and the delivery report), but the only registered
|
|
22
|
+
tools were `asf_health` and `asf_decisions`. Any session that edited a tsx/html/css
|
|
23
|
+
file was steered at a gap it had no hands for — the health loop, one layer down. Two
|
|
24
|
+
agent-callable tools now close it, mirroring `asf_health`:
|
|
25
|
+
|
|
26
|
+
- `asf_browser_verify {page, action, assertion}` — **refused** unless the session
|
|
27
|
+
actually drove a browser. ASF sees every `tool_call`, so a verification with no
|
|
28
|
+
browser in the room is a claim about a page nobody opened; `browser_diagnose` and
|
|
29
|
+
`browser_logs` do not count, because they report on the daemon, not on the surface.
|
|
30
|
+
All three fields are required: that is the minimum a reviewer can go and repeat.
|
|
31
|
+
- `asf_browser_na {reason}` — **refused** when the reason is a placeholder (`n/a`,
|
|
32
|
+
`none`, `not applicable`, one word). A structured n/a is worth having only if a
|
|
33
|
+
reviewer can disagree with it; otherwise it is an exit around the gate.
|
|
34
|
+
|
|
35
|
+
Recorded, not claimed: a tool that accepted "I verified it" would make the gate
|
|
36
|
+
decorative, and a decorative gate reads as coverage. The rules live in
|
|
37
|
+
`lib/gates/browser-evidence.mjs` (pure, testable without a browser) with a thin
|
|
38
|
+
extension adapter, mirroring the `measure.mjs` / `health-record.ts` split.
|
|
39
|
+
|
|
40
|
+
### Fixed — five ledger kinds had no producer
|
|
41
|
+
|
|
42
|
+
`browser-verify`, `browser-na`, `clean-room`, `spec-transition` and `perf-scenario`
|
|
43
|
+
could be validated and rendered but never written. Now:
|
|
44
|
+
|
|
45
|
+
- `spec-transition` is **observed** from the real `update_spec_status` call, so the
|
|
46
|
+
delivery report renders actual transitions without anyone remembering to record them;
|
|
47
|
+
- `clean-room` is observed from the release pipeline (`npm run release` /
|
|
48
|
+
`verify-published.mjs`) together with its exit code — the definition-of-done line
|
|
49
|
+
about the shipped artifact no longer promises evidence nothing can produce;
|
|
50
|
+
- `perf-scenario` is **dropped**: no producer, no requirement, five mandatory fields
|
|
51
|
+
nobody could fill.
|
|
52
|
+
|
|
53
|
+
### Added — the runner can no longer read a suite as green when it never checked
|
|
54
|
+
|
|
55
|
+
`test/harness.mjs` honoured only `export async function run()` returning false. A
|
|
56
|
+
suite exposing its verdict as the module's default export was ignored entirely —
|
|
57
|
+
which is how a new suite reported "51 passed" while enforcing nothing. The runner now
|
|
58
|
+
reads `run()`, a boolean default, or a boolean `ok`; a suite claiming `selfReports`
|
|
59
|
+
must contain a real failure channel (`process.exit(1)` or `node:test`) or it warns;
|
|
60
|
+
and a suite with none of these is named instead of passing silently. The whole class
|
|
61
|
+
is pinned by `test/test-evidence-kinds.mjs`: every ledger kind must have a producer in
|
|
62
|
+
the shipped package, and no kind a gate *requires* may be an orphan.
|
|
63
|
+
|
|
64
|
+
Also: `tool-call.ts` passed the 300-line ceiling and its work observers moved to
|
|
65
|
+
`lib/extension/handlers/observe-work.ts`, with pi's `Input`/`Event` shapes declared
|
|
66
|
+
once in `observed.ts` instead of three times; `String(undefined)` → `?? ""` in the
|
|
67
|
+
evidence builders (the literal "undefined" reads like a value and passes a presence
|
|
68
|
+
check); and a redundant guard in `placeholderReason` was deleted because no mutation
|
|
69
|
+
of it failed a test — a guard that cannot be failed is decoration, not protection.
|
|
70
|
+
|
|
71
|
+
## [0.17.9] - 2026-11-10
|
|
72
|
+
|
|
73
|
+
### Fixed — the health verdict now reports what it measured, not what it planned
|
|
74
|
+
|
|
75
|
+
`asf_health` recorded `scanned` as `rows.length`, and `rows` contains every metric
|
|
76
|
+
the gate **planned** — including the ones that never ran because a tool was not
|
|
77
|
+
installed. Three things followed, all reported from the same observation:
|
|
78
|
+
|
|
79
|
+
- the ledger's `scanned 0` rule ("examined nothing, not a pass") was unreachable
|
|
80
|
+
while any metric was planned, so a run that measured nothing was recorded as a
|
|
81
|
+
nine-metric audit;
|
|
82
|
+
- the tool's own summary line contradicted the report printed directly above it —
|
|
83
|
+
`NOT healthy — 8 metric(s) skipped` followed by `9 metric(s) measured`;
|
|
84
|
+
- the honest number already existed and was thrown away: `verdict()` returns
|
|
85
|
+
`ran`, counting pass+fail and deliberately **excluding** error rows, but
|
|
86
|
+
`runHealth()` never returned it.
|
|
87
|
+
|
|
88
|
+
`runHealth()` now returns `ran` and `skipped`, and the recorded count comes from
|
|
89
|
+
them. Counting `status !== "skipped"` was the tempting fix and is wrong: it counts
|
|
90
|
+
errored rows as measured, which puts the report, the ledger and the summary line
|
|
91
|
+
back out of agreement — the exact disagreement being fixed. A metric that does not
|
|
92
|
+
apply (no persistence in the project) is neither ran nor skipped.
|
|
93
|
+
|
|
94
|
+
### Changed — every path that produces a health verdict records it
|
|
95
|
+
|
|
96
|
+
`/asf health` and the Gate 8 section of `/asf verify` printed the measurement and
|
|
97
|
+
left no trace, so a human could run the command, read HEALTHY, and leave the
|
|
98
|
+
completion gate's health gap open. Recording was the `asf_health` tool's private
|
|
99
|
+
privilege; it is now shared, with `by:` saying who produced the verdict
|
|
100
|
+
(`tool` / `command` / `verify`). The counting rules and the summary wording moved
|
|
101
|
+
to `lib/code-health/measure.mjs` — pure, dependency-free, and tested without a
|
|
102
|
+
harness — so the three callers cannot drift into contradicting each other again.
|
|
103
|
+
|
|
104
|
+
A verdict over zero metrics says so out loud: *"Nothing was actually measured — a
|
|
105
|
+
verdict over zero metrics is not a pass, and installing the missing tools is what
|
|
106
|
+
closes this properly."*
|
|
107
|
+
|
|
108
|
+
|
|
10
109
|
## [0.17.8] - 2026-11-09
|
|
11
110
|
|
|
12
111
|
### Added — the tracked-work vocabulary is the project's to widen
|
package/index.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
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";
|
|
20
22
|
import { registerCommands } from "./lib/extension/commands/index.ts";
|
|
21
23
|
import { registerToolCallHandler } from "./lib/extension/handlers/tool-call.ts";
|
|
22
24
|
import { registerToolResultHandler } from "./lib/extension/handlers/tool-result.ts";
|
|
@@ -28,6 +30,8 @@ export { shouldPersist } from "./lib/extension/evidence.ts";
|
|
|
28
30
|
export default function register(pi: ExtensionAPI): void {
|
|
29
31
|
setPiRef(pi);
|
|
30
32
|
registerHealthTool(pi);
|
|
33
|
+
registerDecisionsTool(pi);
|
|
34
|
+
registerBrowserTools(pi);
|
|
31
35
|
registerCommands(pi);
|
|
32
36
|
registerToolCallHandler(pi);
|
|
33
37
|
registerToolResultHandler(pi);
|
|
@@ -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 {
|
|
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
|
-
|
|
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) {
|
|
@@ -35,7 +37,7 @@ export async function runHealth(projectRoot, args = [], deps = {}) {
|
|
|
35
37
|
const config = await loadConfig(projectRoot, deps.readFile);
|
|
36
38
|
if (!config.enabled) {
|
|
37
39
|
const text = "Code Health Gate: disabled (enabled: false).";
|
|
38
|
-
return { text, json: { enabled: false }, exitCode: 0, healthy: true, rows: [], config };
|
|
40
|
+
return { text, json: { enabled: false }, exitCode: 0, healthy: true, rows: [], ran: 0, skipped: 0, config };
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
const files = listSourceFiles(projectRoot, config.ignore);
|
|
@@ -50,29 +52,40 @@ export async function runHealth(projectRoot, args = [], deps = {}) {
|
|
|
50
52
|
const gateBlocks = config.gate.mode === "block" && config.gate.scope !== "off";
|
|
51
53
|
const exitCode = gateBlocks && !v.healthy ? 1 : 0;
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
const text = applyBaselineFlags({ text: formatReport(rows, config), rows, root: projectRoot, flags });
|
|
54
56
|
|
|
57
|
+
const json = flags.json ? jsonReport(rows, config) : null;
|
|
58
|
+
// `ran` and `skipped` are verdict()'s own counts, and they leave here because
|
|
59
|
+
// they are the ONLY honest answer to "how much was actually measured" — see
|
|
60
|
+
// lib/code-health/measure.mjs. `rows.length` counts metrics PLANNED.
|
|
61
|
+
return { text, json, exitCode, healthy: v.healthy, rows, ran: v.ran, skipped: v.skipped, config };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `--update-baseline` and `--diff`, factored out of `runHealth` so the gate's
|
|
66
|
+
* main path stays readable and the metric accounting stays in one place.
|
|
67
|
+
*/
|
|
68
|
+
function applyBaselineFlags({ text, rows, root, flags }) {
|
|
55
69
|
if (flags.updateBaseline) {
|
|
56
70
|
const payload = baselinePayload(rows);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
const file = writePath(root, "baseline");
|
|
72
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
73
|
+
writeFileSync(file, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
74
|
+
return `${text}\n\nBaseline updated: ${BASELINE_FILE}`;
|
|
75
|
+
}
|
|
76
|
+
if (!flags.diff) return text;
|
|
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");
|
|
80
|
+
let baselineRows = [];
|
|
81
|
+
if (existsSync(baselinePath)) {
|
|
82
|
+
try {
|
|
83
|
+
const base = JSON.parse(readFileSync(baselinePath, "utf-8"));
|
|
84
|
+
baselineRows = Object.entries(base.metrics || {}).map(([key, m]) => ({ key, value: m.value, status: m.status }));
|
|
85
|
+
} catch {
|
|
86
|
+
/* unreadable baseline → no diff */
|
|
69
87
|
}
|
|
70
|
-
const changes = diffRows(rows, baselineRows);
|
|
71
|
-
text += "\n\n" + formatDiff(changes);
|
|
72
88
|
}
|
|
73
|
-
|
|
74
|
-
const json = flags.json ? jsonReport(rows, config) : null;
|
|
75
|
-
return { text, json, exitCode, healthy: v.healthy, rows, config };
|
|
89
|
+
return `${text}\n\n${formatDiff(diffRows(rows, baselineRows))}`;
|
|
76
90
|
}
|
|
77
91
|
|
|
78
|
-
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
|
+
}
|
|
@@ -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,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;
|