agent-doc-system 0.0.0-stage → 1.1.0

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 (124) hide show
  1. package/INSTALL_FOR_AGENTS.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +98 -2
  4. package/adapters/ansible.mjs +306 -0
  5. package/adapters/cache.mjs +27 -0
  6. package/adapters/contract-consumers.mjs +56 -0
  7. package/adapters/database.mjs +116 -0
  8. package/adapters/deploy-config.mjs +34 -0
  9. package/adapters/dockerfile.mjs +26 -0
  10. package/adapters/events.mjs +145 -0
  11. package/adapters/generic.mjs +86 -0
  12. package/adapters/github-actions.mjs +106 -0
  13. package/adapters/go.mjs +91 -0
  14. package/adapters/graphql.mjs +28 -0
  15. package/adapters/index.mjs +56 -0
  16. package/adapters/node.mjs +118 -0
  17. package/adapters/object-store.mjs +64 -0
  18. package/adapters/oidc.mjs +56 -0
  19. package/adapters/openapi.mjs +50 -0
  20. package/adapters/pnpm.mjs +42 -0
  21. package/adapters/protobuf.mjs +35 -0
  22. package/adapters/registry.mjs +27 -0
  23. package/adapters/resource-client.mjs +61 -0
  24. package/adapters/turborepo.mjs +135 -0
  25. package/adapters/typescript.mjs +85 -0
  26. package/adapters/wrangler.mjs +206 -0
  27. package/bin/agentdoc.mjs +494 -0
  28. package/bin/commands.mjs +169 -0
  29. package/bin/usage.txt +28 -0
  30. package/core/acceptances.mjs +69 -0
  31. package/core/audit.mjs +456 -0
  32. package/core/authority/classes.mjs +39 -0
  33. package/core/authority/engine.mjs +266 -0
  34. package/core/authority/facts.mjs +41 -0
  35. package/core/codes.mjs +126 -0
  36. package/core/compile.mjs +1018 -0
  37. package/core/contracts.mjs +121 -0
  38. package/core/derive.mjs +102 -0
  39. package/core/descriptors.mjs +489 -0
  40. package/core/determinism.mjs +95 -0
  41. package/core/discover.mjs +147 -0
  42. package/core/facts.mjs +53 -0
  43. package/core/fsx.mjs +348 -0
  44. package/core/graph.mjs +260 -0
  45. package/core/impact.mjs +175 -0
  46. package/core/indexes.mjs +165 -0
  47. package/core/jsonschema.mjs +298 -0
  48. package/core/normative.mjs +171 -0
  49. package/core/observations.mjs +129 -0
  50. package/core/provenance.mjs +83 -0
  51. package/core/query.mjs +384 -0
  52. package/core/relations.mjs +286 -0
  53. package/core/scaffold.mjs +322 -0
  54. package/core/secrets.mjs +92 -0
  55. package/core/sourcescan.mjs +88 -0
  56. package/core/toml.mjs +163 -0
  57. package/core/yaml.mjs +477 -0
  58. package/docs/ADAPTERS.md +207 -0
  59. package/docs/AUTHORITY.md +269 -0
  60. package/docs/CI.md +138 -0
  61. package/docs/COMPARISON-KODA.md +164 -0
  62. package/docs/EVALS.md +166 -0
  63. package/docs/MIGRATION.md +261 -0
  64. package/docs/PROVENANCE.md +133 -0
  65. package/docs/QUERY.md +180 -0
  66. package/docs/SPEC.md +339 -0
  67. package/docs/VERIFICATION.md +28 -0
  68. package/docs/assets/readme/agentdoc-hero.webp +0 -0
  69. package/docs/assets/readme/agentdoc-in-action.webp +0 -0
  70. package/docs/assets/readme/how-agentdoc-works.webp +0 -0
  71. package/docs/assets/readme/why-agentdoc.webp +0 -0
  72. package/docs/ci/generic-ci.sh +28 -0
  73. package/docs/ci/github-actions.yml +35 -0
  74. package/evals/harness.mjs +426 -0
  75. package/evals/scenarios/fixture-a-01-change-api.json +9 -0
  76. package/evals/scenarios/fixture-a-02-change-db.json +9 -0
  77. package/evals/scenarios/fixture-a-03-enforce-constraint.json +9 -0
  78. package/evals/scenarios/fixture-b-01-change-proto.json +9 -0
  79. package/evals/scenarios/fixture-b-02-change-write-path.json +9 -0
  80. package/evals/scenarios/fixture-b-03-change-shared-lib.json +9 -0
  81. package/evals/scenarios/fixture-b-04-journey.json +9 -0
  82. package/evals/scenarios/fixture-c-01-mixed-runtime-edge.json +19 -0
  83. package/evals/scenarios/fixture-c-02-stale-doc-contradiction.json +9 -0
  84. package/evals/scenarios/fixture-c-03-shared-vocabulary.json +9 -0
  85. package/evals/scenarios/fixture-c-04-contract-change.json +9 -0
  86. package/evals/scenarios/koda-01-modify-api-consumer.json +26 -0
  87. package/evals/scenarios/koda-02-change-db-schema.json +20 -0
  88. package/evals/scenarios/koda-03-change-auth.json +21 -0
  89. package/evals/scenarios/koda-04-add-event-producer.json +19 -0
  90. package/evals/scenarios/koda-05-modify-worker-binding.json +25 -0
  91. package/evals/scenarios/koda-06-change-cross-component-endpoint.json +22 -0
  92. package/evals/scenarios/koda-07-change-shared-package.json +16 -0
  93. package/evals/scenarios/koda-08-change-runtime-cron.json +23 -0
  94. package/evals/scenarios/koda-09-debug-production-mismatch.json +26 -0
  95. package/evals/thresholds.json +11 -0
  96. package/package.json +46 -4
  97. package/schemas/api.schema.json +84 -0
  98. package/schemas/common.schema.json +147 -0
  99. package/schemas/component.schema.json +44 -0
  100. package/schemas/config.schema.json +801 -0
  101. package/schemas/domain.schema.json +14 -0
  102. package/schemas/graph.schema.json +1084 -0
  103. package/schemas/journeys.schema.json +47 -0
  104. package/schemas/observation.schema.json +143 -0
  105. package/schemas/resource.schema.json +28 -0
  106. package/schemas/system.schema.json +26 -0
  107. package/skills/agent-doc-system/SKILL.md +117 -0
  108. package/skills/agent-doc-system/references/authority-and-conflicts.md +70 -0
  109. package/skills/agent-doc-system/references/cli.md +97 -0
  110. package/skills/agent-doc-system/references/create-migrate.md +113 -0
  111. package/skills/agent-doc-system/references/maintenance.md +52 -0
  112. package/templates/ADR.md +26 -0
  113. package/templates/ARCHITECTURE.md +19 -0
  114. package/templates/CONSTRAINTS.md +17 -0
  115. package/templates/PRODUCT.md +17 -0
  116. package/templates/api.yaml +10 -0
  117. package/templates/ci.yml +31 -0
  118. package/templates/component.yaml +14 -0
  119. package/templates/domain.yaml +6 -0
  120. package/templates/journey.yaml +7 -0
  121. package/templates/observation.yaml +19 -0
  122. package/templates/resource.yaml +8 -0
  123. package/templates/system.yaml +10 -0
  124. package/templates/warning-acceptance.yaml +14 -0
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env node
2
+ // agentdoc — portable documentation & software-catalog system.
3
+ //
4
+ // The command list lives in bin/usage.txt and is printed from there, so this
5
+ // header, `--help` and the shipped file cannot drift into three different
6
+ // claims about what the tool does. The flags each command accepts live in
7
+ // bin/commands.mjs, which drives both parsing and `agentdoc <command> --help`.
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { AgentDocError, CODES } from "../core/codes.mjs";
12
+ import { compile, sha256Hex } from "../core/compile.mjs";
13
+ import { Repo } from "../core/fsx.mjs";
14
+ import { assertFresh, readGraph, COMPILER_VERSION, GRAPH_SCHEMA_VERSION, COMPILER_NAME } from "../core/graph.mjs";
15
+ import { queryContext, renderContext } from "../core/query.mjs";
16
+ import { impact } from "../core/impact.mjs";
17
+ import { audit, scaffoldProposal } from "../core/audit.mjs";
18
+ import { observationFreshness, stalenessDiagnostics } from "../core/observations.mjs";
19
+ import { assertNoSecretsInText } from "../core/secrets.mjs";
20
+ import { installTemplates } from "../core/scaffold.mjs";
21
+ import { COMMANDS, flagsOf, parseArgs, commandHelp, UsageError } from "./commands.mjs";
22
+ import { loadSchemaBundle, loadConfig } from "../core/descriptors.mjs";
23
+ import { execFileSync } from "node:child_process";
24
+
25
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
26
+ const PACKAGE_ROOT = path.join(HERE, "..");
27
+ const VERSION = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")).version;
28
+ const MIN_NODE_MAJOR = 22;
29
+ // Commands are run from wherever the agent happens to be. Walk up until the
30
+ // configuration is found, so `agentdoc query src/foo.ts` works from a
31
+ // subdirectory instead of failing with a misleading missing-file error.
32
+ function findRoot(start) {
33
+ let dir = start;
34
+ for (let i = 0; i < 24; i++) {
35
+ if (fs.existsSync(path.join(dir, "agentdoc", "agentdoc.config.yaml"))) return dir;
36
+ const up = path.dirname(dir);
37
+ if (up === dir) break;
38
+ dir = up;
39
+ }
40
+ return start;
41
+ }
42
+
43
+ const ROOT = findRoot(process.cwd());
44
+ const argv = process.argv.slice(2);
45
+ const cmd = argv[0];
46
+ // Flags are declared per command in bin/commands.mjs. An undeclared flag is a
47
+ // usage error (exit 2), so a typo in a gate flag can never pass for the gate.
48
+ let parsed = { flags: new Map(), positional: [] };
49
+ if (Object.prototype.hasOwnProperty.call(COMMANDS, cmd)) {
50
+ try {
51
+ parsed = parseArgs(cmd, argv.slice(1));
52
+ } catch (e) {
53
+ if (!(e instanceof UsageError)) throw e;
54
+ console.error(e.message);
55
+ process.exit(2);
56
+ }
57
+ }
58
+ const positional = parsed.positional;
59
+ // The only way to read a flag. Reading one the command does not declare is a
60
+ // defect in this file, not user input: it would be a flag `--help` never shows.
61
+ function opt(name) {
62
+ if (!Object.prototype.hasOwnProperty.call(flagsOf(cmd), name)) {
63
+ throw new Error("flag --" + name + " is read by '" + cmd + "' but not declared for it in bin/commands.mjs");
64
+ }
65
+ return parsed.flags.has(name) ? parsed.flags.get(name) : null;
66
+ }
67
+
68
+ function fail(errors, stats) {
69
+ for (const e of errors) console.error("ERROR " + (e.format ? e.format() : e.code + ": " + e.message));
70
+ if (stats) console.error("agentdoc: " + JSON.stringify(stats));
71
+ process.exit(1);
72
+ }
73
+
74
+ function usage(exitCode) {
75
+ const text = fs.readFileSync(path.join(HERE, "usage.txt"), "utf8");
76
+ (exitCode === 0 ? process.stdout : process.stderr).write(text);
77
+ process.exit(exitCode);
78
+ }
79
+
80
+ function printDiagnostics(res) {
81
+ for (const d of res.diagnostics || []) {
82
+ const prefix = d.severity === "error" ? "ERROR " : d.acceptance ? "WARN ACCEPTED " : "WARN ";
83
+ console.log(prefix + d.code + ": " + d.message);
84
+ if (d.acceptance) console.log(" " + d.acceptance.classification + ": " + d.acceptance.reason);
85
+ }
86
+ }
87
+
88
+ function gateObservationFreshness(res) {
89
+ // Time-dependent gates run before artifact gates. A committed graph can be
90
+ // stale for many reasons; when the cause is an observation that has aged out
91
+ // of its promise, saying "rebuild the graph" sends the reader down the wrong
92
+ // path entirely.
93
+ const abs = path.join(ROOT, res.graphPath);
94
+ if (!fs.existsSync(abs)) return null;
95
+ const { graph } = readGraph(res.repo, res.graphPath);
96
+ const fresh = observationFreshness(graph.observations || [], new Date());
97
+ const stale = stalenessDiagnostics(fresh);
98
+ if (!stale.length) return fresh;
99
+ if (!opt("allow-stale-observations")) {
100
+ for (const d of stale) console.error("ERROR " + d.code + ": " + d.message);
101
+ process.exit(1);
102
+ }
103
+ // Staleness was explicitly allowed through. This is where a rule's
104
+ // degradeOnStale is applied — at gate time, never at compile time, so a
105
+ // compile stays a pure function of the checkout. `conflict` keeps the
106
+ // election and warns; `unresolved` refuses to stand on a stale observation.
107
+ // Match the elected assertion to the stale observation *set* by capturedAt
108
+ // + environment — capturedAt alone can collide across sets.
109
+ const staleSets = new Set(fresh.filter((f) => f.stale).map((f) => f.capturedAt + "\u0000" + f.environment));
110
+ const assertionById = new Map((graph.assertions || []).map((a) => [a.id, a]));
111
+ const rules = new Map((((res.cfg || {}).authority || {}).rules || []).map((r) => [r.id, r]));
112
+ const degraded = [];
113
+ for (const c of graph.conflicts || []) {
114
+ if (c.status !== "resolved" || !c.election || !c.election.electedAssertionId) continue;
115
+ const rule = rules.get(c.election.ruleId);
116
+ if (!rule || !rule.degradeOnStale) continue;
117
+ const elected = assertionById.get(c.election.electedAssertionId);
118
+ const obs = elected && elected.observed;
119
+ if (!obs || !obs.at || !staleSets.has(obs.at + "\u0000" + obs.environment)) continue;
120
+ if (rule.degradeOnStale === "unresolved") {
121
+ degraded.push(
122
+ c.subject + " " + c.key + " — the elected observation (" + obs.at + ") is past its maxAgeDays " +
123
+ "and authority rule " + rule.id + " requires the election to degrade to unresolved"
124
+ );
125
+ } else {
126
+ console.error(
127
+ "WARN " + CODES.ELECTION_STALE + ": " + c.subject + " " + c.key +
128
+ " — the elected observation (" + obs.at + ") is past its maxAgeDays; authority rule " +
129
+ rule.id + " keeps the election"
130
+ );
131
+ }
132
+ }
133
+ if (degraded.length) {
134
+ for (const m of degraded) console.error("ERROR " + CODES.CONFLICT_UNRESOLVED + ": " + m);
135
+ process.exit(1);
136
+ }
137
+ return fresh;
138
+ }
139
+
140
+ function requireFresh(res, fresh) {
141
+ const abs = path.join(ROOT, res.graphPath);
142
+ if (!fs.existsSync(abs)) {
143
+ fail([new AgentDocError(CODES.GRAPH_MISSING, "no compiled graph at " + res.graphPath + " — run `agentdoc compile`")]);
144
+ }
145
+ const { graph, text } = readGraph(res.repo, res.graphPath);
146
+ try {
147
+ assertFresh(graph, {
148
+ inputHash: res.stats.inputHash,
149
+ dirty: res.stats.dirty,
150
+ compilerVersion: COMPILER_VERSION,
151
+ schemaVersion: GRAPH_SCHEMA_VERSION,
152
+ });
153
+ } catch (e) {
154
+ fail([e]);
155
+ }
156
+ if (graph.source.dirty && opt("require-clean")) {
157
+ fail([new AgentDocError(
158
+ CODES.DIRTY_SOURCE,
159
+ "catalog inputs differ from HEAD (" + res.stats.commit + ") — CI/publish evidence requires a clean source; commit the change and rebuild"
160
+ )], res.stats);
161
+ }
162
+ // `fresh` is normally precomputed by the caller — gateObservationFreshness
163
+ // also evaluates election degradation, so calling it twice would print the
164
+ // same staleness warning twice. The fallback keeps a forgotten call-site safe.
165
+ return { graph, text, fresh: fresh === undefined ? (gateObservationFreshness(res) || []) : fresh };
166
+ }
167
+
168
+ function out(obj, asJson) {
169
+ const text = asJson ? JSON.stringify(obj, null, 2) + "\n" : null;
170
+ if (text) assertNoSecretsInText(text, "output");
171
+ process.stdout.write(text || obj + "\n");
172
+ }
173
+
174
+ try {
175
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
176
+ console.log("agentdoc " + VERSION);
177
+ process.exit(0);
178
+ }
179
+ if (cmd === "--help" || cmd === "-h" || cmd === "help") {
180
+ // `agentdoc help <command>` is the same as `agentdoc <command> --help`.
181
+ if (Object.prototype.hasOwnProperty.call(COMMANDS, argv[1])) {
182
+ process.stdout.write(commandHelp(argv[1]));
183
+ process.exit(0);
184
+ }
185
+ usage(0);
186
+ }
187
+ if (Object.prototype.hasOwnProperty.call(COMMANDS, cmd) && opt("help")) {
188
+ process.stdout.write(commandHelp(cmd));
189
+ process.exit(0);
190
+ }
191
+ if (cmd === "validate") {
192
+ const res = compile(ROOT);
193
+ printDiagnostics(res);
194
+ if (res.errors.length) fail(res.errors, res.stats);
195
+ console.log("agentdoc validate: PASS " + JSON.stringify(res.stats));
196
+ } else if (cmd === "compile") {
197
+ const res = compile(ROOT);
198
+ printDiagnostics(res);
199
+ if (res.errors.length) fail(res.errors, res.stats);
200
+ if (opt("require-clean") && res.stats.dirty) {
201
+ fail([new AgentDocError(CODES.DIRTY_SOURCE, "catalog inputs differ from HEAD — commit the change and rebuild")], res.stats);
202
+ }
203
+ fs.mkdirSync(path.dirname(path.join(ROOT, res.graphPath)), { recursive: true });
204
+ const tmp = path.join(ROOT, res.graphPath) + ".tmp-" + process.pid;
205
+ fs.writeFileSync(tmp, res.serialized);
206
+ fs.renameSync(tmp, path.join(ROOT, res.graphPath));
207
+ console.log("agentdoc compile: PASS " + JSON.stringify(res.stats));
208
+ console.log("graph: " + res.graphPath);
209
+ } else if (cmd === "check") {
210
+ const res = compile(ROOT);
211
+ printDiagnostics(res);
212
+ if (res.errors.length) fail(res.errors, res.stats);
213
+ const preFresh = gateObservationFreshness(res);
214
+ const { graph, text, fresh } = requireFresh(res, preFresh);
215
+ // `source.commit` records which checkout produced the graph, and a checkout
216
+ // without VCS — an extracted archive, a vendored dependency — compiles to
217
+ // `0000000`. That is a difference in provenance, not in content, and failing
218
+ // on it would make a committed graph unusable in exactly the situation it
219
+ // is most needed: shipped as a file, with no history. Everything else must
220
+ // still match byte for byte.
221
+ const withoutCommit = (t) => t.replace(/^(\s*)"commit": "[0-9a-f]*",$/m, '$1"commit": "<provenance>",');
222
+ if (withoutCommit(text) !== withoutCommit(res.serialized)) {
223
+ fail([new AgentDocError(
224
+ CODES.STALE_GRAPH,
225
+ "compiled graph differs from a fresh deterministic compile — commit the descriptor or authority change and rebuild"
226
+ )]);
227
+ }
228
+ const gj = JSON.stringify(graph, null, 2) + "\n";
229
+ if (gj !== text) {
230
+ fail([new AgentDocError(CODES.STALE_GRAPH, "compiled graph is not canonically serialized — rebuild the graph")]);
231
+ }
232
+ // `--strict` is the migration gate: the graph is current *and* nothing is
233
+ // left for a human. Every warning is either accepted with pinned evidence
234
+ // or the gate fails, so a normative section nobody reviewed or a
235
+ // requirement nothing proves cannot ride along in a green check. Opt-in, so
236
+ // plain `check` keeps meaning "the committed graph is fresh".
237
+ if (opt("strict")) {
238
+ const open = (res.diagnostics || []).filter((d) => d.severity === "warning" && !d.acceptance);
239
+ if (open.length) {
240
+ fail(
241
+ open.map((d) => new AgentDocError(d.code, (d.subject && !d.message.includes(d.subject) ? d.subject + ": " : "") + d.message)),
242
+ { ...res.stats, strict: true }
243
+ );
244
+ }
245
+ }
246
+ console.log("agentdoc check: PASS " + JSON.stringify({ ...res.stats, observationFreshness: fresh.length, ...(opt("strict") ? { strict: true } : {}) }));
247
+ } else if (cmd === "query") {
248
+ const needle = positional[0];
249
+ if (!needle) { console.error("usage: agentdoc query <repo-path|entity-ref> [--json] [--md] [--budget relations=10,verification=4]"); process.exit(2); }
250
+ const res = compile(ROOT);
251
+ if (res.errors.length) fail(res.errors, res.stats);
252
+ // Time-dependent gates first, for the same reason as `check`: a stale
253
+ // observation is a different problem from a stale graph and sends the reader
254
+ // somewhere else entirely.
255
+ const preFresh = gateObservationFreshness(res);
256
+ const { graph } = requireFresh(res, preFresh);
257
+ // `--budget` exists because the default budgets are generous enough that a
258
+ // focused query never truncates, which makes the truncation contract
259
+ // untestable and unreachable from the CLI. An agent working in a tight
260
+ // context needs to be able to say so.
261
+ let budget = {};
262
+ const raw = opt("budget");
263
+ if (raw === true || raw === "") {
264
+ console.error("usage: --budget section=limit,... (limits are positive integers)");
265
+ process.exit(2);
266
+ }
267
+ if (raw) {
268
+ for (const pair of raw.split(",")) {
269
+ const [k, v] = pair.split("=");
270
+ if (!k || !/^\d+$/.test(String(v))) {
271
+ console.error("usage: --budget section=limit,... (limits are positive integers)");
272
+ process.exit(2);
273
+ }
274
+ budget[k.trim()] = Number(v);
275
+ }
276
+ }
277
+ const ctx = queryContext(graph, needle, { budget });
278
+ if (opt("md")) out(renderContext(ctx));
279
+ else if (opt("json")) out(ctx, true);
280
+ else out(renderContext(ctx));
281
+ } else if (cmd === "impact") {
282
+ // Impact answers a question about the working tree, so it compiles in
283
+ // memory rather than reading the committed graph. Refusing a stale graph
284
+ // would be right for `query` (which feeds a prompt) and wrong here: the
285
+ // whole point of impact is to run *before* you rebuild.
286
+ const res = compile(ROOT);
287
+ if (res.errors.length) fail(res.errors, res.stats);
288
+ const graph = res.graph;
289
+ const fromRef = opt("diff") || opt("from");
290
+ let paths = positional;
291
+ if (fromRef && fromRef !== true) {
292
+ if (!res.repo.hasGit()) {
293
+ console.error("agentdoc impact --diff requires a git repository; this checkout has no VCS");
294
+ process.exit(2);
295
+ }
296
+ paths = res.repo.diffPaths(fromRef);
297
+ if (paths === null) {
298
+ console.error("cannot resolve diff ref '" + fromRef + "' — is it a valid commit, branch or range?");
299
+ process.exit(2);
300
+ }
301
+ if (!paths.length) {
302
+ console.error("no changed files between " + fromRef + " and HEAD");
303
+ process.exit(2);
304
+ }
305
+ }
306
+ if (!paths.length) {
307
+ console.error("usage: agentdoc impact <path...> | agentdoc impact --diff <git-ref>");
308
+ process.exit(2);
309
+ }
310
+ out(impact(graph, paths), true);
311
+ } else if (cmd === "audit") {
312
+ const res = compile(ROOT);
313
+ if (!res.graph) {
314
+ // An audit must work on a repository that cannot compile yet; report the
315
+ // compilation failures as part of the audit rather than aborting.
316
+ const report = {
317
+ compileErrors: res.errors.map((e) => ({ code: e.code, message: e.message, path: e.path || null, ref: e.ref || null })),
318
+ stats: res.stats,
319
+ };
320
+ out(report, true);
321
+ process.exit(0);
322
+ }
323
+ const report = audit(res.repo, res.cfg, res);
324
+ report.scaffold = scaffoldProposal(res.repo, res.cfg, report);
325
+ out(report, true);
326
+ } else if (cmd === "scaffold") {
327
+ const res = compile(ROOT);
328
+ const write = opt("write");
329
+ // Scaffolding is how a repository reaches a compiling state: requiring the
330
+ // compile to succeed first deadlocks CREATE on every repository whose units
331
+ // are discovered by adapters (go.work, pnpm-workspace, supplementalRoots)
332
+ // rather than already carrying descriptors. When a usable configuration
333
+ // exists, write what can be proven and report what still blocks the compile.
334
+ if (!res.cfg) {
335
+ out({
336
+ scaffolded: [],
337
+ blocked: res.errors.map((e) => ({ code: e.code, message: e.message })),
338
+ note: "the repository cannot compile yet; install the configuration first with `agentdoc init`",
339
+ }, true);
340
+ process.exit(1);
341
+ }
342
+ const report = res.graph ? audit(res.repo, res.cfg, res) : null;
343
+ const proposal = report ? scaffoldProposal(res.repo, res.cfg, report) : null;
344
+ const result = installTemplates(res.repo, res.cfg, {
345
+ write: Boolean(write),
346
+ eligible: res.discovery && res.discovery.eligible,
347
+ });
348
+ out({
349
+ ...result,
350
+ ...(proposal ? { proposal } : {}),
351
+ ...(res.errors.length
352
+ ? { blocked: res.errors.map((e) => ({ code: e.code, message: e.message })) }
353
+ : {}),
354
+ }, true);
355
+ } else if (cmd === "init") {
356
+ const result = installTemplates(null, null, { write: true, root: ROOT, force: opt("force") });
357
+ out(result, true);
358
+ } else if (cmd === "eval") {
359
+ const which = positional[0] || "routing";
360
+ if (which !== "routing") { console.error("usage: agentdoc eval routing [--json]"); process.exit(2); }
361
+ // The harness ships, but it is loaded lazily so a trimmed or damaged
362
+ // installation cannot make every other command fail on a module the user
363
+ // never asked for.
364
+ let runRoutingEval;
365
+ try {
366
+ ({ runRoutingEval } = await import("../evals/harness.mjs"));
367
+ } catch (e) {
368
+ if (e && e.code === "ERR_MODULE_NOT_FOUND") {
369
+ console.error("agentdoc eval: the evaluation harness is missing from this installation — reinstall the agentdoc CLI, or run it from a source checkout");
370
+ process.exit(1);
371
+ }
372
+ throw e;
373
+ }
374
+ const report = runRoutingEval(ROOT, { json: opt("json") });
375
+ if (opt("json")) { out(report, true); process.exit(report.verdict === "FAIL" ? 1 : 0); }
376
+ else {
377
+ console.log(JSON.stringify(report.metrics, null, 2));
378
+ console.log("thresholds: " + JSON.stringify(report.thresholds));
379
+ console.log("verdict: " + report.verdict + " (" + report.failures.join("; ") + ")");
380
+ }
381
+ if (report.verdict === "FAIL") process.exit(1);
382
+ } else if (cmd === "pin-evidence") {
383
+ // Reviewed overrides pin their evidence by content hash, and those digests
384
+ // are re-verified on every compile. That is only workable if an author can
385
+ // obtain the current digest without computing SHA-256 by hand — otherwise
386
+ // the discipline quietly becomes "delete the override", which is worse
387
+ // than not requiring it.
388
+ // Read the configuration directly rather than through `compile`: a stale
389
+ // digest is precisely the condition this command exists to report, and it
390
+ // must be reportable when `compile` refuses to run.
391
+ const repo = new Repo(ROOT);
392
+ const cfg = loadConfig(repo, loadSchemaBundle().bundle);
393
+ const results = [];
394
+ for (const o of cfg.reviewedOverrides || []) {
395
+ for (const ev of o.evidence || []) {
396
+ const p = path.join(ROOT, ev.path);
397
+ if (!fs.existsSync(p)) {
398
+ results.push({ subject: o.subject, path: ev.path, current: null, pinned: ev.sha256, status: "MISSING" });
399
+ continue;
400
+ }
401
+ const current = sha256Hex(fs.readFileSync(p));
402
+ results.push({
403
+ subject: o.subject, path: ev.path, current,
404
+ pinned: ev.sha256,
405
+ status: current === ev.sha256 ? "CURRENT" : "STALE",
406
+ });
407
+ }
408
+ }
409
+ if (opt("json")) { out({ evidence: results }, true); }
410
+ else {
411
+ for (const r of results) {
412
+ console.log(r.status.padEnd(8) + " " + r.path);
413
+ if (r.status === "STALE") {
414
+ console.log(" pinned: " + r.pinned);
415
+ console.log(" current: " + r.current + " <- update the digest after reviewing the change");
416
+ }
417
+ }
418
+ console.log(results.length + " evidence entries across " + (cfg.reviewedOverrides || []).length + " reviewed overrides");
419
+ }
420
+ if (results.some((r) => r.status !== "CURRENT")) process.exit(1);
421
+ } else if (cmd === "doctor") {
422
+ // Two scopes, kept explicit: `doctor` is a machine check — is this
423
+ // installation of the CLI itself healthy — and never fails because a
424
+ // project happens not to be configured yet. `doctor --project` adds the
425
+ // project checks and fails on those too.
426
+ const major = Number(process.versions.node.split(".")[0]);
427
+ const machine = {
428
+ agentdoc: VERSION,
429
+ node: process.version,
430
+ nodeSupported: major >= MIN_NODE_MAJOR,
431
+ nodeRequired: ">=" + MIN_NODE_MAJOR,
432
+ compiler: COMPILER_NAME + "@" + COMPILER_VERSION,
433
+ graphSchema: GRAPH_SCHEMA_VERSION,
434
+ installRoot: PACKAGE_ROOT,
435
+ schemaBundle: null,
436
+ gitAvailable: false,
437
+ externalRuntimeDependencies: [],
438
+ };
439
+ try {
440
+ const { versionInputs } = loadSchemaBundle();
441
+ machine.schemaBundle = { ok: true, schemas: versionInputs.length };
442
+ } catch (e) {
443
+ machine.schemaBundle = { ok: false, error: (e.code ? e.code + ": " : "") + e.message };
444
+ }
445
+ try {
446
+ execFileSync("git", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
447
+ machine.gitAvailable = true;
448
+ } catch {
449
+ machine.gitAvailable = false;
450
+ }
451
+ const machineOk = machine.nodeSupported && machine.schemaBundle.ok;
452
+
453
+ const report = { scope: "machine", ok: machineOk, machine };
454
+ if (opt("project")) {
455
+ const configFound = fs.existsSync(path.join(ROOT, "agentdoc", "agentdoc.config.yaml"));
456
+ const project = {
457
+ root: ROOT,
458
+ configurationFound: configFound,
459
+ configPath: "agentdoc/agentdoc.config.yaml",
460
+ writable: null,
461
+ graphPresent: false,
462
+ };
463
+ try {
464
+ fs.accessSync(ROOT, fs.constants.W_OK);
465
+ project.writable = true;
466
+ } catch {
467
+ project.writable = false;
468
+ }
469
+ if (configFound) {
470
+ try {
471
+ const cfg = loadConfig(new Repo(ROOT), loadSchemaBundle().bundle);
472
+ project.graphPath = cfg.output?.graph || ".agentdoc/graph.json";
473
+ project.graphPresent = fs.existsSync(path.join(ROOT, project.graphPath));
474
+ } catch (e) {
475
+ project.configError = (e.code ? e.code + ": " : "") + e.message;
476
+ }
477
+ }
478
+ report.scope = "machine+project";
479
+ report.project = project;
480
+ report.ok = machineOk && configFound && project.writable === true && !project.configError;
481
+ }
482
+ out(report, true);
483
+ if (!report.ok) process.exit(1);
484
+ } else {
485
+ usage(2);
486
+ }
487
+ } catch (e) {
488
+ if (e instanceof AgentDocError) {
489
+ console.error("ERROR " + e.format());
490
+ process.exit(1);
491
+ }
492
+ console.error("agentdoc internal error: " + (e && e.stack ? e.stack : String(e)));
493
+ process.exit(1);
494
+ }
@@ -0,0 +1,169 @@
1
+ // Command and flag declarations.
2
+ //
3
+ // One table drives three things: which flags a command accepts, how they are
4
+ // parsed, and what `agentdoc <command> --help` prints. Parsing and help read
5
+ // the same rows, so they cannot drift. A flag that is not declared for the
6
+ // command it is given to is a usage error, never silently ignored: a
7
+ // misspelled gate flag (`check --strcit`) must not turn into a green,
8
+ // non-strict gate.
9
+
10
+ const JSON_FLAG = { json: { desc: "print the machine-readable report" } };
11
+ // Commands whose output is always JSON accept --json too, as a no-op, so a
12
+ // script that says what it expects is not rejected for being explicit.
13
+ const JSON_ALWAYS = { json: { desc: "accepted for explicitness; the output is always JSON" } };
14
+
15
+ export const COMMANDS = {
16
+ init: {
17
+ synopsis: "agentdoc init [--force]",
18
+ summary: "write a configuration and documentation skeleton",
19
+ flags: { force: { desc: "overwrite files that already exist" }, ...JSON_ALWAYS },
20
+ },
21
+ audit: {
22
+ synopsis: "agentdoc audit",
23
+ summary: "inventory the repository and classify every fact (JSON report)",
24
+ flags: { ...JSON_ALWAYS },
25
+ },
26
+ scaffold: {
27
+ synopsis: "agentdoc scaffold [--write]",
28
+ summary: "generate a CREATE-mode skeleton from the audit (dry run by default)",
29
+ flags: { write: { desc: "write the proposed files instead of listing them" }, ...JSON_ALWAYS },
30
+ },
31
+ validate: {
32
+ synopsis: "agentdoc validate",
33
+ summary: "sources, schemas and semantic checks",
34
+ flags: {},
35
+ },
36
+ compile: {
37
+ synopsis: "agentdoc compile [--require-clean]",
38
+ summary: "deterministic graph build (atomic write)",
39
+ flags: { "require-clean": { desc: "refuse when catalog inputs differ from HEAD" } },
40
+ },
41
+ check: {
42
+ synopsis: "agentdoc check [--strict] [--require-clean] [--allow-stale-observations]",
43
+ summary: "freshness, determinism and gate check",
44
+ flags: {
45
+ strict: { desc: "also fail on every warning not accepted in warningAcceptances (the end-of-MIGRATE gate)" },
46
+ "require-clean": { desc: "refuse a graph built from inputs that differ from HEAD" },
47
+ "allow-stale-observations": { desc: "let observations past maxAgeDays through, applying each rule's degradeOnStale" },
48
+ },
49
+ },
50
+ query: {
51
+ synopsis: "agentdoc query <path|ref> [--md] [--json] [--budget section=n,...]",
52
+ summary: "route minimal context for a task",
53
+ flags: {
54
+ md: { desc: "compact markdown context (the default)" },
55
+ ...JSON_FLAG,
56
+ budget: { value: "section=n,...", desc: "cap sections, e.g. relations=10,verification=4; truncation is reported" },
57
+ "require-clean": { desc: "refuse a graph built from inputs that differ from HEAD" },
58
+ "allow-stale-observations": { desc: "let observations past maxAgeDays through, applying each rule's degradeOnStale" },
59
+ },
60
+ },
61
+ impact: {
62
+ synopsis: "agentdoc impact <paths...> | agentdoc impact --diff <git-ref>",
63
+ summary: "what a change forces you to keep consistent",
64
+ flags: {
65
+ diff: { value: "git-ref", desc: "use the files changed since <git-ref> instead of paths" },
66
+ from: { value: "git-ref", desc: "alias of --diff" },
67
+ ...JSON_ALWAYS,
68
+ },
69
+ },
70
+ eval: {
71
+ synopsis: "agentdoc eval routing [--json]",
72
+ summary: "agent-routing evaluation against registered scenarios (SKIP when the repo has none)",
73
+ flags: { ...JSON_FLAG },
74
+ },
75
+ "pin-evidence": {
76
+ synopsis: "agentdoc pin-evidence [--json]",
77
+ summary: "verify digests pinned by reviewed overrides",
78
+ flags: { ...JSON_FLAG },
79
+ },
80
+ doctor: {
81
+ synopsis: "agentdoc doctor [--project]",
82
+ summary: "machine self-check: is this CLI install healthy",
83
+ flags: { project: { desc: "also check the current project's catalog health" }, ...JSON_ALWAYS },
84
+ },
85
+ };
86
+
87
+ // Accepted by every command.
88
+ const COMMON_FLAGS = { help: { desc: "show this help" } };
89
+
90
+ export function flagsOf(cmd) {
91
+ return { ...(COMMANDS[cmd] ? COMMANDS[cmd].flags : {}), ...COMMON_FLAGS };
92
+ }
93
+
94
+ export class UsageError extends Error {}
95
+
96
+ // Parse argv (without the command) against the command's declaration.
97
+ // Returns { flags: Map(name -> true|string), positional }.
98
+ export function parseArgs(cmd, args) {
99
+ const declared = flagsOf(cmd);
100
+ const flags = new Map();
101
+ const positional = [];
102
+ for (let i = 0; i < args.length; i++) {
103
+ const a = args[i];
104
+ if (a === "-h") { flags.set("help", true); continue; }
105
+ if (!a.startsWith("--")) { positional.push(a); continue; }
106
+ const eq = a.indexOf("=");
107
+ const name = a.slice(2, eq < 0 ? undefined : eq);
108
+ const spec = declared[name];
109
+ if (!spec) throw new UsageError(unknownFlagMessage(cmd, name));
110
+ if (!spec.value) {
111
+ if (eq >= 0) throw new UsageError("agentdoc " + cmd + ": --" + name + " does not take a value");
112
+ flags.set(name, true);
113
+ continue;
114
+ }
115
+ let v = eq >= 0 ? a.slice(eq + 1) : args[i + 1];
116
+ if (eq < 0) {
117
+ if (v === undefined || v.startsWith("--")) v = "";
118
+ else i++;
119
+ }
120
+ if (!v) throw new UsageError("agentdoc " + cmd + ": --" + name + " requires a value (" + spec.value + ")");
121
+ flags.set(name, v);
122
+ }
123
+ return { flags, positional };
124
+ }
125
+
126
+ function unknownFlagMessage(cmd, name) {
127
+ const own = Object.keys(flagsOf(cmd));
128
+ const near = own
129
+ .map((f) => [f, distance(f, name)])
130
+ .filter(([f, d]) => d <= Math.max(1, Math.floor(f.length / 3)))
131
+ .sort((a, b) => a[1] - b[1] || (a[0] < b[0] ? -1 : 1))[0];
132
+ const elsewhere = Object.keys(COMMANDS).filter((c) => c !== cmd && COMMANDS[c].flags[name]);
133
+ let hint = "";
134
+ if (near) hint = " (did you mean --" + near[0] + "?)";
135
+ else if (elsewhere.length) hint = " (--" + name + " is accepted by: " + elsewhere.join(", ") + ")";
136
+ return "agentdoc " + cmd + ": unknown flag --" + name + hint +
137
+ "\nrun `agentdoc " + cmd + " --help` for the flags this command accepts";
138
+ }
139
+
140
+ function distance(a, b) {
141
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
142
+ for (let j = 1; j <= b.length; j++) dp[0][j] = j;
143
+ for (let i = 1; i <= a.length; i++) {
144
+ for (let j = 1; j <= b.length; j++) {
145
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
146
+ // Adjacent transposition: `strcit` is one slip away from `strict`.
147
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + 1);
148
+ }
149
+ }
150
+ return dp[a.length][b.length];
151
+ }
152
+
153
+ export function commandHelp(cmd) {
154
+ const c = COMMANDS[cmd];
155
+ const flags = flagsOf(cmd);
156
+ const rows = Object.entries(flags).map(([name, spec]) => ["--" + name + (spec.value ? " <" + spec.value + ">" : ""), spec.desc]);
157
+ const width = Math.max(...rows.map((r) => r[0].length));
158
+ return [
159
+ "usage: " + c.synopsis,
160
+ "",
161
+ c.summary,
162
+ "",
163
+ "flags:",
164
+ ...rows.map(([f, d]) => " " + f.padEnd(width) + " " + d),
165
+ "",
166
+ "Exit codes: 0 ok, 1 failure, 2 usage.",
167
+ "",
168
+ ].join("\n");
169
+ }