@fcon-tech/portolan 0.4.5

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/adapters/README.md +226 -0
  4. package/adapters/omp/portolan-mcp +19 -0
  5. package/adapters/opencode/expedition-launcher +70 -0
  6. package/adapters/opencode/install.test.ts +105 -0
  7. package/adapters/opencode/install.ts +357 -0
  8. package/adapters/pi/portolan-mcp +19 -0
  9. package/adapters/scheduling/night-watch.cron +23 -0
  10. package/core/schema/chart.schema.json +154 -0
  11. package/core/src/bin/portolan.ts +84 -0
  12. package/core/src/chart-io.rollback-fixture.ts +55 -0
  13. package/core/src/chart-io.ts +121 -0
  14. package/core/src/chart-store.ts +137 -0
  15. package/core/src/chartroom/cli.ts +63 -0
  16. package/core/src/chartroom/render.ts +213 -0
  17. package/core/src/chartroom/review-template.html +232 -0
  18. package/core/src/chartroom/review.ts +109 -0
  19. package/core/src/chartroom/template.html +1090 -0
  20. package/core/src/fan-in.ts +84 -0
  21. package/core/src/harbor/chat-format.ts +154 -0
  22. package/core/src/harbor/cli.ts +178 -0
  23. package/core/src/harbor/errors.ts +22 -0
  24. package/core/src/harbor/fingerprint.ts +29 -0
  25. package/core/src/harbor/history.ts +178 -0
  26. package/core/src/harbor/launcher.ts +155 -0
  27. package/core/src/harbor/night-policy.ts +64 -0
  28. package/core/src/harbor/proposals.ts +324 -0
  29. package/core/src/harbor/run.ts +72 -0
  30. package/core/src/harbor/settings.ts +108 -0
  31. package/core/src/harbor/snapshot.ts +187 -0
  32. package/core/src/harbor/watch.ts +103 -0
  33. package/core/src/index.ts +28 -0
  34. package/core/src/notices.ts +117 -0
  35. package/core/src/perimeter.ts +44 -0
  36. package/core/src/server/adapter-boundary.ts +66 -0
  37. package/core/src/server/main.ts +27 -0
  38. package/core/src/server/registry.ts +609 -0
  39. package/core/src/server/server.ts +123 -0
  40. package/core/src/server/test-harness.ts +161 -0
  41. package/core/src/sheets.ts +151 -0
  42. package/core/src/staleness.ts +203 -0
  43. package/core/src/tools/log.ts +215 -0
  44. package/core/src/tools/manifests.ts +912 -0
  45. package/core/src/tools/neighborhood.ts +423 -0
  46. package/core/src/tools/shared.ts +72 -0
  47. package/core/src/tools/sound.ts +634 -0
  48. package/core/src/tools/sweep.ts +198 -0
  49. package/core/src/tools/symbols.ts +176 -0
  50. package/core/src/tools/trust-report.ts +193 -0
  51. package/core/src/types.ts +162 -0
  52. package/core/src/validate.ts +106 -0
  53. package/package.json +34 -0
  54. package/skill/SKILL.md +279 -0
  55. package/skill/examples/sailing-directions-example.md +35 -0
  56. package/skill/sailing-directions.template.md +59 -0
  57. package/skill/verify/checks.ts +476 -0
  58. package/skill/verify/dry-run.ts +738 -0
  59. package/skill/verify/fixture.ts +128 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `sweep`: ripgrep-backed pattern search over the target. Every match comes
3
+ * back as an anchored chunk (path, line, matched text, optional context)
4
+ * labeled `measured`. No match is an honest empty list; a malformed pattern
5
+ * is an error naming the pattern with zero results; a missing ripgrep is an
6
+ * error naming the binary — never a substitute search.
7
+ * specs/tools/spec.md
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { statSync } from "node:fs";
11
+ import type { Anchor } from "../types";
12
+ import { findBinary, firstLine, MissingBinaryError, relativeToTarget, type Env } from "./shared";
13
+
14
+ export interface SweepOptions {
15
+ /** Surrounding context lines handed to ripgrep (`rg -C`). */
16
+ context?: number;
17
+ /** Glob filter handed to ripgrep (`rg -g`). */
18
+ glob?: string;
19
+ /** Environment override; tests restrict PATH to probe missing-binary paths. */
20
+ env?: Env;
21
+ }
22
+
23
+ /** One anchored match: where it is, what matched, optional context. */
24
+ export interface SweepChunk {
25
+ /** File path, relative to the target root when ripgrep reports one. */
26
+ path: string;
27
+ /** 1-based line number of the match. */
28
+ line: number;
29
+ /** The full matching line, without trailing newline. */
30
+ text: string;
31
+ /** The first matched substring. */
32
+ match: string;
33
+ /** Every matched substring on the line. */
34
+ matches: string[];
35
+ /** Surrounding lines, present when `context` was requested. */
36
+ context?: string[];
37
+ /** file:line anchor for chart citations. */
38
+ anchor: Anchor;
39
+ }
40
+
41
+ export interface SweepResult {
42
+ trust: "measured";
43
+ pattern: string;
44
+ chunks: SweepChunk[];
45
+ }
46
+
47
+ /** Raised when ripgrep itself fails (malformed pattern, bad root, ...). */
48
+ export class SweepError extends Error {
49
+ readonly pattern: string;
50
+ constructor(pattern: string, message: string) {
51
+ super(`sweep: ${message}`);
52
+ this.name = "SweepError";
53
+ this.pattern = pattern;
54
+ }
55
+ }
56
+
57
+ interface RgText {
58
+ text?: string;
59
+ }
60
+
61
+ /** The subset of ripgrep's --json event stream the tool consumes. */
62
+ interface RgEvent {
63
+ type: string;
64
+ data?: {
65
+ path?: RgText;
66
+ lines?: RgText;
67
+ line_number?: number;
68
+ submatches?: Array<{ match?: RgText }>;
69
+ };
70
+ }
71
+
72
+ interface FileEvent {
73
+ line: number;
74
+ text: string;
75
+ isMatch: boolean;
76
+ matches: string[];
77
+ }
78
+
79
+ /**
80
+ * Map ripgrep's --json events to anchored chunks. Only `match` and
81
+ * `context` events carry evidence; begin/end/summary and any unknown event
82
+ * kinds are ignored so a ripgrep version bump degrades gracefully.
83
+ */
84
+ function parseRgEvents(
85
+ stdout: string,
86
+ targetRoot: string,
87
+ contextRequested: number,
88
+ ): SweepChunk[] {
89
+ const perFile = new Map<string, FileEvent[]>();
90
+ for (const raw of stdout.split("\n")) {
91
+ if (raw.trim().length === 0) continue;
92
+ let event: RgEvent;
93
+ try {
94
+ event = JSON.parse(raw) as RgEvent;
95
+ } catch {
96
+ throw new Error(
97
+ `sweep: ripgrep emitted unparseable output: ${raw.slice(0, 120)}`,
98
+ );
99
+ }
100
+ if (event.type !== "match" && event.type !== "context") continue;
101
+ const data = event.data ?? {};
102
+ const path = data.path?.text;
103
+ const line = data.line_number;
104
+ if (path === undefined || line === undefined) continue;
105
+ const events = perFile.get(path) ?? [];
106
+ events.push({
107
+ line,
108
+ text: (data.lines?.text ?? "").replace(/\r?\n$/, ""),
109
+ isMatch: event.type === "match",
110
+ matches: (data.submatches ?? []).map((s) => s.match?.text ?? ""),
111
+ });
112
+ perFile.set(path, events);
113
+ }
114
+
115
+ const chunks: SweepChunk[] = [];
116
+ for (const [rawPath, events] of perFile) {
117
+ const path = relativeToTarget(rawPath, targetRoot);
118
+ for (const ev of events) {
119
+ if (!ev.isMatch) continue;
120
+ const context =
121
+ contextRequested > 0
122
+ ? events
123
+ .filter(
124
+ (e) =>
125
+ !e.isMatch &&
126
+ e.line !== ev.line &&
127
+ Math.abs(e.line - ev.line) <= contextRequested,
128
+ )
129
+ .sort((a, b) => a.line - b.line)
130
+ .map((e) => e.text)
131
+ : undefined;
132
+ chunks.push({
133
+ path,
134
+ line: ev.line,
135
+ text: ev.text,
136
+ match: ev.matches[0] ?? "",
137
+ matches: ev.matches,
138
+ ...(context !== undefined ? { context } : {}),
139
+ anchor: { type: "file", path, line: ev.line },
140
+ });
141
+ }
142
+ }
143
+ chunks.sort((a, b) =>
144
+ a.path === b.path ? a.line - b.line : a.path < b.path ? -1 : 1,
145
+ );
146
+ return chunks;
147
+ }
148
+
149
+ /** Search `targetRoot` for `pattern`; anchored chunks labeled `measured`. */
150
+ export function sweep(
151
+ targetRoot: string,
152
+ pattern: string,
153
+ options: SweepOptions = {},
154
+ ): SweepResult {
155
+ const env: Env = options.env ?? process.env;
156
+ let isDir = false;
157
+ try {
158
+ isDir = statSync(targetRoot).isDirectory();
159
+ } catch {
160
+ // fall through to the honest error below
161
+ }
162
+ if (!isDir) {
163
+ throw new SweepError(pattern, `target root ${targetRoot} is not a directory`);
164
+ }
165
+
166
+ const rg = findBinary("rg", env);
167
+ if (!rg) throw new MissingBinaryError("rg", "sweep");
168
+
169
+ const args = ["--json"];
170
+ if (options.context !== undefined && options.context > 0) {
171
+ args.push("-C", String(Math.floor(options.context)));
172
+ }
173
+ if (options.glob !== undefined) args.push("-g", options.glob);
174
+ args.push("--", pattern, ".");
175
+
176
+ const run = spawnSync(rg, args, {
177
+ cwd: targetRoot,
178
+ env,
179
+ encoding: "utf8",
180
+ maxBuffer: 512 * 1024 * 1024,
181
+ });
182
+ if (run.error) throw new MissingBinaryError("rg", "sweep");
183
+ // ripgrep: 0 = matches found, 1 = no matches, >=2 = error (bad pattern, ...).
184
+ if (run.status !== 0 && run.status !== 1) {
185
+ const reason =
186
+ firstLine(run.stderr ?? "") || `ripgrep exited with code ${run.status}`;
187
+ throw new SweepError(
188
+ pattern,
189
+ `pattern ${JSON.stringify(pattern)} rejected by ripgrep: ${reason}`,
190
+ );
191
+ }
192
+ const context = options.context ?? 0;
193
+ return {
194
+ trust: "measured",
195
+ pattern,
196
+ chunks: parseRgEvents(run.stdout ?? "", targetRoot, context),
197
+ };
198
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `symbols`: ctags-backed symbol lookup. Definitions (name, kind, path,
3
+ * line) are authoritative ctags output; references are resolved only by a
4
+ * corroborating sweep of the symbol name at definition-free sites — never
5
+ * invented. An absent symbol is an honest empty result; a missing ctags is
6
+ * an error naming the binary.
7
+ * specs/tools/spec.md
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { statSync } from "node:fs";
11
+ import type { Anchor } from "../types";
12
+ import { escapeRegExp, findBinary, firstLine, MissingBinaryError, relativeToTarget, type Env } from "./shared";
13
+ import { sweep } from "./sweep";
14
+
15
+ export interface SymbolOptions {
16
+ /** Resolve references via a corroborating sweep of the symbol name. */
17
+ references?: boolean;
18
+ /** Environment override; tests restrict PATH to probe missing-binary paths. */
19
+ env?: Env;
20
+ }
21
+
22
+ export interface SymbolDefinition {
23
+ name: string;
24
+ kind: string;
25
+ path: string;
26
+ line: number;
27
+ anchor: Anchor;
28
+ }
29
+
30
+ export interface SymbolReference {
31
+ path: string;
32
+ line: number;
33
+ text: string;
34
+ anchor: Anchor;
35
+ }
36
+
37
+ /** References are either resolved occurrences or honestly absent. */
38
+ export type SymbolReferences =
39
+ | { resolvable: true; items: SymbolReference[] }
40
+ | { resolvable: false; reason: string };
41
+
42
+ export interface SymbolResult {
43
+ trust: "measured";
44
+ name: string;
45
+ /** Empty (not an error) when the symbol is unknown to ctags. */
46
+ definitions: SymbolDefinition[];
47
+ /** Present only when references were requested. */
48
+ references?: SymbolReferences;
49
+ }
50
+
51
+ export class SymbolsError extends Error {
52
+ constructor(message: string) {
53
+ super(`symbols: ${message}`);
54
+ this.name = "SymbolsError";
55
+ }
56
+ }
57
+
58
+ /** The subset of universal-ctags JSON output the tool consumes. */
59
+ interface TagRecord {
60
+ _type?: string;
61
+ name?: unknown;
62
+ path?: unknown;
63
+ line?: unknown;
64
+ kind?: unknown;
65
+ }
66
+
67
+ /**
68
+ * Names shorter than this cannot be corroborated by a text sweep without
69
+ * guessing (design.md, decision 2): their references stay honestly absent.
70
+ */
71
+ const MIN_REFERENCE_NAME_LENGTH = 4;
72
+
73
+ /** Run ctags over the target and return every usable tag record. */
74
+ function runCtags(targetRoot: string, env: Env): TagRecord[] {
75
+ const ctags = findBinary("ctags", env);
76
+ if (!ctags) throw new MissingBinaryError("ctags", "symbols");
77
+ const run = spawnSync(
78
+ ctags,
79
+ ["--output-format=json", "--fields=+nK", "-f", "-", "--recurse", "."],
80
+ { cwd: targetRoot, env, encoding: "utf8", maxBuffer: 512 * 1024 * 1024 },
81
+ );
82
+ if (run.error) throw new MissingBinaryError("ctags", "symbols");
83
+ if (run.status !== 0) {
84
+ const reason =
85
+ firstLine(run.stderr ?? "") || `ctags exited with code ${run.status}`;
86
+ throw new SymbolsError(`ctags failed: ${reason}`);
87
+ }
88
+ const records: TagRecord[] = [];
89
+ for (const raw of (run.stdout ?? "").split("\n")) {
90
+ if (raw.trim().length === 0) continue;
91
+ let record: TagRecord;
92
+ try {
93
+ record = JSON.parse(raw) as TagRecord;
94
+ } catch {
95
+ throw new SymbolsError(
96
+ `ctags emitted unparseable output: ${raw.slice(0, 120)}`,
97
+ );
98
+ }
99
+ // Pseudo-tags and any non-tag records carry no symbol evidence.
100
+ if (record._type !== undefined && record._type !== "tag") continue;
101
+ if (typeof record.name !== "string" || typeof record.path !== "string") continue;
102
+ records.push(record);
103
+ }
104
+ return records;
105
+ }
106
+
107
+ /** Look up `name` in `targetRoot` through ctags; results labeled `measured`. */
108
+ export function symbols(
109
+ targetRoot: string,
110
+ name: string,
111
+ options: SymbolOptions = {},
112
+ ): SymbolResult {
113
+ const env: Env = options.env ?? process.env;
114
+ let isDir = false;
115
+ try {
116
+ isDir = statSync(targetRoot).isDirectory();
117
+ } catch {
118
+ // fall through to the honest error below
119
+ }
120
+ if (!isDir) {
121
+ throw new SymbolsError(`target root ${targetRoot} is not a directory`);
122
+ }
123
+
124
+ const all = runCtags(targetRoot, env);
125
+ const definitions: SymbolDefinition[] = [];
126
+ for (const record of all) {
127
+ if (record.name !== name || typeof record.line !== "number") continue;
128
+ const path = relativeToTarget(record.path as string, targetRoot);
129
+ const line = record.line;
130
+ definitions.push({
131
+ name,
132
+ kind: typeof record.kind === "string" ? record.kind : "unknown",
133
+ path,
134
+ line,
135
+ anchor: { type: "file", path, line },
136
+ });
137
+ }
138
+ definitions.sort((a, b) =>
139
+ a.path === b.path ? a.line - b.line : a.path < b.path ? -1 : 1,
140
+ );
141
+
142
+ let references: SymbolReferences | undefined;
143
+ if (options.references) {
144
+ if (name.length < MIN_REFERENCE_NAME_LENGTH) {
145
+ references = {
146
+ resolvable: false,
147
+ reason:
148
+ `the name "${name}" is too short or too common to corroborate by ` +
149
+ `sweep; references are reported as not resolvable rather than guessed`,
150
+ };
151
+ } else {
152
+ // Corroborating sweep of the name; definition sites are excluded so
153
+ // what remains are occurrences, never the definition itself.
154
+ const swept = sweep(targetRoot, `\\b${escapeRegExp(name)}\\b`, { env });
155
+ const definitionSites = new Set(
156
+ definitions.map((d) => `${d.path}:${d.line}`),
157
+ );
158
+ const items = swept.chunks
159
+ .filter((chunk) => !definitionSites.has(`${chunk.path}:${chunk.line}`))
160
+ .map((chunk) => ({
161
+ path: chunk.path,
162
+ line: chunk.line,
163
+ text: chunk.text,
164
+ anchor: chunk.anchor,
165
+ }));
166
+ references = { resolvable: true, items };
167
+ }
168
+ }
169
+
170
+ return {
171
+ trust: "measured",
172
+ name,
173
+ definitions,
174
+ ...(references !== undefined ? { references } : {}),
175
+ };
176
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * `trust.report`: the province's verification summary in one call
3
+ * (openspec/changes/verification-spine). Aggregates the chart — trust-label
4
+ * distribution, per-kind counts, staleness — and re-sounds every anchor
5
+ * live through the existing `sound.anchor` machinery, so the report states
6
+ * what holds now, not what the index last claimed. Refuted anchors are
7
+ * named, never smoothed over — including a citation so malformed that
8
+ * `sound.anchor` refuses it (a hand-edited index): it counts as refuted
9
+ * with the refusal as its finding, so one planted anchor cannot sink the
10
+ * report. Adoption counts the mandated query tools' invocations from the
11
+ * ship's log — invocation facts, never a measure of mandate compliance.
12
+ *
13
+ * Refreshes staleness first, exactly as `chart.read` does; that refresh is
14
+ * the only write the report may cause (inside `.portolan/`, per the
15
+ * refresh's own contract). No receipt is appended.
16
+ */
17
+ import type { Anchor, EntryKind, TrustLabel } from "../types";
18
+ import { ENTRY_KINDS, TRUST_LABELS } from "../types";
19
+ import { readChart } from "../chart-store";
20
+ import { chargeStaleEntries, compareVesselRank, vesselFanIn } from "../fan-in";
21
+ import { refreshStaleness } from "../staleness";
22
+ import { readReceipts, type Receipt } from "./log";
23
+ import { SoundingError, soundAnchor } from "./sound";
24
+
25
+ /**
26
+ * The mandated query tools whose invocations the report must account for
27
+ * (specs/invocation/spec.md: a query tool ships only with a per-tool
28
+ * adoption counter). A future mandated tool just appends here.
29
+ */
30
+ export const ADOPTION_TOOLS = ["chart.neighborhood"] as const;
31
+
32
+ /** The concrete tools in the adoption registry. */
33
+ export type MandatedQueryTool = (typeof ADOPTION_TOOLS)[number];
34
+
35
+ /** Invocation facts for one mandated query tool, read from the ship's log. */
36
+ export interface ToolAdoption {
37
+ /** Receipts in the log whose command names the tool. */
38
+ invocations: number;
39
+ /** First invocation's receipt id; null when the log holds none. */
40
+ firstReceipt: string | null;
41
+ /** Most recent invocation's receipt id; null when the log holds none. */
42
+ lastReceipt: string | null;
43
+ }
44
+
45
+ /** One refuted anchor: the entry that cites it, the citation, what was found. */
46
+ export interface RefutedAnchor {
47
+ /** The citing entry's chart id. */
48
+ entryId: string;
49
+ /** The anchor exactly as cited by the entry. */
50
+ anchor: Anchor;
51
+ /** What the sounding actually found at the citation. */
52
+ found: string;
53
+ }
54
+
55
+ /** One vessel dragging entries into `pending correction`. */
56
+ export interface PendingVessel {
57
+ id: string;
58
+ /** Chart entries marked pending correction that hang from this vessel. */
59
+ entries: number;
60
+ }
61
+
62
+ /** What `trust.report` returns: the one-call verification summary. */
63
+ export interface TrustReport {
64
+ /** Chart entries per trust label; all five labels stated, zero-filled. */
65
+ trust: Record<TrustLabel, number>;
66
+ /** Chart entries per kind; all six kinds stated, zero-filled. */
67
+ kinds: Record<EntryKind, number>;
68
+ /** Pending-correction vessels, in the repair rank's order (../fan-in.ts), read from the chart's stale flags after the refresh. */
69
+ staleness: { pendingVessels: PendingVessel[] };
70
+ /** Live re-sounding of every chart anchor. */
71
+ anchors: {
72
+ /** Anchors cited on the chart. */
73
+ total: number;
74
+ /** Anchors sounded — equal to `total` by construction: no sampling. */
75
+ sounded: number;
76
+ /** Soundings that resolved `confirmed`. */
77
+ confirmed: number;
78
+ /** Soundings that resolved `refuted`. */
79
+ refuted: number;
80
+ /** Every refuted anchor, sorted by entry id then anchor index. */
81
+ refutedList: RefutedAnchor[];
82
+ };
83
+ /** The ship's log: total receipts and the most recent one (null on an empty log). */
84
+ log: { receipts: number; lastReceipt: Receipt | null };
85
+ /** Per-tool invocation facts for every mandated query tool, zero-filled. */
86
+ adoption: { tools: Record<MandatedQueryTool, ToolAdoption> };
87
+ }
88
+
89
+ /**
90
+ * A receipt's command names the tool when it is the bare tool name or the
91
+ * tool followed by its arguments ("chart.neighborhood vessel=tug").
92
+ */
93
+ function namesTool(command: string, tool: MandatedQueryTool): boolean {
94
+ return command === tool || command.startsWith(`${tool} `);
95
+ }
96
+
97
+ /**
98
+ * `trust.report`: the one-call verification summary. Deterministic — no
99
+ * timestamps participate, so two runs over an unchanged province return the
100
+ * same report, refuted list in the same order.
101
+ */
102
+ export function trustReport(targetRoot: string): TrustReport {
103
+ // Staleness first, chart.read semantics: the staleness section is never
104
+ // served from a stale signature. This is the report's only possible write.
105
+ refreshStaleness(targetRoot);
106
+ const entries = readChart(targetRoot);
107
+
108
+ const trust = Object.fromEntries(TRUST_LABELS.map((l) => [l, 0])) as Record<TrustLabel, number>;
109
+ const kinds = Object.fromEntries(ENTRY_KINDS.map((k) => [k, 0])) as Record<EntryKind, number>;
110
+ for (const entry of entries) {
111
+ trust[entry.trust] += 1;
112
+ kinds[entry.kind] += 1;
113
+ }
114
+
115
+ // The queue's voice (openspec/changes/resurvey-queue/specs/tools/spec.md):
116
+ // pending vessels list in the repair rank's order — fan-in desc, vessel id
117
+ // — while membership stays this report's own attribution: a stale fairway
118
+ // charges both endpoints, so the list names vessels the repair queue does
119
+ // not.
120
+ const pending = chargeStaleEntries(entries);
121
+ const fanIn = vesselFanIn(entries);
122
+ const pendingVessels = [...pending.keys()]
123
+ .sort((a, b) => compareVesselRank(a, b, fanIn))
124
+ .map((id) => ({ id, entries: pending.get(id)! }));
125
+
126
+ const refuted: (RefutedAnchor & { index: number })[] = [];
127
+ let confirmed = 0;
128
+ let total = 0;
129
+ for (const entry of entries) {
130
+ for (const [index, anchor] of entry.anchors.entries()) {
131
+ total += 1;
132
+ let verdict: ReturnType<typeof soundAnchor>;
133
+ try {
134
+ verdict = soundAnchor(targetRoot, { anchor });
135
+ } catch (err) {
136
+ if (!(err instanceof SoundingError)) throw err;
137
+ // A non-citable anchor (plantable by a hand-edited index) is
138
+ // refuted by name like any other unresolvable citation — one bad
139
+ // citation never sinks the whole report, and the report never
140
+ // sounds fewer anchors than the chart cites.
141
+ refuted.push({ entryId: entry.id, anchor, index, found: err.message });
142
+ continue;
143
+ }
144
+ if (verdict.verdict === "confirmed") confirmed += 1;
145
+ else {
146
+ // sound.anchor yields confirmed or refuted only; its evidence names
147
+ // what was actually found — the one-line summary is the fallback so
148
+ // the cross-module evidence invariant is not assumed blindly here.
149
+ refuted.push({
150
+ entryId: entry.id,
151
+ anchor,
152
+ index,
153
+ found: verdict.evidence[0]?.found ?? verdict.report,
154
+ });
155
+ }
156
+ }
157
+ }
158
+ refuted.sort((a, b) =>
159
+ a.entryId < b.entryId ? -1 : a.entryId > b.entryId ? 1 : a.index - b.index,
160
+ );
161
+ const refutedList: RefutedAnchor[] = refuted.map(({ index: _index, ...r }) => r);
162
+
163
+ const receipts = readReceipts(targetRoot);
164
+ // Append-only log: file order is invocation order, so first/last by it.
165
+ const adoption = Object.fromEntries(
166
+ ADOPTION_TOOLS.map((tool) => {
167
+ const mine = receipts.filter((receipt) => namesTool(receipt.command, tool));
168
+ const stat: ToolAdoption = {
169
+ invocations: mine.length,
170
+ firstReceipt: mine[0]?.id ?? null,
171
+ lastReceipt: mine.length > 0 ? mine[mine.length - 1]!.id : null,
172
+ };
173
+ return [tool, stat] as const;
174
+ }),
175
+ ) as Record<MandatedQueryTool, ToolAdoption>;
176
+ return {
177
+ trust,
178
+ kinds,
179
+ staleness: { pendingVessels },
180
+ anchors: {
181
+ total,
182
+ sounded: total,
183
+ confirmed,
184
+ refuted: refutedList.length,
185
+ refutedList,
186
+ },
187
+ log: {
188
+ receipts: receipts.length,
189
+ lastReceipt: receipts.length > 0 ? receipts[receipts.length - 1]! : null,
190
+ },
191
+ adoption: { tools: adoption },
192
+ };
193
+ }