avouch 0.1.22

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 (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +356 -0
  4. package/SPEC.md +379 -0
  5. package/dist/cli/avouch.d.ts +2 -0
  6. package/dist/cli/avouch.js +68 -0
  7. package/dist/cli/check.d.ts +86 -0
  8. package/dist/cli/check.js +333 -0
  9. package/dist/cli/facts.d.ts +3 -0
  10. package/dist/cli/facts.js +157 -0
  11. package/dist/cli/gate.d.ts +3 -0
  12. package/dist/cli/gate.js +149 -0
  13. package/dist/cli/init.d.ts +21 -0
  14. package/dist/cli/init.js +282 -0
  15. package/dist/cli/jsonl.d.ts +5 -0
  16. package/dist/cli/jsonl.js +20 -0
  17. package/dist/cli/query.d.ts +2 -0
  18. package/dist/cli/query.js +138 -0
  19. package/dist/cli/reconcile.d.ts +9 -0
  20. package/dist/cli/reconcile.js +131 -0
  21. package/dist/src/checker.d.ts +3 -0
  22. package/dist/src/checker.js +1136 -0
  23. package/dist/src/contract.d.ts +135 -0
  24. package/dist/src/contract.js +3 -0
  25. package/dist/src/coverage.d.ts +65 -0
  26. package/dist/src/coverage.js +237 -0
  27. package/dist/src/evaluate.d.ts +66 -0
  28. package/dist/src/evaluate.js +535 -0
  29. package/dist/src/expr.d.ts +209 -0
  30. package/dist/src/expr.js +497 -0
  31. package/dist/src/index.d.ts +11 -0
  32. package/dist/src/index.js +11 -0
  33. package/dist/src/ledger.d.ts +96 -0
  34. package/dist/src/ledger.js +241 -0
  35. package/dist/src/markdown.d.ts +66 -0
  36. package/dist/src/markdown.js +701 -0
  37. package/dist/src/patch.d.ts +6 -0
  38. package/dist/src/patch.js +37 -0
  39. package/dist/src/query.d.ts +333 -0
  40. package/dist/src/query.js +739 -0
  41. package/dist/src/reconcile.d.ts +61 -0
  42. package/dist/src/reconcile.js +151 -0
  43. package/dist/src/stage1-check.d.ts +31 -0
  44. package/dist/src/stage1-check.js +329 -0
  45. package/dist/src/touched.d.ts +1 -0
  46. package/dist/src/touched.js +9 -0
  47. package/dist/src/unknown.d.ts +10 -0
  48. package/dist/src/unknown.js +173 -0
  49. package/docs/adapter-guide.md +187 -0
  50. package/docs/markdown-convention.md +635 -0
  51. package/ontology.schema.json +1757 -0
  52. package/package.json +57 -0
  53. package/templates/agents-block.md +12 -0
  54. package/vocab.ttl +524 -0
@@ -0,0 +1,149 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { verifyCoverage } from "../src/coverage.js";
5
+ import { parseLedger, unitKeyText, verifyLedger } from "../src/ledger.js";
6
+ import { checkStamp, resolveLedger, resolveQueryProject } from "./check.js";
7
+ import { jsonLines, readJsonLines } from "./jsonl.js";
8
+ import { expectPositionals, parseOptions, readOntology, wantsHelp } from "./reconcile.js";
9
+ export const GATE_USAGE = "usage: avouch gate verify [-f <ontology.yaml>] --rows <rows.jsonl> [--baseline <baseline.json>] [--json] [--check] [--config <avouch.config.json>] [--adapter <module|config.json>]";
10
+ export const GATE_DOCS_USAGE = "usage: avouch gate docs [--config <avouch.config.json>] [--json]";
11
+ const unitText = (row) => (row === "otherwise" ? "otherwise" : `rows.${row}`);
12
+ function text(check, r, pass) {
13
+ const lines = [];
14
+ if (check.status === "fail")
15
+ lines.push(`FAIL check: ${check.schemaErrors} schema error(s), ${check.violations} violation(s)`);
16
+ for (const u of r.uncovered)
17
+ lines.push(`FAIL uncovered ${u.action}:${unitText(u.row)}`);
18
+ for (const g of r.unexempted)
19
+ lines.push(`FAIL unexempted ${g.action}:${g.condition}`);
20
+ for (const g of r.unreasoned)
21
+ lines.push(`FAIL unreasoned ${g.action}:${g.condition}`);
22
+ for (const s of r.stale)
23
+ lines.push(s.list === "uncovered" ? `FAIL stale uncovered ${s.action}:${unitText(s.row)} ${s.why}` : `FAIL stale exempt ${s.action}:${s.condition} ${s.why}`);
24
+ for (const f of r.failed)
25
+ lines.push(`FAIL case ${f.id} ${f.action} ${f.why}`);
26
+ lines.push(`INFO check ${check.status}`);
27
+ for (const b of r.blocked)
28
+ lines.push(`INFO blocked ${b.action}:${unitText(b.row)} by ${b.condition}`);
29
+ for (const a of r.noTable)
30
+ lines.push(`INFO no_table ${a}`);
31
+ for (const c of r.unasserted)
32
+ lines.push(`INFO unasserted ${c.id} ${c.action}`);
33
+ lines.push(`${pass ? "PASS" : "FAIL"}: ${r.units} unit(s), ${r.verified.length} verified, ${r.baselined.length} baselined, ${r.blocked.length} blocked, ` +
34
+ `${r.uncovered.length} uncovered; ${r.unexempted.length} unexempted, ${r.unreasoned.length} unreasoned, ${r.stale.length} stale, ` +
35
+ `${r.failed.length} failed case(s), ${r.unasserted.length} unasserted`);
36
+ return lines.join("\n") + "\n";
37
+ }
38
+ async function verify(args) {
39
+ const { pos, opts } = parseOptions(args, ["-f", "--rows", "--baseline", "--config", "--adapter"], ["--json", "--check"]);
40
+ expectPositionals(pos, 0);
41
+ const rowsFile = opts.get("--rows");
42
+ if (typeof rowsFile !== "string")
43
+ throw new Error("--rows is required");
44
+ const baselineFile = opts.get("--baseline");
45
+ const { file, adapter } = resolveQueryProject(opts);
46
+ const ont = readOntology(file);
47
+ const rows = readJsonLines(rowsFile);
48
+ let baseline = null;
49
+ if (baselineFile !== undefined) {
50
+ const raw = readFileSync(resolve(baselineFile), "utf8");
51
+ try {
52
+ baseline = JSON.parse(raw);
53
+ }
54
+ catch (error) {
55
+ throw new Error(`${baselineFile}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ let result;
59
+ try {
60
+ result = verifyCoverage(ont, rows, baseline);
61
+ }
62
+ catch (error) {
63
+ const msg = error instanceof Error ? error.message : String(error);
64
+ throw new Error(`${msg.startsWith("baseline:") ? baselineFile : msg.startsWith("ontology:") ? file : rowsFile}: ${msg}`);
65
+ }
66
+ const check = await checkStamp(file, adapter);
67
+ const pass = result.ok && check.status !== "fail";
68
+ process.stdout.write(opts.get("--json") === true ? `${JSON.stringify({ avouchGate: 1, gate: "verify", check, result }, null, 2)}\n` : text(check, result, pass));
69
+ return pass ? 0 : 1;
70
+ }
71
+ const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
72
+ function docsText(r) {
73
+ const lines = [];
74
+ for (const u of r.changed)
75
+ lines.push(`FAIL changed ${unitKeyText(u)}`);
76
+ for (const u of r.unreviewed)
77
+ lines.push(`FAIL unreviewed ${unitKeyText(u)}`);
78
+ for (const s of r.stale)
79
+ lines.push(`FAIL stale ${"outline" in s ? `${s.doc} outline` : unitKeyText(s)}`);
80
+ for (const o of r.outline)
81
+ lines.push(`FAIL outline ${o.doc}`);
82
+ for (const d of r.dangling)
83
+ lines.push(`FAIL dangling ${unitKeyText(d)} ${d.pointer}`);
84
+ lines.push(`${r.ok ? "PASS" : "FAIL"}: ${r.docs} document(s), ${r.units} unit(s), ${r.emptyUnits} empty unit(s); ${r.changed.length} changed, ` +
85
+ `${r.unreviewed.length} unreviewed, ${r.stale.length} stale, ${r.outline.length} outline, ${r.dangling.length} dangling`);
86
+ return lines.join("\n") + "\n";
87
+ }
88
+ async function docs(args) {
89
+ const { pos, opts } = parseOptions(args, ["--config"], ["--json"]);
90
+ expectPositionals(pos, 0);
91
+ const p = resolveLedger(opts.get("--config"));
92
+ if (!existsSync(p.ledger))
93
+ throw new Error(`${p.ledger}: file not found`);
94
+ let entries;
95
+ try {
96
+ entries = parseLedger(jsonLines(p.ledger, readFileSync(p.ledger, "utf8").replace(/^\ufeff/, "")));
97
+ }
98
+ catch (error) {
99
+ const msg = error.message;
100
+ throw new Error(msg.startsWith(`${p.ledger}:`) ? msg : `${p.ledger}:${msg}`);
101
+ }
102
+ const ont = readOntology(p.ontology);
103
+ const texts = p.docs.map((doc) => {
104
+ try {
105
+ return { doc, text: readFileSync(resolve(p.dir, doc), "utf8") };
106
+ }
107
+ catch (error) {
108
+ throw new Error(`${p.config}: ledger: ${doc}: ${error.message}`);
109
+ }
110
+ });
111
+ const result = verifyLedger(texts, entries, ont, sha256);
112
+ process.stdout.write(opts.get("--json") === true ? `${JSON.stringify({ avouchGate: 1, gate: "docs", result }, null, 2)}\n` : docsText(result));
113
+ return result.ok ? 0 : 1;
114
+ }
115
+ // Exit 0: the gate passes; 1: it fails; 2: a usage, file, parse or shape error, or an adapter error (one stderr line).
116
+ export async function runGate(args) {
117
+ let usage = `${GATE_USAGE}; ${GATE_DOCS_USAGE}`;
118
+ try {
119
+ if (args[0] === "--help" || args[0] === "-h") {
120
+ console.log(GATE_USAGE);
121
+ console.log(GATE_DOCS_USAGE);
122
+ return 0;
123
+ }
124
+ if (args.length === 0)
125
+ throw new Error("missing gate");
126
+ if (args[0] === "verify") {
127
+ usage = GATE_USAGE;
128
+ if (wantsHelp(args.slice(1), ["-f", "--rows", "--baseline", "--config", "--adapter"])) {
129
+ console.log(usage);
130
+ return 0;
131
+ }
132
+ return await verify(args.slice(1));
133
+ }
134
+ if (args[0] === "docs") {
135
+ usage = GATE_DOCS_USAGE;
136
+ if (wantsHelp(args.slice(1), ["--config"])) {
137
+ console.log(usage);
138
+ return 0;
139
+ }
140
+ return await docs(args.slice(1));
141
+ }
142
+ throw new Error(`unknown gate ${args[0]}`);
143
+ }
144
+ catch (error) {
145
+ const msg = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ");
146
+ console.error(`${msg}; ${usage}`);
147
+ return 2;
148
+ }
149
+ }
@@ -0,0 +1,21 @@
1
+ import { type SpawnSyncOptions } from "node:child_process";
2
+ export declare const INIT_USAGE = "usage: avouch init [--append | --print] [--plugin | --no-plugin] [--dry-run]";
3
+ /**
4
+ * Yes/no questions on one readline interface, made at the first question. Lines that arrive before a question is asked
5
+ * (typed ahead) are queued and answer the next questions in order; at the end of input a question gets no (an empty answer).
6
+ */
7
+ export declare function createAsker(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): {
8
+ ask: (question: string) => Promise<boolean>;
9
+ close: () => void;
10
+ };
11
+ /**
12
+ * How to run `claude` with fixed arguments. On Windows `claude` is usually `claude.cmd`, which only a shell can run; the
13
+ * arguments are constants, so the command line is safe for the shell. Elsewhere no shell.
14
+ */
15
+ export declare function pluginSpawn(args: string[], platform?: NodeJS.Platform): {
16
+ command: string;
17
+ args: string[];
18
+ options: SpawnSyncOptions;
19
+ };
20
+ /** `avouch init`. Exit 0: done (also the print fallback and a dry run); 1: a plugin command failed or a file could not be written; 2: a usage error or not in a git repository. */
21
+ export declare function runInit(args: string[]): Promise<number>;
@@ -0,0 +1,282 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { fences } from "../src/markdown.js";
6
+ import { gitTop } from "./check.js";
7
+ import { expectPositionals, parseOptions, wantsHelp } from "./reconcile.js";
8
+ export const INIT_USAGE = "usage: avouch init [--append | --print] [--plugin | --no-plugin] [--dry-run]";
9
+ const FLAGS = ["--append", "--print", "--plugin", "--no-plugin", "--dry-run"];
10
+ const PLUGIN_COMMANDS = [
11
+ ["plugin", "marketplace", "add", "githonllc/avouch"],
12
+ ["plugin", "install", "avouch@avouch"],
13
+ ];
14
+ const BEGIN = "<!-- BEGIN avouch -->";
15
+ const END = "<!-- END avouch -->";
16
+ const TARGETS = ["AGENTS.md", "CLAUDE.md"];
17
+ const isTty = () => process.stdin.isTTY === true && process.stdout.isTTY === true;
18
+ const commandLine = (args) => `claude ${args.join(" ")}`;
19
+ /**
20
+ * Yes/no questions on one readline interface, made at the first question. Lines that arrive before a question is asked
21
+ * (typed ahead) are queued and answer the next questions in order; at the end of input a question gets no (an empty answer).
22
+ */
23
+ export function createAsker(input, output) {
24
+ let rl;
25
+ let ended = false;
26
+ const lines = [];
27
+ const waiting = [];
28
+ const next = () => {
29
+ if (lines.length > 0)
30
+ return Promise.resolve(lines.shift());
31
+ if (ended)
32
+ return Promise.resolve("");
33
+ return new Promise((resolve) => waiting.push(resolve));
34
+ };
35
+ return {
36
+ ask: async (question) => {
37
+ if (rl === undefined) {
38
+ rl = createInterface({ input, output });
39
+ rl.on("line", (line) => (waiting.length > 0 ? waiting.shift()(line) : lines.push(line)));
40
+ rl.on("close", () => {
41
+ ended = true;
42
+ for (const resolve of waiting.splice(0))
43
+ resolve("");
44
+ });
45
+ }
46
+ if (ended)
47
+ output.write(`${question} [y/N] \n`);
48
+ else {
49
+ rl.setPrompt(`${question} [y/N] `);
50
+ rl.prompt();
51
+ }
52
+ return /^y(es)?$/i.test((await next()).trim());
53
+ },
54
+ close: () => rl?.close(),
55
+ };
56
+ }
57
+ let asker = createAsker(process.stdin, process.stdout);
58
+ const ask = (question) => asker.ask(question);
59
+ /**
60
+ * How to run `claude` with fixed arguments. On Windows `claude` is usually `claude.cmd`, which only a shell can run; the
61
+ * arguments are constants, so the command line is safe for the shell. Elsewhere no shell.
62
+ */
63
+ export function pluginSpawn(args, platform = process.platform) {
64
+ return platform === "win32"
65
+ ? { command: commandLine(args), args: [], options: { stdio: "inherit", shell: true } }
66
+ : { command: "claude", args, options: { stdio: "inherit" } };
67
+ }
68
+ // True when an executable file named `claude` is in a PATH directory.
69
+ function claudeOnPath() {
70
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
71
+ if (dir === "")
72
+ continue;
73
+ for (const name of process.platform === "win32" ? ["claude.exe", "claude.cmd", "claude"] : ["claude"]) {
74
+ const path = join(dir, name);
75
+ try {
76
+ if (!statSync(path).isFile())
77
+ continue;
78
+ accessSync(path, constants.X_OK);
79
+ return true;
80
+ }
81
+ catch {
82
+ // not there or not executable
83
+ }
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+ /** The agent instructions, wrapped in the markers, with `eol` line endings. */
89
+ function wrappedBlock(eol) {
90
+ const block = readFileSync(new URL("../../templates/agents-block.md", import.meta.url), "utf8").replace(/\r\n/g, "\n").replace(/\n+$/, "");
91
+ return `${BEGIN}\n${block}\n${END}\n`.replace(/\n/g, eol);
92
+ }
93
+ const BOM = "\xef\xbb\xbf"; // the UTF-8 byte order mark, as latin1 text
94
+ /**
95
+ * The new bytes of one file, or an error when its markers are not exactly one BEGIN and one END in that order. The file is
96
+ * processed as latin1 (one byte, one character), so every byte outside the block is kept as it is, also a byte order mark
97
+ * and bytes that are not valid UTF-8; the block is written as UTF-8. A marker line starts at column 0 and is outside a
98
+ * fence (the fence rules of the Markdown convention); any such line that starts with "<!-- BEGIN avouch" or
99
+ * "<!-- END avouch" is a marker candidate and must be exactly a marker, trailing white space allowed.
100
+ */
101
+ function planFile(file, content) {
102
+ const all = content.toString("latin1");
103
+ const bom = all.startsWith(BOM) ? BOM : "";
104
+ const body = all.slice(bom.length);
105
+ const eol = body.includes("\r\n") ? "\r\n" : "\n";
106
+ const block = Buffer.from(wrappedBlock(eol), "utf8");
107
+ const lines = body.match(/[^\n]*(\n|$)/g).filter((l, i, every) => l !== "" || i < every.length - 1);
108
+ const { inFence } = fences(lines.map((l) => l.replace(/\r?\n$/, "")));
109
+ const candidates = lines.flatMap((l, i) => (!inFence[i] && (l.startsWith("<!-- BEGIN avouch") || l.startsWith("<!-- END avouch")) ? [i] : []));
110
+ const bad = candidates.find((i) => ![BEGIN, END].includes(lines[i].replace(/[ \t\r\n]+$/, "")));
111
+ if (bad !== undefined) {
112
+ return { file, error: `${file}: line ${bad + 1} is not exactly "${BEGIN}" or "${END}": ${lines[bad].replace(/[ \t\r\n]+$/, "")}; the file is not changed` };
113
+ }
114
+ const begins = candidates.filter((i) => lines[i].startsWith(BEGIN));
115
+ const ends = candidates.filter((i) => lines[i].startsWith(END));
116
+ const latin1 = (text) => Buffer.from(text, "latin1");
117
+ if (candidates.length === 0) {
118
+ const sep = body === "" ? "" : `${/\n$/.test(body) ? "" : eol}${eol}`;
119
+ return { file, action: "append", bytes: Buffer.concat([content, latin1(sep), block]) };
120
+ }
121
+ if (begins.length !== 1 || ends.length !== 1 || begins[0] > ends[0]) {
122
+ return { file, error: `${file}: expected one "${BEGIN}" line and then one "${END}" line, found ${begins.length} and ${ends.length}${begins.length === 1 && ends.length === 1 ? " in the wrong order" : ""}; the file is not changed` };
123
+ }
124
+ const bytes = Buffer.concat([latin1(bom + lines.slice(0, begins[0]).join("")), block, latin1(lines.slice(ends[0] + 1).join(""))]);
125
+ return { file, action: bytes.equals(content) ? "unchanged" : "replace", bytes };
126
+ }
127
+ function printBlock() {
128
+ console.log("Add the text between the two cut lines to AGENTS.md (and, in CLAUDE.md, the line `@AGENTS.md`) yourself:");
129
+ console.log("-----8<----- Avouch agent instructions -----8<-----");
130
+ process.stdout.write(wrappedBlock("\n"));
131
+ console.log("-----8<----- end of the Avouch agent instructions -----8<-----");
132
+ }
133
+ /** Step 1: the Claude Code plugin. Returns false when a plugin command failed. */
134
+ async function plugin(opts, dryRun) {
135
+ console.log("Step 1: the Avouch plugin for Claude Code (a hook that blocks text reads of an ontology, and the skills /avouch:scan and /avouch:cli).");
136
+ if (opts.has("--no-plugin")) {
137
+ console.log("Skipped (--no-plugin).");
138
+ return true;
139
+ }
140
+ const printCommands = (why) => {
141
+ console.log(`${why} To install the plugin, run:`);
142
+ for (const c of PLUGIN_COMMANDS)
143
+ console.log(` ${commandLine(c)}`);
144
+ };
145
+ if (!claudeOnPath()) {
146
+ printCommands("No `claude` executable is on PATH.");
147
+ return true;
148
+ }
149
+ if (dryRun) {
150
+ if (opts.has("--plugin")) {
151
+ console.log("Dry run. These commands would run:");
152
+ for (const c of PLUGIN_COMMANDS)
153
+ console.log(` ${commandLine(c)}`);
154
+ }
155
+ else
156
+ printCommands(`Dry run. Without --plugin, init would ${isTty() ? "ask before it runs these commands" : "only print these commands"}.`);
157
+ return true;
158
+ }
159
+ let run = opts.has("--plugin");
160
+ if (!run) {
161
+ if (!isTty()) {
162
+ printCommands("No terminal, so init does not run the commands.");
163
+ return true;
164
+ }
165
+ run = await ask("Install the Avouch plugin for Claude Code (user scope)?");
166
+ if (!run) {
167
+ printCommands("Not installed.");
168
+ return true;
169
+ }
170
+ }
171
+ let ok = true;
172
+ // Both commands run even when the first fails: adding a marketplace that is already added can fail while the install still works.
173
+ for (const c of PLUGIN_COMMANDS) {
174
+ console.log(`Running: ${commandLine(c)}`);
175
+ const s = pluginSpawn(c);
176
+ const r = spawnSync(s.command, s.args, s.options);
177
+ if (r.error !== undefined || r.status !== 0) {
178
+ ok = false;
179
+ console.error(`FAIL: ${commandLine(c)}: ${r.error !== undefined ? r.error.message : r.status !== null ? `exit ${r.status}` : `signal ${r.signal}`}`);
180
+ }
181
+ }
182
+ return ok;
183
+ }
184
+ /** Step 2: the agent instructions. Returns false when a file could not be written. */
185
+ async function instructions(top, opts, dryRun) {
186
+ console.log("Step 2: the Avouch instructions for coding agents (AGENTS.md, CLAUDE.md).");
187
+ const exists = (name) => existsSync(join(top, name)) && statSync(join(top, name)).isFile();
188
+ const files = [];
189
+ for (const name of TARGETS) {
190
+ if (!exists(name))
191
+ continue;
192
+ if (name === "CLAUDE.md" && exists("AGENTS.md") && readFileSync(join(top, name), "utf8").split(/\r?\n/).some((l) => l.trim() === "@AGENTS.md")) {
193
+ console.log("CLAUDE.md is skipped: it has the line `@AGENTS.md`, so it imports the instructions from AGENTS.md.");
194
+ continue;
195
+ }
196
+ files.push(name);
197
+ }
198
+ let append = opts.has("--append");
199
+ if (!append && !opts.has("--print") && files.length > 0) {
200
+ if (dryRun) {
201
+ console.log("Dry run without --append or --print: this is the plan for --append (init asks in a terminal and prints otherwise).");
202
+ append = true;
203
+ }
204
+ else if (isTty())
205
+ append = await ask(`Append the Avouch instructions to ${files.join(", ")}?`);
206
+ }
207
+ if (!append) {
208
+ printBlock();
209
+ return true;
210
+ }
211
+ if (files.length === 0) {
212
+ console.log(`No file to append to: AGENTS.md and CLAUDE.md do not exist at ${top}, and init never creates a file.`);
213
+ printBlock();
214
+ return true;
215
+ }
216
+ let ok = true;
217
+ for (const name of files) {
218
+ const path = join(top, name);
219
+ const plan = planFile(name, readFileSync(path));
220
+ if ("error" in plan) {
221
+ ok = false;
222
+ console.error(`FAIL: ${plan.error}`);
223
+ continue;
224
+ }
225
+ if (dryRun) {
226
+ console.log(`Dry run: ${name}: ${plan.action === "unchanged" ? "unchanged" : `would ${plan.action === "append" ? "append the block" : "replace the block"}`}`);
227
+ continue;
228
+ }
229
+ if (plan.action === "unchanged") {
230
+ console.log(`${name}: unchanged (the block is up to date)`);
231
+ continue;
232
+ }
233
+ try {
234
+ writeFileSync(path, plan.bytes);
235
+ console.log(`${name}: ${plan.action === "append" ? "appended the block" : "replaced the block"}`);
236
+ }
237
+ catch (error) {
238
+ ok = false;
239
+ console.error(`FAIL: ${name}: ${error.message}`);
240
+ }
241
+ }
242
+ return ok;
243
+ }
244
+ /** `avouch init`. Exit 0: done (also the print fallback and a dry run); 1: a plugin command failed or a file could not be written; 2: a usage error or not in a git repository. */
245
+ export async function runInit(args) {
246
+ if (wantsHelp(args, [])) {
247
+ console.log(INIT_USAGE);
248
+ return 0;
249
+ }
250
+ let opts;
251
+ try {
252
+ const parsed = parseOptions(args, [], FLAGS);
253
+ expectPositionals(parsed.pos, 0);
254
+ opts = parsed.opts;
255
+ if (opts.has("--append") && opts.has("--print"))
256
+ throw new Error("--append and --print cannot be used together");
257
+ if (opts.has("--plugin") && opts.has("--no-plugin"))
258
+ throw new Error("--plugin and --no-plugin cannot be used together");
259
+ }
260
+ catch (error) {
261
+ console.error((error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " "));
262
+ console.error(INIT_USAGE);
263
+ return 2;
264
+ }
265
+ const top = gitTop(process.cwd());
266
+ if (top === null) {
267
+ console.error("avouch init must run inside a git repository (or git is not available)");
268
+ return 2;
269
+ }
270
+ const dryRun = opts.has("--dry-run");
271
+ let pluginOk, filesOk;
272
+ try {
273
+ pluginOk = await plugin(opts, dryRun);
274
+ filesOk = await instructions(top, opts, dryRun);
275
+ }
276
+ finally {
277
+ asker.close();
278
+ asker = createAsker(process.stdin, process.stdout);
279
+ }
280
+ console.log("Step 3: next, run `/avouch:scan` in Claude Code at the top level of the repository. It drafts avouch.config.json, a first ontology and the document review ledger. The scan skill is for Claude Code only.");
281
+ return pluginOk && filesOk ? 0 : 1;
282
+ }
@@ -0,0 +1,5 @@
1
+ export declare function jsonLines(file: string, text?: string): {
2
+ line: number;
3
+ value: unknown;
4
+ }[];
5
+ export declare function readJsonLines(file: string): unknown[];
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from "node:fs";
2
+ // One JSON value per line, with its line number (from 1). Lines that are empty or only white space are skipped; the
3
+ // line numbers count them. `text`: the text of the file, when the caller has read it.
4
+ export function jsonLines(file, text = readFileSync(file, "utf8")) {
5
+ return text
6
+ .split("\n")
7
+ .flatMap((text, i) => {
8
+ if (text.trim() === "")
9
+ return [];
10
+ try {
11
+ return [{ line: i + 1, value: JSON.parse(text) }];
12
+ }
13
+ catch (error) {
14
+ throw new Error(`${file}:${i + 1}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
15
+ }
16
+ });
17
+ }
18
+ export function readJsonLines(file) {
19
+ return jsonLines(file).map((x) => x.value);
20
+ }
@@ -0,0 +1,2 @@
1
+ export declare const QUERY_USAGE = "usage: avouch query <list|action|object|result|writes|reads|cites|link> <arg> [-f <ontology.yaml>] [--json] [--check] [--config <avouch.config.json>] [--adapter <module|config.json>]";
2
+ export declare function runQuery(args: string[]): Promise<number>;
@@ -0,0 +1,138 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { dirname, relative, resolve, sep } from "node:path";
4
+ import { parse } from "yaml";
5
+ import { isObj } from "../src/expr.js";
6
+ import { QUERY_LIST_KINDS, queryAction, queryCites, queryDisposition, queryLink, queryList, queryObject, queryReads, queryWrites, } from "../src/query.js";
7
+ import { checkStamp, resolveQueryProject } from "./check.js";
8
+ import { expectPositionals, parseOptions, wantsHelp } from "./reconcile.js";
9
+ export const QUERY_USAGE = "usage: avouch query <list|action|object|result|writes|reads|cites|link> <arg> [-f <ontology.yaml>] [--json] [--check] [--config <avouch.config.json>] [--adapter <module|config.json>]";
10
+ const VERBS = ["list", "action", "object", "result", "writes", "reads", "cites", "link"];
11
+ // Read-only git commands, run from the directory of the ontology file. null when any of them fails.
12
+ function gitStamp(file) {
13
+ const git = (...args) => {
14
+ const r = spawnSync("git", ["--no-optional-locks", ...args], { cwd: dirname(file), encoding: "utf8" });
15
+ return r.error !== undefined || r.status !== 0 ? null : r.stdout;
16
+ };
17
+ const commit = git("rev-parse", "HEAD");
18
+ const top = git("rev-parse", "--show-toplevel");
19
+ const status = git("status", "--porcelain", "--", file);
20
+ if (commit === null || top === null || status === null)
21
+ return null;
22
+ // status prints nothing for an ignored file; a file that is not tracked is in no commit, so it is dirty.
23
+ const tracked = git("ls-files", "--error-unmatch", "--", file);
24
+ return { commit: commit.trim(), dirty: status.trim() !== "" || tracked === null, path: relative(top.trim(), file).split(sep).join("/") };
25
+ }
26
+ function run(verb, arg, ont) {
27
+ switch (verb) {
28
+ case "list": {
29
+ const r = queryList(ont, arg);
30
+ return { result: r, empty: r === null || r.items.length === 0 };
31
+ }
32
+ case "cites": {
33
+ const r = queryCites(ont, arg);
34
+ return { result: r, empty: r.claims.length === 0 };
35
+ }
36
+ default: {
37
+ const f = { action: queryAction, object: queryObject, result: queryDisposition, writes: queryWrites, reads: queryReads, link: queryLink }[verb];
38
+ const r = f(ont, arg);
39
+ return { result: r, empty: r === null };
40
+ }
41
+ }
42
+ }
43
+ // ---------------------------------------------------------------------------------------------------------- text
44
+ const isScalar = (x) => x === null || typeof x !== "object";
45
+ const isCite = (x) => isObj(x) && Object.keys(x).length === 2 && typeof x.doc === "string" && (typeof x.quote === "string" || x.quote === null);
46
+ // A scalar on one line: a line break becomes the two characters \n (or \r).
47
+ const esc = (x) => String(x).replace(/\n/g, "\\n").replace(/\r/g, "\\r");
48
+ // The one-line form of a value, or undefined when it needs a block.
49
+ function inline(v) {
50
+ if (v === null || v === undefined)
51
+ return "-";
52
+ if (typeof v !== "object")
53
+ return esc(v);
54
+ if (isCite(v))
55
+ return v.quote === null ? `[${esc(v.doc)}]` : `[${esc(v.doc)}] ${esc(v.quote)}`;
56
+ if (Array.isArray(v)) {
57
+ if (v.length === 0)
58
+ return "-";
59
+ return v.every(isScalar) ? v.map((x) => (x === null ? "-" : esc(x))).join(", ") : undefined;
60
+ }
61
+ return Object.keys(v).length === 0 ? "-" : undefined;
62
+ }
63
+ function block(v, pad) {
64
+ if (Array.isArray(v))
65
+ return v.flatMap((el) => {
66
+ const s = inline(el);
67
+ if (s !== undefined)
68
+ return [`${pad}- ${s}`];
69
+ const sub = block(el, `${pad} `);
70
+ if (sub.length === 0)
71
+ return [`${pad}- -`];
72
+ return [`${pad}- ${sub[0].slice(pad.length + 2)}`, ...sub.slice(1)];
73
+ });
74
+ const o = v;
75
+ return Object.keys(o)
76
+ .filter((k) => !(k === "expr" && "exprText" in o))
77
+ .flatMap((k) => {
78
+ const s = inline(o[k]);
79
+ return s !== undefined ? [`${pad}${k}: ${s}`] : [`${pad}${k}:`, ...block(o[k], `${pad} `)];
80
+ });
81
+ }
82
+ function text(env) {
83
+ const { stamp } = env;
84
+ const c = stamp.check;
85
+ const lines = [
86
+ `ontology: ${stamp.ontology}`,
87
+ stamp.git === null ? "commit: none (not in git)" : `commit: ${stamp.git.commit}${stamp.git.dirty ? " (dirty)" : ""}`,
88
+ c.status === "not_run" ? "check: not_run" : `check: ${c.status} (${c.schemaErrors} schema error(s), ${c.violations} violation(s), ${c.waived} waived)`,
89
+ "",
90
+ ];
91
+ const s = inline(env.result);
92
+ return [...lines, ...(s !== undefined ? [s] : block(env.result, ""))].join("\n") + "\n";
93
+ }
94
+ // ---------------------------------------------------------------------------------------------------------- main
95
+ function parseArgs(args) {
96
+ const { pos, opts } = parseOptions(args, ["-f", "--config", "--adapter"], ["--json", "--check"]);
97
+ expectPositionals(pos, 2);
98
+ const json = opts.get("--json") === true;
99
+ const [verb, arg] = pos;
100
+ if (!VERBS.includes(verb))
101
+ throw new Error(`unknown verb ${verb}`);
102
+ if (verb === "list" && !QUERY_LIST_KINDS.includes(arg))
103
+ throw new Error(`unknown list kind ${arg}`);
104
+ if ((verb === "writes" || verb === "reads") && arg.split(".").length !== 2)
105
+ throw new Error(`${verb} takes <Object>.<property>`);
106
+ const { file, adapter } = resolveQueryProject(opts);
107
+ return { verb: verb, arg, file, json, adapter };
108
+ }
109
+ async function query(args) {
110
+ const { verb, arg, file, json, adapter } = parseArgs(args);
111
+ const real = realpathSync(resolve(file));
112
+ const ont = parse(readFileSync(real, "utf8"));
113
+ if (!isObj(ont))
114
+ throw new Error(`${file}: the root is not a mapping`);
115
+ const check = await checkStamp(file, adapter);
116
+ const { result, empty } = run(verb, arg, ont);
117
+ const git = gitStamp(real);
118
+ // the stamp holds no absolute path: the path in the repository, or else the path from the working directory
119
+ const stampPath = git?.path ?? relative(realpathSync(process.cwd()), real).split(sep).join("/");
120
+ const env = { avouchQuery: 1, stamp: { ontology: stampPath, git, check }, verb, arg, result };
121
+ process.stdout.write(json ? `${JSON.stringify(env, null, 2)}\n` : text(env));
122
+ return empty ? 1 : 0;
123
+ }
124
+ // Exit 0: a result; 1: no result (null or an empty list); 2: a usage, file, parse or adapter error (one stderr line).
125
+ export async function runQuery(args) {
126
+ if (wantsHelp(args, ["-f", "--config", "--adapter"])) {
127
+ console.log(QUERY_USAGE);
128
+ return 0;
129
+ }
130
+ try {
131
+ return await query(args);
132
+ }
133
+ catch (error) {
134
+ const msg = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ");
135
+ console.error(`${msg}; ${QUERY_USAGE}`);
136
+ return 2;
137
+ }
138
+ }
@@ -0,0 +1,9 @@
1
+ export declare const RECONCILE_USAGE = "usage: avouch reconcile [-f <ontology.yaml>] [--config <avouch.config.json>] --cases <cases.jsonl>";
2
+ export declare function parseOptions(args: string[], values: readonly string[], flags: readonly string[]): {
3
+ pos: string[];
4
+ opts: Map<string, string | true>;
5
+ };
6
+ export declare function wantsHelp(args: readonly string[], values: readonly string[]): boolean;
7
+ export declare function expectPositionals(pos: string[], n: number): void;
8
+ export declare function readOntology(file: string): Record<string, unknown>;
9
+ export declare function runReconcile(args: string[]): Promise<number>;