agent-doc-system 1.2.0 → 1.3.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 (45) hide show
  1. package/CHANGELOG.md +134 -1
  2. package/README.md +1 -1
  3. package/adapters/cache.mjs +5 -4
  4. package/adapters/database.mjs +3 -2
  5. package/adapters/deploy-config.mjs +3 -2
  6. package/adapters/dockerfile.mjs +3 -2
  7. package/adapters/generic.mjs +2 -1
  8. package/adapters/github-actions.mjs +2 -1
  9. package/adapters/go.mjs +4 -3
  10. package/adapters/node.mjs +3 -2
  11. package/adapters/rust.mjs +3 -0
  12. package/adapters/turborepo.mjs +3 -2
  13. package/adapters/typescript.mjs +7 -5
  14. package/adapters/wrangler.mjs +3 -2
  15. package/bin/agentdoc.mjs +9 -2
  16. package/bin/usage.txt +2 -0
  17. package/core/audit.mjs +7 -5
  18. package/core/compile.mjs +7 -5
  19. package/core/derive.mjs +4 -3
  20. package/core/descriptors.mjs +3 -2
  21. package/core/discover.mjs +3 -2
  22. package/core/fsx.mjs +174 -42
  23. package/core/graph.mjs +1 -1
  24. package/core/impact.mjs +11 -4
  25. package/core/paths.mjs +71 -0
  26. package/core/query.mjs +58 -23
  27. package/core/scaffold.mjs +130 -23
  28. package/core/sourcescan.mjs +4 -1
  29. package/docs/ADAPTERS.md +1 -1
  30. package/docs/BENCHMARK.md +83 -21
  31. package/docs/CI.md +7 -1
  32. package/docs/EVALS.md +209 -17
  33. package/docs/MIGRATION.md +22 -4
  34. package/docs/PROVENANCE.md +6 -0
  35. package/docs/QUERY.md +25 -3
  36. package/docs/SPEC.md +47 -2
  37. package/docs/ci/github-actions.yml +1 -1
  38. package/evals/baseline-search.mjs +272 -0
  39. package/evals/facts.mjs +28 -0
  40. package/evals/harness.mjs +120 -116
  41. package/package.json +4 -2
  42. package/skills/agent-doc-system/SKILL.md +2 -1
  43. package/skills/agent-doc-system/references/cli.md +6 -1
  44. package/skills/agent-doc-system/references/create-migrate.md +8 -2
  45. package/templates/ci.yml +1 -1
package/core/scaffold.mjs CHANGED
@@ -13,7 +13,8 @@ import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { AgentDocError, CODES } from "./codes.mjs";
16
- import { Repo } from "./fsx.mjs";
16
+ import { Repo, assertRepoPath } from "./fsx.mjs";
17
+ import { CONFIG_PATH } from "./descriptors.mjs";
17
18
  import { discoverUnits, isNotUnitRoot } from "./discover.mjs";
18
19
  import { classifyContractFile } from "./contracts.mjs";
19
20
  import { parse as parseToml } from "./toml.mjs";
@@ -45,23 +46,96 @@ function sentence(s) {
45
46
  return /[.!?]$/.test(t) ? t : t + ".";
46
47
  }
47
48
 
48
- export function installTemplates(repo, cfg, { write, root, force = false, eligible = null } = {}) {
49
+ // Where the documentation skeleton goes when no configuration exists yet. Once
50
+ // a configuration exists, it is the only authority: `docs.*` is what audit and
51
+ // compile read, so the scaffolder writes there and nowhere else (issue #49 —
52
+ // `docs/` is often the product's own user documentation).
53
+ //
54
+ // The default lives under `agentdoc/` (issue #59): authored AgentDoc material
55
+ // lives in `agentdoc/`, generated output in `.agentdoc/`. Not `docs/`, which is
56
+ // the product's user documentation (and a MkDocs/Docusaurus/Pages source), and
57
+ // not `.agentdoc/`, which the compiler skips and many repositories git-ignore.
58
+ export const DEFAULT_DOCS = Object.freeze({
59
+ product: "agentdoc/docs/PRODUCT.md",
60
+ architecture: "agentdoc/docs/ARCHITECTURE.md",
61
+ constraints: "agentdoc/docs/CONSTRAINTS.md",
62
+ adrDir: "agentdoc/docs/adr",
63
+ });
64
+
65
+ // The skeleton roles that have a template. `runbookDir` and `normative` are
66
+ // honoured by never writing anything for them: there is no runbook template,
67
+ // and normative documents are the project's own.
68
+ const SKELETON_ROLES = [
69
+ { key: "product", template: "PRODUCT.md", file: (p) => p },
70
+ { key: "architecture", template: "ARCHITECTURE.md", file: (p) => p },
71
+ { key: "constraints", template: "CONSTRAINTS.md", file: (p) => p },
72
+ { key: "adrDir", template: "ADR.md", file: (p) => p.replace(/\/+$/, "") + "/README.md" },
73
+ ];
74
+
75
+ // Resolve the documentation skeleton for a loaded configuration (`null` when
76
+ // there is none). A configured role is written to its configured path; a role
77
+ // a configuration leaves out is not written anywhere and is reported, so an
78
+ // omitted role can never fall back into `docs/`.
79
+ export function docsSkeleton(cfg) {
80
+ const docs = cfg ? cfg.docs || {} : DEFAULT_DOCS;
81
+ const targets = [];
82
+ const notConfigured = [];
83
+ for (const role of SKELETON_ROLES) {
84
+ const configured = docs[role.key];
85
+ if (!configured) { notConfigured.push("docs." + role.key); continue; }
86
+ // loadConfig already validated these; re-asserting keeps this function
87
+ // safe for any caller that hands it an unvalidated object.
88
+ assertRepoPath(configured, CONFIG_PATH);
89
+ const rel = role.file(configured);
90
+ assertRepoPath(rel, CONFIG_PATH);
91
+ targets.push({ key: role.key, path: rel, template: role.template });
92
+ }
93
+ return { targets, notConfigured, docs: cfg ? docs : DEFAULT_DOCS };
94
+ }
95
+
96
+ // Refuse a write target that leaves the repository, including through a
97
+ // symlinked directory or a (possibly dangling) symlink at the target itself.
98
+ // Lexical checks alone (`isRepoPath`) cannot see a symlink.
99
+ function assertWritableInside(root, rel) {
100
+ const abs = path.resolve(root, rel);
101
+ const inside = path.relative(path.resolve(root), abs);
102
+ if (!inside || inside === ".." || inside.startsWith(".." + path.sep) || path.isAbsolute(inside)) {
103
+ throw new AgentDocError(CODES.PATH_ESCAPE, "path escapes repository: " + rel, { path: rel });
104
+ }
105
+ const realRoot = fs.realpathSync(root);
106
+ let cur = path.resolve(root);
107
+ for (const seg of inside.split(path.sep)) {
108
+ cur = path.join(cur, seg);
109
+ let st;
110
+ try { st = fs.lstatSync(cur); } catch { return; } // nothing further exists yet
111
+ if (!st.isSymbolicLink()) continue;
112
+ let real = null;
113
+ try { real = fs.realpathSync(cur); } catch { /* dangling */ }
114
+ if (real === null || (real !== realRoot && !real.startsWith(realRoot + path.sep))) {
115
+ throw new AgentDocError(CODES.PATH_ESCAPE, "refusing to write through a symlink that leaves the repository: " + rel, { path: rel });
116
+ }
117
+ }
118
+ }
119
+
120
+ // `cfg` is the loaded configuration (scaffold) or null (init). `docsConfig`
121
+ // overrides the configuration the documentation paths are read from: init
122
+ // writes no component descriptors, but it must still honour an existing
123
+ // configuration's `docs.*` paths.
124
+ export function installTemplates(repo, cfg, { write, root, force = false, eligible = null, docsConfig } = {}) {
49
125
  const written = [];
50
126
  const skipped = [];
51
127
  const targetRoot = root || (repo ? repo.root : process.cwd());
52
128
  const r = repo || new Repo(targetRoot, { noGit: false });
129
+ const docsSource = docsConfig === undefined ? cfg : docsConfig;
130
+ const skeleton = docsSkeleton(docsSource);
53
131
 
54
- const put = (rel, content) => {
55
- const abs = path.join(targetRoot, rel);
56
- if (fs.existsSync(abs) && !force) { skipped.push(rel); return; }
57
- if (!write) { written.push(rel + " (dry run)"); return; }
58
- fs.mkdirSync(path.dirname(abs), { recursive: true });
59
- fs.writeFileSync(abs, content.endsWith("\n") ? content : content + "\n");
60
- written.push(rel);
61
- };
132
+ // Plan every file first and check every target before writing anything, so
133
+ // a refused path leaves the repository exactly as it was.
134
+ const plan = [];
135
+ const put = (rel, content) => { plan.push([rel, content]); };
62
136
 
63
137
  // ── configuration ───────────────────────────────────────────────────────
64
- const config = buildConfig(scanLayout(r, cfg));
138
+ const config = buildConfig(scanLayout(r, cfg), skeleton.docs);
65
139
  put("agentdoc/agentdoc.config.yaml", config);
66
140
  put("agentdoc/journeys.yaml", "schemaVersion: agentdoc.dev/journeys/v1\njourneys: []\n");
67
141
  for (const f of ["domains.yaml", "systems.yaml", "apis.yaml", "resources.yaml"]) {
@@ -69,12 +143,10 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
69
143
  }
70
144
  put("agentdoc/observations/README.md", OBSERVATIONS_README);
71
145
 
72
- // ── documentation hierarchy ──────────────────────────────────────────────
73
- for (const f of ["PRODUCT.md", "ARCHITECTURE.md", "CONSTRAINTS.md"]) {
74
- const tpl = fs.readFileSync(path.join(TEMPLATES_DIR, f), "utf8");
75
- put("docs/" + f, tpl);
146
+ // ── documentation hierarchy (configured docs.* paths) ───────────────────
147
+ for (const t of skeleton.targets) {
148
+ put(t.path, fs.readFileSync(path.join(TEMPLATES_DIR, t.template), "utf8"));
76
149
  }
77
- put("docs/adr/README.md", fs.readFileSync(path.join(TEMPLATES_DIR, "ADR.md"), "utf8"));
78
150
  put(".github/workflows/agentdoc.yml", fs.readFileSync(path.join(TEMPLATES_DIR, "ci.yml"), "utf8"));
79
151
 
80
152
  // ── per-unit component descriptors ──────────────────────────────────────
@@ -88,7 +160,30 @@ export function installTemplates(repo, cfg, { write, root, force = false, eligib
88
160
  put(u.root + "/" + cfg.discovery.componentDescriptorName, componentDescriptor(u));
89
161
  }
90
162
  }
91
- return { written, skipped, note: write ? "files written" : "dry run: pass --write to apply" };
163
+
164
+ const seen = new Set();
165
+ for (const [rel] of plan) assertWritableInside(targetRoot, rel);
166
+ for (const [rel, content] of plan) {
167
+ if (seen.has(rel)) continue; // two roles configured onto one file: first wins
168
+ seen.add(rel);
169
+ const abs = path.join(targetRoot, rel);
170
+ if (fs.existsSync(abs) && !force) { skipped.push(rel); continue; }
171
+ if (!write) { written.push(rel + " (dry run)"); continue; }
172
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
173
+ fs.writeFileSync(abs, content.endsWith("\n") ? content : content + "\n");
174
+ written.push(rel);
175
+ }
176
+ return {
177
+ written,
178
+ skipped,
179
+ ...(docsSource && skeleton.notConfigured.length
180
+ ? {
181
+ notConfigured: skeleton.notConfigured,
182
+ notConfiguredNote: "the configuration declares no path for these roles, so no skeleton was written for them; add them under docs: to scaffold them",
183
+ }
184
+ : {}),
185
+ note: write ? "files written" : "dry run: pass --write to apply",
186
+ };
92
187
  }
93
188
 
94
189
  // Layout inference for CREATE mode: which directories look like unit roots.
@@ -108,6 +203,7 @@ function scanLayout(repo, cfg) {
108
203
  for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
109
204
  if (e.isSymbolicLink() || skip.has(e.name)) continue;
110
205
  const rel = dir ? dir + "/" + e.name : e.name;
206
+ if (repo.isIgnored && repo.isIgnored(rel)) continue;
111
207
  if (e.isDirectory()) {
112
208
  if (e.name.startsWith(".")) continue;
113
209
  walk(rel, depth + 1);
@@ -269,7 +365,7 @@ const ADAPTER_FOR_MARKER = {
269
365
  platform: "wrangler",
270
366
  };
271
367
 
272
- function buildConfig(layout) {
368
+ function buildConfig(layout, docs = DEFAULT_DOCS) {
273
369
  const roots = layout.map((l) => l.root);
274
370
  const groups = new Map();
275
371
  for (const r of roots) {
@@ -346,15 +442,26 @@ function buildConfig(layout) {
346
442
  " graph: .agentdoc/graph.json",
347
443
  " observations: agentdoc/observations",
348
444
  "",
349
- "docs:",
350
- " product: docs/PRODUCT.md",
351
- " architecture: docs/ARCHITECTURE.md",
352
- " constraints: docs/CONSTRAINTS.md",
353
- " adrDir: docs/adr",
445
+ ...docsSection(docs),
354
446
  "",
355
447
  ].join("\n");
356
448
  }
357
449
 
450
+ // The `docs:` block of a generated configuration. It repeats the configured
451
+ // paths (init --force regenerates the file and must not move documentation
452
+ // elsewhere), or the agentdoc/docs/ defaults when there is no configuration yet.
453
+ function docsSection(docs) {
454
+ const scalar = (v) => (/^[A-Za-z0-9._/-]+$/.test(v) ? v : yamlStr(v));
455
+ const lines = [];
456
+ for (const key of ["product", "architecture", "constraints", "adrDir", "runbookDir"]) {
457
+ if (docs[key]) lines.push(" " + key + ": " + scalar(docs[key]));
458
+ }
459
+ if (Array.isArray(docs.normative) && docs.normative.length) {
460
+ lines.push(" normative:", ...docs.normative.map((g) => " - " + yamlStr(g)));
461
+ }
462
+ return lines.length ? ["docs:", ...lines] : ["# docs: {} # no documentation roles configured"];
463
+ }
464
+
358
465
  const OBSERVATIONS_README = `# Runtime observations
359
466
 
360
467
  An ObservationSet records what an authoritative external system actually said,
@@ -7,6 +7,7 @@
7
7
  // knows the namespace convention.
8
8
  import path from "node:path";
9
9
  import fs from "node:fs";
10
+ import { joinPath } from "./paths.mjs";
10
11
 
11
12
  const SPECIFIER_RE = /["'`]([A-Za-z@][A-Za-z0-9@._/+-]*(?:\.[A-Za-z0-9@._/+-]+)*(?:\/[A-Za-z0-9@._+*-]+)*)["'`]/g;
12
13
 
@@ -66,6 +67,7 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
66
67
  const abs = path.join(dirAbs, e.name);
67
68
  const rel = path.relative(repo.root, abs).split(path.sep).join("/");
68
69
  if (!repo.ownsUnitPath(root, rel)) continue;
70
+ if (repo.isIgnored && repo.isIgnored(rel)) continue;
69
71
  if (e.name === "migrations" || e.name === "migration") {
70
72
  out.push(rel);
71
73
  continue;
@@ -86,7 +88,8 @@ export function findMigrationDirs(repo, root, maxDepth = 5) {
86
88
 
87
89
  export function firstExistingFile(repo, root, names) {
88
90
  for (const n of names) {
89
- if (repo.ownsUnitPath(root, root + "/" + n) && repo.exists(root + "/" + n)) return root + "/" + n;
91
+ const p = joinPath(root, n);
92
+ if (repo.ownsUnitPath(root, p) && repo.exists(p)) return p;
90
93
  }
91
94
  return null;
92
95
  }
package/docs/ADAPTERS.md CHANGED
@@ -220,7 +220,7 @@ Workspace roots come from every `Cargo.toml` that has `[workspace]`, not only
220
220
  the one at the repository root. The search is bounded to five directories
221
221
  down, stays inside the repository, and does not enter `discovery.notUnitRoots`
222
222
  or their descendants (the authored-descriptor exception is described below),
223
- `discovery.generatedDirs`, dot-directories, or `test` / `tests` / `examples` /
223
+ `discovery.generatedDirs`, git-ignored directories, dot-directories, or `test` / `tests` / `examples` /
224
224
  `example`. `members` minus `exclude` are still relative to the manifest that
225
225
  declares them; `*` matches one path segment, and a member that leaves the
226
226
  repository is ignored. `workspace = true` resolves against the nearest
package/docs/BENCHMARK.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # BENCHMARK
2
2
 
3
- Two measurements back the numbers in the README. Both are reproducible from a
4
- checkout. Neither involves a model, so phrasing cannot move them.
3
+ Two measurements are reproducible from a checkout. Neither involves a model;
4
+ both depend on the measurement rules described here.
5
5
 
6
6
  ## Routing quality: `node evals/run-all.mjs`
7
7
 
@@ -9,25 +9,87 @@ The routing evaluation is described in [EVALS.md](EVALS.md). It has 30 scenarios
9
9
  on six repositories (Node, TypeScript, Go, Rust and Python), with thresholds
10
10
  registered on 2026-09-28 and not moved since. For each scenario, it compares
11
11
  the context `agentdoc query` + `agentdoc impact` returns with what an agent
12
- gets by reading only the component it is changing.
13
-
14
- | | reading the touched component | AgentDoc |
15
- |---|---|---|
16
- | facts the change needs, retrieved | 58% | **100%** |
17
- | retrieved facts that are unrelated (noise) | 40% | **8.7%** |
18
- | share of the repository's checks returned (selectivity) | 100% | **46%** (29% on the golden corpus) |
19
- | context size vs. reading the whole catalog | — | **−70%** |
20
-
21
- Every check a scenario requires is in AgentDoc's answer (verification recall
22
- 1.0). The baseline never misses one either, because it runs every check in the
23
- repository. Selectivity is therefore the meaningful comparison: the share of
24
- the repository's checks the answer contains, counting everything the agent is
25
- shown. It is reported, not gated; small corpora inflate it, and a
26
- one-component corpus scores 100% because there is nothing to narrow.
27
-
28
- The size reduction is measured on the golden corpus (`examples/koda`). The
29
- smaller fixtures hold fewer than 100 facts, so the harness reports their
30
- reduction but does not aggregate it.
12
+ gets without routing.
13
+
14
+ The measured systems are catalog routing, a corrected graph-assisted targeted
15
+ reference, and working-tree search. Their current results appear below. The
16
+ pre-registered thresholds and scenario truth are unchanged.
17
+
18
+ ### Correction: the fact-recall comparison is withdrawn
19
+
20
+ Until 2026-10-27 this page, the README and the 1.2.0 release notes reported
21
+ that AgentDoc retrieves 100% of the facts a change needs, against 58% for a
22
+ baseline that reads only the touched component, with noise under 9% against
23
+ 40%. That comparison does not hold, and it is withdrawn.
24
+
25
+ - The targeted baseline already holds almost every expected fact, under a
26
+ different label from the ground truth. It records `usesResource:<ref>` where
27
+ the truth says `uses:<ref>`, and `buildDependencyOf:<ref>` where the truth
28
+ names the component `<ref>`. Relabelled, it retrieves about 99% of the
29
+ expected facts.
30
+ - The baseline also takes those relations from the compiled graph, which an
31
+ agent without AgentDoc does not have. A fair baseline has to find them with
32
+ search over the repository's files.
33
+
34
+ The search comparison is now measured. The targeted reference's corrected
35
+ micro-recall is 100/101 (99.01%); its scenario-mean recall is 99.17%. The
36
+ 100%-versus-58% comparison remains withdrawn.
37
+
38
+ ### Search baseline
39
+
40
+ `baseline-search` starts with touched files, searches source/manifests for
41
+ import names and local paths in both directions to a fixed point, and reads
42
+ the reached components' docs and checks. Contracts, endpoint strings, event
43
+ subjects and resource evidence are found in working-tree files. Catalog
44
+ descriptors supply only component identity and path ownership. Retrieval never
45
+ reads the compiled graph or compiler relations. All naming guesses, exclusions
46
+ and check conventions are listed in [EVALS.md](EVALS.md#search-baseline-heuristics).
47
+
48
+ | Corpus (scenarios) | System | Mean recall | Noise | Check selectivity |
49
+ |---|---|---:|---:|---:|
50
+ | Koda (9) | baseline-targeted (corrected) | 100.00% | 41.23% | 100.00% |
51
+ | Koda (9) | baseline-search | 71.90% | 71.55% | 100.00% |
52
+ | Koda (9) | catalog | 100.00% | 4.02% | 28.79% |
53
+ | Node/TS (3) | baseline-targeted (corrected) | 100.00% | 17.86% | 100.00% |
54
+ | Node/TS (3) | baseline-search | 91.67% | 30.64% | 83.33% |
55
+ | Node/TS (3) | catalog | 100.00% | 0.00% | 100.00% |
56
+ | Go (4) | baseline-targeted (corrected) | 93.75% | 28.94% | 100.00% |
57
+ | Go (4) | baseline-search | 83.33% | 26.01% | 100.00% |
58
+ | Go (4) | catalog | 100.00% | 5.88% | 66.67% |
59
+ | Mixed monorepo (4) | baseline-targeted (corrected) | 100.00% | 37.82% | 100.00% |
60
+ | Mixed monorepo (4) | baseline-search | 100.00% | 41.25% | 100.00% |
61
+ | Mixed monorepo (4) | catalog | 100.00% | 5.88% | 54.17% |
62
+ | Rust (4) | baseline-targeted (corrected) | 100.00% | 48.25% | 100.00% |
63
+ | Rust (4) | baseline-search | 100.00% | 32.81% | 83.33% |
64
+ | Rust (4) | catalog | 100.00% | 0.00% | 25.00% |
65
+ | Python (6) | baseline-targeted (corrected) | 100.00% | 46.70% | 100.00% |
66
+ | Python (6) | baseline-search | 100.00% | 23.68% | 41.67% |
67
+ | Python (6) | catalog | 100.00% | 0.00% | 41.67% |
68
+ | Aggregate (30) | baseline-targeted (corrected) | 99.17% | 38.83% | 100.00% |
69
+ | Aggregate (30) | baseline-search | 88.51% | 42.61% | 84.44% |
70
+ | Aggregate (30) | catalog | 100.00% | 2.77% | 46.42% |
71
+
72
+ These are scenario means, aggregated by scenario count. Search micro-recall is
73
+ 85/101 (84.16%), compared with 100/101 (99.01%) for corrected targeted and
74
+ 101/101 for catalog routing. All three systems retrieve every scenario-required
75
+ verification expectation. Selectivity counts distinct registered check IDs,
76
+ including repository gates, after matching search's discovered scoped commands.
77
+
78
+ Search matches catalog fact recall in 21 of 30 scenarios, including all Rust,
79
+ Python and mixed-monorepo scenarios. It beats catalog recall in none. The
80
+ remaining misses include authored placement, authority conflicts/runtime
81
+ observations, and API identities/roles not recoverable by these lexical rules.
82
+ Its broad closure can also overreach through shared endpoint strings. This is
83
+ a comparison of deterministic retrieval rules on authored corpora, not an agent
84
+ session, a limit on search quality, or evidence of time/token savings. Noise is
85
+ scored against the catalog's one-hop graph closure and can penalize useful
86
+ source facts absent from that graph.
87
+
88
+ The unchanged relative recall gate passes: corrected targeted ties catalog in
89
+ five corpora and trails it in Go. All applicable gates pass. Normalization also
90
+ changes noise and deduplicates fact counts: the full Koda inventory now has 86
91
+ facts, below the unchanged 100-fact size floor. Aggregate retrieval reduction
92
+ is **not measured**, so the former 70% size figure is not a current result.
31
93
 
32
94
  ## Fresh setup on public repositories: `node scripts/bench/run.mjs`
33
95
 
package/docs/CI.md CHANGED
@@ -20,7 +20,7 @@ jobs:
20
20
  with: { fetch-depth: 0 }
21
21
  - uses: actions/setup-node@v4
22
22
  with: { node-version: '24' }
23
- # Pin a tag for reproducibility, e.g. github:lbframe/agent-doc-system#v1.2.0 —
23
+ # Pin a tag for reproducibility, e.g. github:lbframe/agent-doc-system#v1.3.0 —
24
24
  # a compiler upgrade intentionally invalidates every committed graph.
25
25
  - run: npm install -g github:lbframe/agent-doc-system
26
26
  - run: agentdoc validate
@@ -47,6 +47,12 @@ compare the graph's recorded commit with `HEAD`: committing the graph changes
47
47
  The commit is recorded as provenance — which checkout produced this graph — not
48
48
  as a gate.
49
49
 
50
+ Files git ignores are not inputs, so a graph compiled after a local build
51
+ (`astro build`, `next build`, anything writing into an ignored directory) is the
52
+ graph CI reproduces from a clean checkout. No `git clean -fdX` is needed before
53
+ `compile`. An untracked file that is *not* ignored still counts, and marks the
54
+ graph dirty: commit it or ignore it.
55
+
50
56
  Two things to decide per repository:
51
57
 
52
58
  - **Commit the graph or ignore it.** Both work. Committing it makes the