agent-doc-system 1.3.0 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,79 @@ refused by the next with `AGENTDOC_COMPILER_MISMATCH` until it is recompiled.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.5.0] - 2026-11-13
13
+
14
+ ### Added
15
+
16
+ - Task-oriented `prepare`, `expand` and `refresh` JSON operations with versioned facts, evidence references, explicit coverage, snapshot-bound pagination and exact serialized token budgets for `o200k_base` and `cl100k_base`.
17
+ - Local persistent compilation reuse, shared reads and content-addressed JSON parsing. Changed files, directory probes, ignore rules and compiler inputs invalidate reuse; full compilation remains the reference.
18
+ - Session check-result imports with pinned execution evidence and conservative snapshot invalidation, plus an opt-in JavaScript TypeScript symbol-candidate index that never narrows checks.
19
+ - An agent evaluation runner with independent validators, a 30-task synthetic starter corpus, a local Codex driver, bounded campaigns and conservative candidate selection. No model-efficiency improvement is claimed from the starter corpus.
20
+ - Local context benchmarks with graph and packet equivalence checks.
21
+
22
+ ### Fixed
23
+
24
+ - Default Markdown now reports capped conflicts and checks, repository gates, typed relationships and runtime facts. Settled conflicts are distinguished from unresolved ones.
25
+ - Test fixtures no longer inherit local Git commit or tag signing requirements.
26
+
27
+ ### Upgrade
28
+
29
+ - Recompile committed graphs with `agentdoc compile` after upgrading.
30
+ - Exact token budgets use the pinned `js-tiktoken` runtime dependency. The compiler itself remains independent of model APIs. See [the task context protocol](docs/CONTEXT.md).
31
+
32
+
33
+ ## [1.4.0] - 2026-11-10
34
+
35
+ `impact` now treats a change under a Drizzle migration directory as a change
36
+ to the database it migrates. It reaches every component bound to that
37
+ database, not only the component that holds the file. Any other ORM layout can
38
+ be declared on the database Resource with the new `spec.migrationPaths`.
39
+ Existing `migrations/` and `alembic/versions/` paths give the same answer as
40
+ before.
41
+
42
+ Before you upgrade:
43
+
44
+ - **Recompile committed graphs.** As with every release, a graph compiled by
45
+ 1.3.0 is refused with `AGENTDOC_COMPILER_MISMATCH` until you run
46
+ `agentdoc compile` again. Graphs of repositories that enable the `database`
47
+ adapter also gain `derived.migrationDirs` and the provenance behind it.
48
+ - **A Drizzle config whose `out` is not a plain string** (an environment
49
+ variable, an expression, a spread) now raises the warning
50
+ `AGENTDOC_MIGRATION_DIR_UNRESOLVED`. Declare the directory with
51
+ `spec.migrationPaths`, or accept the warning ([#63]).
52
+
53
+ ### Added
54
+
55
+ - A `logical-database` Resource can declare where its schema migrations live,
56
+ with `spec.migrationPaths` ([#63]). A change under a declared directory is a
57
+ change to that Resource, whichever component owns the file, so `impact`
58
+ reaches every component bound to the database. Paths are whole-segment
59
+ prefixes, not globs: `frontend/apps/web/drizzle` covers `0036_x.sql` and
60
+ `meta/_journal.json`, and never `drizzle-extra/`. A glob, a declaration on
61
+ another Resource type, or one path declared by two Resources fails with the
62
+ new `AGENTDOC_MIGRATION_PATH`; a path outside the repository fails with
63
+ `AGENTDOC_PATH_ESCAPE`, a missing one with `AGENTDOC_PATH_UNRESOLVED`. The
64
+ declaration never creates a binding.
65
+
66
+ ### Fixed
67
+
68
+ - `impact` now classifies a change under a Drizzle migration directory as a
69
+ database change ([#63]). Only `migrations/`, `migration/` and
70
+ `alembic/versions/` used to count, so `impact
71
+ frontend/apps/web/drizzle/0036_email_locale.sql` reported the web component
72
+ alone and missed every other component bound to the same database. The
73
+ `database` adapter now reads Drizzle Kit's `out` from `drizzle.config.ts`
74
+ (`.js`, `.mjs`) as a string literal, without running it, and defaults to
75
+ `./drizzle`. An `out` it cannot read statically is reported with the
76
+ acceptable warning `AGENTDOC_MIGRATION_DIR_UNRESOLVED` instead of being
77
+ guessed. Detected directories, including `migrations` and `alembic/versions`,
78
+ are recorded in the graph as `derived.migrationDirs` with provenance, and
79
+ `impact` classifies from them. A detected directory still reaches a database
80
+ only through the component's existing bindings. The answer for an existing
81
+ `migrations/` or `alembic/versions/` path is unchanged. Committed graphs of
82
+ repositories that enable the `database` adapter gain `migrationDirs` and
83
+ must be recompiled.
84
+
12
85
  ## [1.3.0] - 2026-11-07
13
86
 
14
87
  A graph compiled on your machine now passes `check` in CI and in a clean
@@ -367,7 +440,9 @@ First public release.
367
440
  for project health.
368
441
  - An Agent Skill under `skills/agent-doc-system/`.
369
442
 
370
- [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.3.0...HEAD
443
+ [Unreleased]: https://github.com/lbframe/agent-doc-system/compare/v1.5.0...HEAD
444
+ [1.5.0]: https://github.com/lbframe/agent-doc-system/compare/v1.4.0...v1.5.0
445
+ [1.4.0]: https://github.com/lbframe/agent-doc-system/compare/v1.3.0...v1.4.0
371
446
  [1.3.0]: https://github.com/lbframe/agent-doc-system/compare/v1.2.0...v1.3.0
372
447
  [1.2.0]: https://github.com/lbframe/agent-doc-system/compare/v1.1.0...v1.2.0
373
448
  [1.1.0]: https://github.com/lbframe/agent-doc-system/compare/v1.0.0...v1.1.0
@@ -400,3 +475,4 @@ First public release.
400
475
  [#51]: https://github.com/lbframe/agent-doc-system/issues/51
401
476
  [#55]: https://github.com/lbframe/agent-doc-system/issues/55
402
477
  [#59]: https://github.com/lbframe/agent-doc-system/issues/59
478
+ [#63]: https://github.com/lbframe/agent-doc-system/issues/63
@@ -37,7 +37,7 @@ npm install -g github:lbframe/agent-doc-system
37
37
  The command is idempotent: run it again to upgrade to the latest `main`.
38
38
  If a specific ref is required, append it: `github:lbframe/agent-doc-system#<ref>`.
39
39
 
40
- The package has zero runtime dependencies. If `npm install -g` fails on
40
+ The task context commands use a pinned tokenizer dependency. If `npm install -g` fails on
41
41
  permissions, do **not** use `sudo`; fix the npm global prefix instead
42
42
  (`npm config set prefix ~/.npm-global` and add `~/.npm-global/bin` to `PATH`),
43
43
  or report the blocker.
package/README.md CHANGED
@@ -45,6 +45,10 @@ AgentDoc gives them a map.
45
45
 
46
46
  ![AgentDoc in action — query, impact, check](docs/assets/readme/agentdoc-in-action.webp)
47
47
 
48
+ ## Task context for agents
49
+
50
+ Use `agentdoc prepare` with a JSON task request to obtain a versioned context packet with an exact token budget. `expand` continues a partial packet; `refresh` updates retained context after edits. [Protocol and examples](docs/CONTEXT.md).
51
+
48
52
  ## Three commands, one workflow
49
53
 
50
54
  ```bash
@@ -102,7 +106,7 @@ Measured, reproducible, and with no model in the loop. Method and caveats are in
102
106
  - **Seconds to map a real repository.** A fresh `init` → `scaffold` → `compile` takes 1.0–4.6 s on four public repositories, from 141 to 5,142 files (axum, pydantic-ai, full-stack-fastapi-template, create-t3-turbo). No edit was needed beyond choosing each component's placement.
103
107
  - **A briefing, not a dump.** A `query` answer is about 100–260 tokens: what the change reaches, what to read, and which checks to run.
104
108
  - **The same graph every time.** Compiles are byte-identical, and CI can verify it.
105
- - **Small and strict.** Zero runtime dependencies, a package of about 285 kB packed, 21 adapters, and 709/709 cases on the official TOML 1.0 conformance suite.
109
+ - **Small and strict.** A pinned tokenizer dependency, 21 adapters, and 709/709 cases on the official TOML 1.0 conformance suite.
106
110
 
107
111
  ## Install manually
108
112
 
@@ -9,7 +9,7 @@
9
9
  // library shipped migrations but explicitly did not own a database.
10
10
  import { Adapter } from "./registry.mjs";
11
11
  import { firstExistingFile, findMigrationDirs } from "../core/sourcescan.mjs";
12
- import { joinPath } from "../core/paths.mjs";
12
+ import { canonicalPath, joinPath } from "../core/paths.mjs";
13
13
 
14
14
  const ORM_CONFIG = [
15
15
  "drizzle.config.ts", "drizzle.config.js", "drizzle.config.mjs",
@@ -48,6 +48,12 @@ export class DatabaseAdapter extends Adapter {
48
48
  }
49
49
  }
50
50
  const pr = prov.add("observed", evidencePath, "migration-extractor", ["der:" + compRef + ":/bindings"]);
51
+ // Where this unit's migrations live, recorded in the graph so `impact`
52
+ // classifies a change from the graph rather than from a list of names.
53
+ // Authority over a directory, never ownership of a database: these records
54
+ // create no binding.
55
+ const recorded = recordMigrationDirs(ctx, unit, migrationDirs, ormConfig, pr);
56
+ const withDirs = (out) => (recorded.length ? { ...out, migrationDirs: recorded } : out);
51
57
 
52
58
  // A component that both migrates and opens a connection owns the database.
53
59
  const opensConnection = hasConnectionEvidence(ctx, unit);
@@ -66,7 +72,7 @@ export class DatabaseAdapter extends Adapter {
66
72
  evidenceClass: "DERIVED", confidence: "candidate", provRecs: [pr],
67
73
  semantics: "a schema exists here, but which component owns the logical database is unresolved",
68
74
  });
69
- return { migrationAuthority: evidencePath };
75
+ return withDirs({ migrationAuthority: evidencePath });
70
76
  }
71
77
 
72
78
  const name = compRef.split("/")[1];
@@ -89,7 +95,7 @@ export class DatabaseAdapter extends Adapter {
89
95
  evidenceClass: "DERIVED", confidence: "candidate", provRecs: [pr],
90
96
  semantics: "this component opens a database connection, but no authored Resource claims ownership",
91
97
  });
92
- return { migrationAuthority: evidencePath };
98
+ return withDirs({ migrationAuthority: evidencePath });
93
99
  }
94
100
  ctx.addFact(compRef, "resource.ownership", target.name, {
95
101
  evidenceClass: "DERIVED", confidence: "deterministic", provRecs: [pr],
@@ -101,10 +107,10 @@ export class DatabaseAdapter extends Adapter {
101
107
  const client = ctx.clientKeyForResourceType("logical-database");
102
108
  const attrs = client ? { via: "migrations+connection", client } : { via: "migrations+connection" };
103
109
  ctx.addEdge("usesResource", compRef, target.ref, attrs, [pr], "DERIVED");
104
- return {
110
+ return withDirs({
105
111
  migrationAuthority: evidencePath,
106
112
  bindings: [{ kind: "database", logicalResourceRef: target.ref, resolution: "resolved", provenanceIds: null, _provs: new Set([pr]) }],
107
- };
113
+ });
108
114
  }
109
115
  }
110
116
 
@@ -129,3 +135,145 @@ function findDatabaseResource(ctx, name) {
129
135
  );
130
136
  return cands.length === 1 ? cands[0] : null;
131
137
  }
138
+
139
+ // ── migration directories ───────────────────────────────────────────────────
140
+ //
141
+ // Two routes, each with its own evidence:
142
+ // - a directory named by convention (`migrations`, `migration`,
143
+ // `alembic/versions`), evidenced by a file inside it;
144
+ // - Drizzle Kit's `out`, evidenced by the config file that states it.
145
+ // Prisma Migrate writes to `prisma/migrations`, which the first route covers.
146
+ function recordMigrationDirs(ctx, unit, conventionDirs, ormConfig, pr) {
147
+ const { repo, prov, compRef } = ctx;
148
+ const subject = "der:" + compRef + ":/migrationDirs";
149
+ const byPath = new Map();
150
+ const add = (dir, via, rec) => {
151
+ if (!byPath.has(dir)) byPath.set(dir, { path: dir, via: new Set(), _provs: new Set() });
152
+ byPath.get(dir).via.add(via);
153
+ byPath.get(dir)._provs.add(rec);
154
+ };
155
+ for (const dir of conventionDirs) {
156
+ // A directory with no file in the input set has nothing a change could
157
+ // touch, and nothing to cite as evidence.
158
+ const file = repo.walkUnit(unit.root, dir)[0];
159
+ if (!file) continue;
160
+ let rec;
161
+ if (file === pr.path) {
162
+ rec = pr;
163
+ pr.subjects.add(subject);
164
+ } else {
165
+ rec = prov.add("observed", file, "migration-extractor", [subject]);
166
+ }
167
+ add(dir, /(^|\/)alembic\/versions$/.test(dir) ? "alembic-versions" : "directory-name", rec);
168
+ }
169
+ if (ormConfig && /(^|\/)drizzle\.config\.[cm]?[jt]s$/.test(ormConfig)) {
170
+ const dir = drizzleOutDir(ctx, unit, ormConfig);
171
+ if (dir) add(dir, "drizzle-config", prov.add("observed", ormConfig, "drizzle-config-extractor", [subject]));
172
+ }
173
+ return [...byPath.values()]
174
+ .sort((a, b) => (a.path < b.path ? -1 : 1))
175
+ .map((d) => ({ path: d.path, via: [...d.via].sort(), provenanceIds: null, _provs: d._provs }));
176
+ }
177
+
178
+ // Drizzle Kit writes migrations to `out`, `./drizzle` when absent. The config
179
+ // is code, and it is never executed: only a string literal is read. Anything
180
+ // else — an expression, an environment variable, a spread that could carry
181
+ // `out`, several `out` keys — is reported and not guessed, because a wrong
182
+ // directory would route migrations to the wrong database silently.
183
+ function drizzleOutDir(ctx, unit, configPath) {
184
+ const { repo, compRef } = ctx;
185
+ const unresolved = (why) => {
186
+ ctx.diagnostics.push({
187
+ severity: "warning",
188
+ code: "AGENTDOC_MIGRATION_DIR_UNRESOLVED",
189
+ subject: compRef,
190
+ refs: [compRef],
191
+ message:
192
+ "Drizzle migration directory in '" + configPath + "' is not recorded: " + why + ". " +
193
+ "Declare it with spec.migrationPaths on the logical-database Resource it migrates",
194
+ paths: [configPath],
195
+ });
196
+ return null;
197
+ };
198
+ const parsed = drizzleOut(repo.readText(configPath));
199
+ if (parsed.error) return unresolved(parsed.error);
200
+ const value = parsed.value ?? "./drizzle";
201
+ // Relative to the directory drizzle-kit runs from, which is the config's own
202
+ // directory in the documented layout (and the unit root here).
203
+ const base = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/")) : ".";
204
+ const dir = value.startsWith("/") ? null : canonicalPath(joinPath(base, value));
205
+ if (!dir || dir === "." || dir === canonicalPath(unit.root)) {
206
+ return unresolved("out " + JSON.stringify(value) + " is not a directory inside the unit at '" + unit.root + "'");
207
+ }
208
+ if (!repo.ownsUnitPath(unit.root, dir)) {
209
+ return unresolved("out " + JSON.stringify(value) + " is outside the unit at '" + unit.root + "'");
210
+ }
211
+ // Not generated yet: nothing to classify. The probe is an input, so the
212
+ // first generated migration invalidates the graph.
213
+ if (!repo.isDir(dir)) return null;
214
+ return dir;
215
+ }
216
+
217
+ // Static read of `out` from a Drizzle config. Comments are removed and string
218
+ // literals set aside first, so neither `// out: "x"` nor a string containing
219
+ // "out:" is mistaken for the key.
220
+ export function drizzleOut(text) {
221
+ const strings = [];
222
+ let code = "";
223
+ for (let i = 0; i < text.length; ) {
224
+ const c = text[i];
225
+ if (c === "/" && text[i + 1] === "/") {
226
+ while (i < text.length && text[i] !== "\n") i++;
227
+ continue;
228
+ }
229
+ if (c === "/" && text[i + 1] === "*") {
230
+ const end = text.indexOf("*/", i + 2);
231
+ i = end < 0 ? text.length : end + 2;
232
+ code += " ";
233
+ continue;
234
+ }
235
+ if (c === '"' || c === "'" || c === "`") {
236
+ let j = i + 1;
237
+ let value = "";
238
+ let closed = false;
239
+ while (j < text.length) {
240
+ if (text[j] === "\\") {
241
+ value += text[j + 1] ?? "";
242
+ j += 2;
243
+ continue;
244
+ }
245
+ if (text[j] === c) { closed = true; break; }
246
+ if (c !== "`" && text[j] === "\n") break;
247
+ value += text[j];
248
+ j++;
249
+ }
250
+ if (!closed) return { error: "the config could not be read statically" };
251
+ // A template with a substitution is an expression, not a literal.
252
+ strings.push(c === "`" && value.includes("${") ? null : value);
253
+ code += "\u0000" + (strings.length - 1) + "\u0000";
254
+ i = j + 1;
255
+ continue;
256
+ }
257
+ code += c;
258
+ i++;
259
+ }
260
+ const hits = [];
261
+ for (const m of code.matchAll(/(?:^|[{,\s])(?:out|\u0000(\d+)\u0000)\s*(:|[,}])/g)) {
262
+ // A quoted key counts only when it says "out".
263
+ if (m[1] !== undefined && strings[Number(m[1])] !== "out") continue;
264
+ hits.push(m);
265
+ }
266
+ if (hits.length === 0) {
267
+ // A spread could bring `out` in from elsewhere; the default is then a guess.
268
+ if (/\.\.\.\s*[A-Za-z_$(]/.test(code)) return { error: "the config spreads another object that may set out" };
269
+ return { value: null };
270
+ }
271
+ if (hits.length > 1) return { error: "out is set more than once" };
272
+ const m = hits[0];
273
+ if (m[2] !== ":") return { error: "out is a shorthand property, not a string literal" };
274
+ const rest = code.slice(m.index + m[0].length);
275
+ const lit = /^\s*\u0000(\d+)\u0000\s*(?:[,}]|$)/.exec(rest);
276
+ const value = lit ? strings[Number(lit[1])] : null;
277
+ if (typeof value !== "string" || value.trim() === "") return { error: "out is not a string literal" };
278
+ return { value };
279
+ }
package/bin/agentdoc.mjs CHANGED
@@ -188,7 +188,16 @@ try {
188
188
  process.stdout.write(commandHelp(cmd));
189
189
  process.exit(0);
190
190
  }
191
- if (cmd === "validate") {
191
+ if (["prepare", "expand", "refresh"].includes(cmd)) {
192
+ if (positional.length) throw new UsageError("context operations accept JSON via --request or stdin");
193
+ const file = opt("request");
194
+ const raw = fs.readFileSync(!file || file === "-" ? 0 : file, "utf8");
195
+ if (Buffer.byteLength(raw) > 1024 * 1024) throw new UsageError("context request exceeds 1 MiB");
196
+ const request = JSON.parse(raw);
197
+ const { prepare, expand, refresh, serializePacket } = await import("../core/context/index.mjs");
198
+ const result = ({ prepare, expand, refresh })[cmd](ROOT, request);
199
+ process.stdout.write(serializePacket(result));
200
+ } else if (cmd === "validate") {
192
201
  const res = compile(ROOT);
193
202
  printDiagnostics(res);
194
203
  if (res.errors.length) fail(res.errors, res.stats);
@@ -492,6 +501,12 @@ try {
492
501
  usage(2);
493
502
  }
494
503
  } catch (e) {
504
+ if (e instanceof UsageError || e instanceof SyntaxError && ["prepare", "expand", "refresh"].includes(cmd)) {
505
+ console.error("ERROR " + CODES.CONTEXT_REQUEST + ": " + e.message); process.exit(2);
506
+ }
507
+ if (Object.values(CODES).includes(e.code)) {
508
+ console.error("ERROR " + e.code + ": " + e.message); process.exit(1);
509
+ }
495
510
  if (e instanceof AgentDocError) {
496
511
  console.error("ERROR " + e.format());
497
512
  process.exit(1);
package/bin/commands.mjs CHANGED
@@ -13,6 +13,21 @@ const JSON_FLAG = { json: { desc: "print the machine-readable report" } };
13
13
  const JSON_ALWAYS = { json: { desc: "accepted for explicitness; the output is always JSON" } };
14
14
 
15
15
  export const COMMANDS = {
16
+ prepare: {
17
+ synopsis: "agentdoc prepare [--request file|-]",
18
+ summary: "prepare a versioned task context packet from JSON (stdin by default)",
19
+ flags: { request: { value: "file|-", desc: "JSON request file, or - for stdin" }, ...JSON_ALWAYS },
20
+ },
21
+ expand: {
22
+ synopsis: "agentdoc expand [--request file|-]",
23
+ summary: "expand a packet continuation on the same snapshot",
24
+ flags: { request: { value: "file|-", desc: "JSON continuation request" }, ...JSON_ALWAYS },
25
+ },
26
+ refresh: {
27
+ synopsis: "agentdoc refresh [--request file|-]",
28
+ summary: "refresh context with explicit retained record IDs",
29
+ flags: { request: { value: "file|-", desc: "JSON refresh request" }, ...JSON_ALWAYS },
30
+ },
16
31
  init: {
17
32
  synopsis: "agentdoc init [--force]",
18
33
  summary: "write a configuration and documentation skeleton",
package/bin/usage.txt CHANGED
@@ -4,6 +4,10 @@ agentdoc — portable documentation & software-catalog system
4
4
  agentdoc --help show this help
5
5
  agentdoc <command> --help every flag one command accepts (also: agentdoc help <command>)
6
6
 
7
+ agentdoc prepare [--request file|-] task context packet (JSON stdin/stdout)
8
+ agentdoc expand [--request file|-] continue a packet on its snapshot
9
+ agentdoc refresh [--request file|-] update retained context after changes
10
+
7
11
  agentdoc init [--force] write a configuration and documentation skeleton
8
12
  (docs skeleton goes to the configured docs.* paths;
9
13
  agentdoc/docs/ when there is no configuration yet)
@@ -0,0 +1,34 @@
1
+ // One bounded, atomic cache record per repository. Cache errors only cost work.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { sha256Hex } from "../fsx.mjs";
6
+
7
+ const LIMIT = 32 * 1024 * 1024;
8
+ const digest = (record) => sha256Hex(JSON.stringify([record.key, record.serialized, record.stats]));
9
+ export function readCompiledCache(file, key) {
10
+ try {
11
+ if (fs.statSync(file).size > LIMIT) return null;
12
+ const record = JSON.parse(fs.readFileSync(file, "utf8"));
13
+ if (record.version !== 1 || record.key !== key || typeof record.serialized !== "string") return null;
14
+ if (!record.stats || digest(record) !== record.digest) return null;
15
+ return record;
16
+ } catch { return null; }
17
+ }
18
+
19
+ export function writeCompiledCache(file, record) {
20
+ let temporary;
21
+ try {
22
+ for (const dir of [path.dirname(file), path.dirname(path.dirname(file))]) {
23
+ if (fs.existsSync(dir) && fs.lstatSync(dir).isSymbolicLink()) return false;
24
+ }
25
+ const text = JSON.stringify({ ...record, version: 1, digest: digest(record) });
26
+ if (Buffer.byteLength(text) > LIMIT) return false;
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ temporary = file + "." + randomUUID() + ".tmp";
29
+ fs.writeFileSync(temporary, text, { flag: "wx", mode: 0o600 });
30
+ fs.renameSync(temporary, file);
31
+ return true;
32
+ } catch { return false; }
33
+ finally { if (temporary) { try { fs.rmSync(temporary, { force: true }); } catch { /* cache is optional */ } } }
34
+ }
@@ -0,0 +1,33 @@
1
+ import { sha256Hex } from "../fsx.mjs";
2
+
3
+ // Bounded content-addressed JSON parsing across snapshots in one process.
4
+ // Values are cloned on both boundaries: extractors may mutate their result.
5
+ export class ParseCache {
6
+ constructor(maxBytes = 8 * 1024 * 1024) {
7
+ this.maxBytes = maxBytes;
8
+ this.bytes = 0;
9
+ this.entries = new Map();
10
+ }
11
+ get(text) {
12
+ const key = sha256Hex("json@1\0" + text);
13
+ const entry = this.entries.get(key);
14
+ if (!entry) return undefined;
15
+ this.entries.delete(key);
16
+ this.entries.set(key, entry);
17
+ return structuredClone(entry.value);
18
+ }
19
+ set(text, value) {
20
+ const bytes = Buffer.byteLength(text);
21
+ if (bytes > this.maxBytes) return;
22
+ const key = sha256Hex("json@1\0" + text);
23
+ if (this.entries.has(key)) this.bytes -= this.entries.get(key).bytes;
24
+ this.entries.delete(key);
25
+ while ((this.bytes + bytes > this.maxBytes || this.entries.size >= 1000) && this.entries.size) {
26
+ const oldest = this.entries.keys().next().value;
27
+ this.bytes -= this.entries.get(oldest).bytes;
28
+ this.entries.delete(oldest);
29
+ }
30
+ this.entries.set(key, { bytes, value: structuredClone(value) });
31
+ this.bytes += bytes;
32
+ }
33
+ }
package/core/codes.mjs CHANGED
@@ -2,6 +2,17 @@
2
2
  // produce is named here so tests, CI and the skill can match on identity
3
3
  // rather than on human-readable message text.
4
4
  export const CODES = {
5
+ // Task context, session evidence and snapshot capture.
6
+ CONTEXT_REQUEST: "AGENTDOC_CONTEXT_REQUEST",
7
+ CONTEXT_BUDGET: "AGENTDOC_CONTEXT_BUDGET",
8
+ CONTEXT_TOKENIZER: "AGENTDOC_CONTEXT_TOKENIZER",
9
+ CONTEXT_COMPILE: "AGENTDOC_CONTEXT_COMPILE",
10
+ CONTEXT_REFERENCE: "AGENTDOC_CONTEXT_REFERENCE",
11
+ CONTEXT_STALE: "AGENTDOC_CONTEXT_STALE",
12
+ SESSION_RESULT: "AGENTDOC_SESSION_RESULT",
13
+ SYMBOL_REQUEST: "AGENTDOC_SYMBOL_REQUEST",
14
+ SNAPSHOT_UNSTABLE: "AGENTDOC_SNAPSHOT_UNSTABLE",
15
+
5
16
  // source & schema
6
17
  UTF8: "AGENTDOC_UTF8",
7
18
  YAML_PARSE: "AGENTDOC_YAML_PARSE",
@@ -23,6 +34,7 @@ export const CODES = {
23
34
  GLOB: "AGENTDOC_GLOB",
24
35
  PATH_ESCAPE: "AGENTDOC_PATH_ESCAPE",
25
36
  PATH_UNRESOLVED: "AGENTDOC_PATH_UNRESOLVED",
37
+ MIGRATION_PATH: "AGENTDOC_MIGRATION_PATH",
26
38
  SECRET: "AGENTDOC_SECRET",
27
39
 
28
40
  // identity & references
@@ -98,6 +110,7 @@ export const ACCEPTABLE_WARNING_CODES = Object.freeze([
98
110
  "AGENTDOC_NORMATIVE_UNCOVERED",
99
111
  "AGENTDOC_REQUIREMENT_UNVERIFIED",
100
112
  "AGENTDOC_PYTHON_SPECIFIER",
113
+ "AGENTDOC_MIGRATION_DIR_UNRESOLVED",
101
114
  ]);
102
115
 
103
116
  export class AgentDocError extends Error {
package/core/compile.mjs CHANGED
@@ -49,7 +49,27 @@ function earlyOut(res, extra = {}) {
49
49
  }
50
50
 
51
51
  export function compile(root, opts = {}) {
52
+ const started = performance.now();
53
+ const result = compileOnce(root, opts);
54
+ if (opts.telemetry) result.telemetry = {
55
+ durationMs: performance.now() - started,
56
+ ...result.repo?.metrics,
57
+ serializedBytes: result.serialized ? Buffer.byteLength(result.serialized) : 0,
58
+ heapUsedBytes: process.memoryUsage().heapUsed,
59
+ stages: result.phaseTimings || {},
60
+ };
61
+ return result;
62
+ }
63
+
64
+ function compileOnce(root, opts = {}) {
52
65
  const res = new CompileResult();
66
+ let stageStart = opts.telemetry ? performance.now() : 0;
67
+ const stage = (name) => {
68
+ if (!opts.telemetry) return;
69
+ const now = performance.now();
70
+ (res.phaseTimings ||= {})[name] = now - stageStart;
71
+ stageStart = now;
72
+ };
53
73
  // A peek load, only to read the generated-directory list: a project that keeps
54
74
  // build output in a directory this build does not know about must be able to
55
75
  // say so.
@@ -60,7 +80,7 @@ export function compile(root, opts = {}) {
60
80
  } catch {
61
81
  // No configuration, or an invalid one: the real load below reports it.
62
82
  }
63
- const repo = new Repo(root, { noGit: opts.noGit, generatedDirs: cfgGeneratedDirs });
83
+ const repo = new Repo(root, { noGit: opts.noGit, generatedDirs: cfgGeneratedDirs, snapshotReads: opts.snapshotReads, parseCache: opts.parseCache });
64
84
  let bundle;
65
85
  let schemaInputs;
66
86
  try {
@@ -83,6 +103,7 @@ export function compile(root, opts = {}) {
83
103
  // a coverage failure is what scaffolding exists to fix.
84
104
  res.repo = repo;
85
105
  res.cfg = cfg;
106
+ stage("configuration");
86
107
  const refs = makeRefs(cfg.namespace);
87
108
  const graphPath = cfg.output.graph;
88
109
 
@@ -145,6 +166,7 @@ export function compile(root, opts = {}) {
145
166
  throw e;
146
167
  }
147
168
  const discovery = discoverUnits(repo, cfg, adapters);
169
+ stage("sourcesAndDiscovery");
148
170
  repo.nestedUnitRoots = discovery.nestedRoots;
149
171
  // Eligibility is the compiler's own answer to "which directories are units" —
150
172
  // exposed on the result so scaffold can write descriptors for exactly these
@@ -498,6 +520,10 @@ export function compile(root, opts = {}) {
498
520
  "entf:" + e.ref + ":/metadata/description",
499
521
  ];
500
522
  if (e.doc.spec !== undefined) subjects.push("entf:" + e.ref + ":/spec");
523
+ // A declared migration path is the one authored path whose meaning reaches
524
+ // past the descriptor (impact routes every change below it to this
525
+ // Resource), so it is pointed at directly rather than through `/spec`.
526
+ if (e.doc.spec?.migrationPaths) subjects.push("entf:" + e.ref + ":/spec/migrationPaths");
501
527
  prov.add("declared", e.file, "descriptor-reader", subjects);
502
528
  }
503
529
 
@@ -777,7 +803,7 @@ export function compile(root, opts = {}) {
777
803
  return earlyOut(res, { entities: sources.entities.length, units: discovery.eligible.length });
778
804
  }
779
805
  for (const d of derivedMap.values()) {
780
- for (const list of [d.bindings || [], d.healthContracts || []]) {
806
+ for (const list of [d.bindings || [], d.healthContracts || [], d.migrationDirs || []]) {
781
807
  for (const b of list) {
782
808
  b.provenanceIds = [...(b._provs || [])].map((p) => provFinal.idOf.get(p)).filter(Boolean).sort();
783
809
  delete b._provs;
@@ -785,6 +811,7 @@ export function compile(root, opts = {}) {
785
811
  }
786
812
  }
787
813
 
814
+ stage("extractionAndAuthority");
788
815
  const graph = assembleGraph(sources, rel, indexes, derivedMap, facts, rawConflicts, observationOut, provFinal, diagnostics);
789
816
  graph.compiler = { name: COMPILER_NAME, version: COMPILER_VERSION, adapters: adapterStamp(adapters) };
790
817
 
@@ -861,6 +888,7 @@ export function compile(root, opts = {}) {
861
888
  commit,
862
889
  dirty,
863
890
  };
891
+ stage("assemblyAndValidation");
864
892
  return res;
865
893
  }
866
894