agent-doc-system 1.4.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/core/fsx.mjs CHANGED
@@ -70,11 +70,19 @@ export class Repo {
70
70
  this.root = path.resolve(root);
71
71
  this.readPaths = new Map();
72
72
  this.probes = new Map();
73
+ this.probeRecords = new Map();
73
74
  this.walks = new Map();
75
+ this.walkInputs = new Map();
74
76
  this.listings = new Map();
75
77
  this.nestedUnitRoots = new Map();
76
78
  this.generatedDirs = new Set(opts.generatedDirs || DEFAULT_GENERATED_DIRS);
77
79
  this.noGit = Boolean(opts.noGit);
80
+ this.snapshotReads = Boolean(opts.snapshotReads);
81
+ this.byteCache = new Map();
82
+ this.textCache = new Map();
83
+ this.jsonCache = new Map();
84
+ this.parseCache = opts.parseCache;
85
+ this.metrics = { physicalReads: 0, bytesRead: 0, readCacheHits: 0, jsonParses: 0, jsonCacheHits: 0, sharedJsonHits: 0 };
78
86
  // Per-Repo git caches. undefined = not asked yet; null = no answer.
79
87
  this._hasGit = undefined;
80
88
  this._prefix = undefined;
@@ -148,15 +156,18 @@ export class Repo {
148
156
  exists(rel) {
149
157
  const r = !this.isIgnored(rel) && fs.existsSync(this.abs(rel));
150
158
  this.probes.set(rel, r);
159
+ this.probeRecords.set("exists:" + rel, { kind: "exists", path: rel, value: r });
151
160
  return r;
152
161
  }
153
162
  isDir(rel) {
154
163
  try {
155
164
  const r = !this.isIgnored(rel) && fs.statSync(this.abs(rel)).isDirectory();
156
165
  this.probes.set(rel, r);
166
+ this.probeRecords.set("isDir:" + rel, { kind: "isDir", path: rel, value: r });
157
167
  return r;
158
168
  } catch {
159
169
  this.probes.set(rel, false);
170
+ this.probeRecords.set("isDir:" + rel, { kind: "isDir", path: rel, value: false });
160
171
  return false;
161
172
  }
162
173
  }
@@ -172,9 +183,15 @@ export class Repo {
172
183
  { path: rel }
173
184
  );
174
185
  }
175
- let buf;
186
+ let buf = this.snapshotReads ? this.byteCache.get(rel) : undefined;
187
+ if (buf) this.metrics.readCacheHits++;
176
188
  try {
177
- buf = fs.readFileSync(a);
189
+ if (!buf) {
190
+ buf = fs.readFileSync(a);
191
+ this.metrics.physicalReads++;
192
+ this.metrics.bytesRead += buf.length;
193
+ if (this.snapshotReads) this.byteCache.set(rel, buf);
194
+ }
178
195
  } catch {
179
196
  throw new AgentDocError(CODES.PATH_UNRESOLVED, "path does not resolve inside the repository: " + rel, { path: rel });
180
197
  }
@@ -183,6 +200,7 @@ export class Repo {
183
200
  }
184
201
  readText(rel, opts = {}) {
185
202
  const buf = this.readBytes(rel, opts);
203
+ if (this.snapshotReads && this.textCache.has(rel)) return this.textCache.get(rel);
186
204
  let text;
187
205
  try {
188
206
  text = new TextDecoder("utf-8", { fatal: true }).decode(buf);
@@ -190,10 +208,26 @@ export class Repo {
190
208
  throw new AgentDocError(CODES.UTF8, "file is not valid UTF-8: " + rel, { path: rel });
191
209
  }
192
210
  if (text.startsWith("")) text = text.slice(1);
211
+ if (this.snapshotReads) this.textCache.set(rel, text);
193
212
  return text;
194
213
  }
195
214
  readJson(rel) {
196
- return JSON.parse(this.readText(rel));
215
+ const text = this.readText(rel);
216
+ if (this.snapshotReads && this.jsonCache.has(rel)) {
217
+ this.metrics.jsonCacheHits++;
218
+ return structuredClone(this.jsonCache.get(rel));
219
+ }
220
+ const shared = this.parseCache?.get(text);
221
+ if (shared !== undefined) {
222
+ this.metrics.sharedJsonHits++;
223
+ if (this.snapshotReads) this.jsonCache.set(rel, structuredClone(shared));
224
+ return shared;
225
+ }
226
+ this.metrics.jsonParses++;
227
+ const value = JSON.parse(text);
228
+ this.parseCache?.set(text, value);
229
+ if (this.snapshotReads) this.jsonCache.set(rel, structuredClone(value));
230
+ return value;
197
231
  }
198
232
  readJsonUntracked(rel) {
199
233
  return JSON.parse(this.readText(rel, { track: false }));
@@ -215,6 +249,10 @@ export class Repo {
215
249
  continue;
216
250
  }
217
251
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
252
+ if (this.snapshotReads) {
253
+ const rel = path.relative(this.root, dir).split(path.sep).join("/") || ".";
254
+ this.walkInputs.set(rel, this.directoryState(rel, entries));
255
+ }
218
256
  for (const ent of entries) {
219
257
  if (this.generatedDirs.has(ent.name)) continue;
220
258
  const full = path.join(dir, ent.name);
@@ -232,6 +270,15 @@ export class Repo {
232
270
  this.walks.set(relDir || ".", out);
233
271
  return out;
234
272
  }
273
+ // Raw visited-directory state, independent of extractor filter closures.
274
+ // Those closures may depend on discovery state which evolves during compile.
275
+ directoryState(relDir, entries = fs.readdirSync(this.abs(relDir), { withFileTypes: true })) {
276
+ const prefix = relDir && relDir !== "." ? relDir + "/" : "";
277
+ return entries
278
+ .filter(e => !this.generatedDirs.has(e.name) && !this.isIgnored(prefix + e.name))
279
+ .map(e => [e.name, e.isSymbolicLink() ? "link" : e.isDirectory() ? "dir" : "file"])
280
+ .sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
281
+ }
235
282
  // Per-unit scans use the discovery result, never filesystem heuristics.
236
283
  // Keep walk() repository-wide for discovery, contracts and authored inputs.
237
284
  ownsUnitPath(unitRoot, rel) {
package/core/graph.mjs CHANGED
@@ -9,7 +9,7 @@ import { assertDeterministic, structuralProjection } from "./determinism.mjs";
9
9
  import { sha256Hex } from "./fsx.mjs";
10
10
 
11
11
  export const COMPILER_NAME = "agentdoc-compiler";
12
- export const COMPILER_VERSION = "1.4.0";
12
+ export const COMPILER_VERSION = "1.5.0";
13
13
  export const GRAPH_SCHEMA_VERSION = "agentdoc.dev/graph/v1";
14
14
 
15
15
  // Resolve symbolic provenance subjects to RFC 6901 pointers into the graph.
package/core/query.mjs CHANGED
@@ -130,7 +130,7 @@ function contractsOf(graph) {
130
130
 
131
131
  export function queryContext(graph, needle, opts = {}) {
132
132
  const budget = { ...DEFAULT_BUDGET, ...(opts.budget || {}) };
133
- const idx = new ContextIndex(graph);
133
+ const idx = opts.index || new ContextIndex(graph);
134
134
  const entity = idx.resolve(needle);
135
135
  if (!entity) {
136
136
  throw new AgentDocError(
@@ -379,8 +379,21 @@ export function renderContext(ctx, opts = {}) {
379
379
  if (ctx.placement.domains.length) L.push("- domain: " + ctx.placement.domains.join(", "));
380
380
  if (ctx.apis.provided.length) L.push("- provides: " + ctx.apis.provided.map((a) => a.api).join(", "));
381
381
  if (ctx.apis.consumed.length) L.push("- consumes: " + ctx.apis.consumed.map((a) => a.api + " (via " + a.provider + ")").join(", "));
382
+ if (ctx.apis.provider) L.push("- API provider: " + ctx.apis.provider);
383
+ if (ctx.apis.consumers.length) L.push("- API consumers: " + ctx.apis.consumers.join(", "));
384
+ if (ctx.apis.contract) L.push("- API contract: " + JSON.stringify(ctx.apis.contract));
382
385
  if (ctx.resources.used.length) L.push("- uses resource: " + ctx.resources.used.join(", "));
383
- if (ctx.events.items.length) L.push("- events: " + ctx.events.items.map((x) => x.pattern + " [" + x.resolution + "]").join(", "));
386
+ if (ctx.resources.usedBy.length) L.push("- resource users: " + ctx.resources.usedBy.join(", "));
387
+ if (ctx.relations.items.length) {
388
+ L.push("- relations:");
389
+ for (const r of ctx.relations.items) {
390
+ L.push(" - " + r.direction + " " + r.type + " " + r.other + " [" + r.evidenceClass + "]" +
391
+ (r.attributes ? " " + JSON.stringify(r.attributes) : ""));
392
+ }
393
+ }
394
+ if (ctx.events.items.length) L.push("- events: " + ctx.events.items.map((x) => x.pattern + " [" + x.resolution + "]" +
395
+ " producers=" + JSON.stringify(x.producers) + " consumers=" + JSON.stringify(x.consumers) +
396
+ (x.contract ? " contract=" + JSON.stringify(x.contract) : "")).join(", "));
384
397
  if (ctx.externalDependencies.items.length) L.push("- external: " + ctx.externalDependencies.items.map((x) => x.name + " (" + x.role + ")").join(", "));
385
398
  if (ctx.capabilities.items.length) L.push("- capabilities: " + ctx.capabilities.items.map((x) => x.name).join(", "));
386
399
  for (const key of ["constraints", "docs", "runbooks"]) {
@@ -395,13 +408,36 @@ export function renderContext(ctx, opts = {}) {
395
408
  }
396
409
  if (ctx.verification.items.length) {
397
410
  L.push("- verify:");
398
- for (const v of ctx.verification.items) L.push(" - [" + v.tier + "] " + v.command);
411
+ for (const v of ctx.verification.items) L.push(" - [" + v.tier + "] " + v.command + " (" + v.id +
412
+ (v.configPaths?.length ? "; config: " + v.configPaths.join(", ") : "") + ")");
413
+ }
414
+ if (ctx.globalGates.length) {
415
+ L.push("- repository gates (required in addition to scoped checks):");
416
+ for (const v of ctx.globalGates) L.push(" - [" + v.tier + "] " + v.command + " (" + v.id + ")");
399
417
  }
400
418
  if (ctx.journeys.items.length) L.push("- journeys: " + ctx.journeys.items.map((j) => j.id).join(", "));
401
- if (ctx.authority.conflicts.items.length) {
402
- L.push("- CONFLICTS (do not assume one is true):");
403
- for (const c of ctx.authority.conflicts.items) {
419
+ if (ctx.journeyPeers.length) L.push("- journey peers: " + ctx.journeyPeers.join(", "));
420
+ if (ctx.contracts.items.length) {
421
+ L.push("- contracts:");
422
+ for (const c of ctx.contracts.items) L.push(" - " + JSON.stringify(c));
423
+ }
424
+ if (ctx.authority.assertions.items.length) {
425
+ L.push("- assertions:");
426
+ for (const a of ctx.authority.assertions.items) {
427
+ L.push(" - " + a.key + " [" + a.evidenceClass + "/" + a.confidence + "/" + a.status + "]: " +
428
+ JSON.stringify(a.value) + (a.semantics ? " (" + a.semantics + ")" : ""));
429
+ }
430
+ }
431
+ for (const [settled, title] of [
432
+ [false, "CONFLICTS (do not assume one is true)"],
433
+ [true, "RESOLVED CONFLICTS (use the recorded election; re-check its review condition)"],
434
+ ]) {
435
+ const conflicts = ctx.authority.conflicts.items.filter((c) => (c.status !== "unresolved") === settled);
436
+ if (!conflicts.length) continue;
437
+ L.push("- " + title + ":");
438
+ for (const c of conflicts) {
404
439
  L.push(" - " + c.key + " [" + c.kind + "/" + c.status + "] " + (c.detail || ""));
440
+ if (c.election) L.push(" - election: " + JSON.stringify(c.election));
405
441
  for (const a of c.assertions) L.push(" - " + a.evidenceClass + "/" + a.confidence + ": " + JSON.stringify(a.value));
406
442
  }
407
443
  }
@@ -421,9 +457,24 @@ export function renderContext(ctx, opts = {}) {
421
457
  const obs = ctx.authority.observations;
422
458
  if (obs.length) {
423
459
  L.push("- RUNTIME OBSERVATIONS:");
424
- for (const o of obs) L.push(" - " + o.environment + " via " + o.sourceSystem + " at " + o.capturedAt + " (evidence " + o.evidenceBundle + ")");
460
+ for (const o of obs) {
461
+ L.push(" - " + o.environment + " via " + o.sourceSystem + " at " + o.capturedAt + " (evidence " + o.evidenceBundle + ")");
462
+ for (const f of o.facts) L.push(" - " + f.key + ": " + JSON.stringify(f.value) +
463
+ (f.semantics ? " (" + f.semantics + ")" : ""));
464
+ }
425
465
  }
426
466
  const neighbours = ctx.neighbours.filter((n) => n.ref !== e.ref);
427
467
  if (neighbours.length) L.push("- neighbours: " + neighbours.map((n) => n.ref).join(", "));
468
+ if (ctx.provenance.items.length) {
469
+ L.push("- evidence:");
470
+ for (const p of ctx.provenance.items) L.push(" - " + p.id + " " + p.path + " [" + p.class + "] " + p.contentHash);
471
+ }
472
+ if (ctx.truncated.length) {
473
+ L.push("- INCOMPLETE CONTEXT (request a larger --budget before treating these sections as complete):");
474
+ for (const name of ctx.truncated) {
475
+ const section = name.split(".").reduce((value, key) => value[key], ctx);
476
+ L.push(" - " + name + " [truncated: " + section.items.length + " of " + section.total + "]");
477
+ }
478
+ }
428
479
  return L.join("\n");
429
480
  }
@@ -0,0 +1,138 @@
1
+ import { CODES } from "../codes.mjs";
2
+ // Imported runner evidence is scoped to one repository snapshot and execution
3
+ // context. This module never executes a check or promotes a recommendation into
4
+ // a success. Any changed graph or evidence invalidates the imported result.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { compileSnapshot } from "../snapshot.mjs";
9
+ import { canonicalPath } from "../paths.mjs";
10
+ import { observationFreshness } from "../observations.mjs";
11
+ import { assertNoSecretsInText } from "../secrets.mjs";
12
+
13
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
14
+ const graphHash = (graph) => digest(JSON.stringify(graph));
15
+ function reject(message) { throw Object.assign(new Error(message), { code: CODES.SESSION_RESULT }); }
16
+ function object(value, keys, name) {
17
+ if (!value || typeof value !== "object" || Array.isArray(value)) reject(name + " must be an object");
18
+ for (const key of Object.keys(value)) if (!keys.includes(key)) reject("unknown " + name + " field: " + key);
19
+ }
20
+ function nonempty(value, name) {
21
+ if (typeof value !== "string" || !value.trim()) reject(name + " must be a nonempty string");
22
+ }
23
+ function sessionFile(root, session) {
24
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(session || "")) reject("invalid session reference");
25
+ return path.join(path.resolve(root), ".agentdoc", "sessions", "check-results", session + ".json");
26
+ }
27
+ function repositoryPath(root, value, directory = false) {
28
+ const rel = canonicalPath(value);
29
+ if (rel === null || typeof value !== "string" || !value) reject("execution paths must stay inside the repository");
30
+ let real;
31
+ try {
32
+ real = fs.realpathSync(path.join(root, rel));
33
+ const stat = fs.statSync(real);
34
+ if (directory ? !stat.isDirectory() : !stat.isFile()) reject("wrong execution path kind: " + rel);
35
+ } catch { reject("execution path unavailable: " + rel); }
36
+ const base = fs.realpathSync(root);
37
+ if (real !== base && !real.startsWith(base + path.sep)) reject("execution path escapes repository: " + rel);
38
+ return { rel, real };
39
+ }
40
+ function current(root, snapshot) {
41
+ if (!snapshot || !/^[a-f0-9]{64}$/.test(snapshot.graphHash || "")) reject("snapshot.graphHash must identify the full graph");
42
+ const res = compileSnapshot(root);
43
+ if (res.errors.length) reject("cannot validate results against a failing compilation");
44
+ if (snapshot.graphHash !== graphHash(res.graph)) reject("snapshot changed; imported checks must be run again");
45
+ if (observationFreshness(res.graph.observations).some((o) => o.stale)) reject("runtime observation expired; refresh context before importing checks");
46
+ return res.graph;
47
+ }
48
+ function execution(result, root, graph) {
49
+ object(result, ["checkId", "command", "cwd", "status", "exitCode", "runner", "evidence"], "result");
50
+ nonempty(result.checkId, "checkId");
51
+ nonempty(result.command, "command");
52
+ const check = [...graph.verification, ...graph.gates].find((v) => v.id === result.checkId);
53
+ if (!check || check.command !== result.command) reject("result must name the exact registered check and command");
54
+ const cwd = repositoryPath(root, result.cwd, true).rel;
55
+ if (!["succeeded", "failed"].includes(result.status) || !Number.isSafeInteger(result.exitCode) || result.exitCode < 0 ||
56
+ (result.status === "succeeded") !== (result.exitCode === 0)) reject("execution status must agree with its exit code");
57
+ object(result.runner, ["name", "version"], "runner");
58
+ nonempty(result.runner.name, "runner.name"); nonempty(result.runner.version, "runner.version");
59
+ object(result.evidence, ["path", "sha256"], "evidence");
60
+ if (!/^[a-f0-9]{64}$/.test(result.evidence.sha256 || "")) reject("evidence requires a SHA-256 digest");
61
+ const evidencePath = repositoryPath(root, result.evidence.path);
62
+ const bytes = fs.readFileSync(evidencePath.real);
63
+ if (digest(bytes) !== result.evidence.sha256) reject("execution evidence changed");
64
+ let report;
65
+ try { report = JSON.parse(bytes.toString("utf8")); } catch { reject("execution evidence must be a JSON runner report"); }
66
+ if (!report || typeof report !== "object" || report.snapshot?.graphHash !== graphHash(graph) ||
67
+ report.checkId !== result.checkId || report.command !== result.command || report.cwd !== cwd ||
68
+ report.status !== result.status || report.exitCode !== result.exitCode ||
69
+ report.runner?.name !== result.runner.name || report.runner?.version !== result.runner.version) reject("runner report does not corroborate the imported result");
70
+ return { ...result, cwd, runner: { ...result.runner }, evidence: { path: evidencePath.rel, sha256: result.evidence.sha256 } };
71
+ }
72
+ function read(root, session) {
73
+ const file = sessionFile(root, session);
74
+ if (!fs.existsSync(file)) return null;
75
+ try {
76
+ const stored = JSON.parse(fs.readFileSync(file, "utf8"));
77
+ if (stored.digest !== digest(JSON.stringify(stored.data)) || stored.data.repository !== fs.realpathSync(root) ||
78
+ stored.data.session !== session || stored.data.schemaVersion !== "agentdoc.dev/check-results/v1" || !Array.isArray(stored.data.results)) reject("invalid stored results");
79
+ return stored.data;
80
+ } catch { reject("stored session results are invalid; forget the session and rerun its checks"); }
81
+ }
82
+
83
+ export function importCheckResults(root, request) {
84
+ object(request, ["session", "snapshot", "results"], "import");
85
+ const file = sessionFile(root, request.session);
86
+ if (!Array.isArray(request.results) || !request.results.length) reject("results must be a nonempty execution list");
87
+ const graph = current(root, request.snapshot);
88
+ const incoming = request.results.map((r) => execution(r, root, graph));
89
+ const previous = read(root, request.session);
90
+ const combined = new Map();
91
+ if (previous?.graphHash === request.snapshot.graphHash) {
92
+ for (const r of previous.results) {
93
+ try { const valid = execution(r, root, graph); combined.set(JSON.stringify([valid.checkId, valid.command, valid.cwd, valid.runner]), valid); }
94
+ catch { /* An old evidence bundle may have been replaced by a rerun. */ }
95
+ }
96
+ }
97
+ for (const r of incoming) combined.set(JSON.stringify([r.checkId, r.command, r.cwd, r.runner]), r);
98
+ const data = { schemaVersion: "agentdoc.dev/check-results/v1", repository: fs.realpathSync(root), session: request.session,
99
+ graphHash: request.snapshot.graphHash, results: [...combined.values()] };
100
+ const serialized = JSON.stringify({ data, digest: digest(JSON.stringify(data)) });
101
+ assertNoSecretsInText(serialized, "session check results");
102
+ fs.mkdirSync(path.dirname(file), { recursive: true });
103
+ const temp = file + "." + randomUUID() + ".tmp";
104
+ try { fs.writeFileSync(temp, serialized, { mode: 0o600 }); fs.renameSync(temp, file); }
105
+ finally { fs.rmSync(temp, { force: true }); }
106
+ return { session: request.session, graphHash: data.graphHash, results: data.results };
107
+ }
108
+
109
+ export function validResultsForSnapshot(root, session, snapshot) {
110
+ const data = read(root, session);
111
+ if (!data || data.graphHash !== snapshot?.graphHash) return [];
112
+ let graph;
113
+ try { graph = current(root, snapshot); } catch { return []; }
114
+ const valid = [];
115
+ for (const result of data.results) {
116
+ try { valid.push(execution(result, root, graph)); } catch { /* Expired or changed evidence is not a valid result. */ }
117
+ }
118
+ return valid;
119
+ }
120
+
121
+ export function forgetSession(root, session) {
122
+ const file = sessionFile(root, session);
123
+ const existed = fs.existsSync(file);
124
+ fs.rmSync(file, { force: true });
125
+ return existed;
126
+ }
127
+
128
+ export function clearSessions(root) {
129
+ const dir = path.dirname(sessionFile(root, "session"));
130
+ if (!fs.existsSync(dir)) return 0;
131
+ let removed = 0;
132
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
133
+ if (entry.isFile() && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}\.json$/.test(entry.name)) {
134
+ fs.unlinkSync(path.join(dir, entry.name)); removed++;
135
+ }
136
+ }
137
+ return removed;
138
+ }
@@ -0,0 +1,123 @@
1
+ // Conservative persistent compilation reuse. Every visible file is hashed;
2
+ // this deliberately over-invalidates rather than inventing extractor-level
3
+ // dependencies. Full compile remains the oracle and is always available.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { Repo, sha256Hex } from "./fsx.mjs";
8
+ import { compile, CompileResult } from "./compile.mjs";
9
+ import { loadConfig, loadSchemaBundle } from "./descriptors.mjs";
10
+ import { AgentDocError, CODES } from "./codes.mjs";
11
+ import { readCompiledCache, writeCompiledCache } from "./cache/compiled.mjs";
12
+ import { ParseCache } from "./cache/parses.mjs";
13
+
14
+ const implementationRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
+
16
+ function implementationHash() {
17
+ const records = [];
18
+ for (const dir of ["core", "adapters", "schemas"]) {
19
+ const walk = (base) => {
20
+ for (const entry of fs.readdirSync(base, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
21
+ const file = path.join(base, entry.name);
22
+ if (entry.isDirectory()) walk(file);
23
+ else if (entry.isFile()) records.push([path.relative(implementationRoot, file), sha256Hex(fs.readFileSync(file))]);
24
+ }
25
+ };
26
+ walk(path.join(implementationRoot, dir));
27
+ }
28
+ return sha256Hex(JSON.stringify(records));
29
+ }
30
+
31
+ // A running process keeps its imported implementation even if files on disk
32
+ // are replaced by an upgrade. Never label old code's result with the new hash.
33
+ const implementationVersion = implementationHash();
34
+ const sharedParses = new ParseCache();
35
+
36
+ function capture(root, opts) {
37
+ const schema = loadSchemaBundle();
38
+ const cfg = loadConfig(new Repo(root, { noGit: true }), schema.bundle);
39
+ const repo = new Repo(root, { noGit: opts.noGit, generatedDirs: cfg.discovery.generatedDirs || opts.generatedDirs, snapshotReads: true });
40
+ const files = repo.walk("").filter((file) => file !== cfg.output.graph);
41
+ const content = files.map((file) => [file, sha256Hex(repo.readBytes(file))]);
42
+ // Empty directories and path kinds are captured too: exists/isDir/listDir
43
+ // probes may affect resolution even when there is no readable file yet.
44
+ let symlinks = false;
45
+ const listings = [];
46
+ const visit = (dir) => {
47
+ if (repo.isIgnored(dir)) return;
48
+ const entries = fs.readdirSync(repo.abs(dir), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
49
+ const visible = [];
50
+ for (const entry of entries) {
51
+ const rel = dir === "." ? entry.name : dir + "/" + entry.name;
52
+ if (repo.generatedDirs.has(entry.name) || repo.isIgnored(rel)) continue;
53
+ visible.push([entry.name, entry.isSymbolicLink() ? "link" : entry.isDirectory() ? "dir" : "file"]);
54
+ if (entry.isSymbolicLink()) symlinks = true;
55
+ if (entry.isDirectory()) visit(rel);
56
+ }
57
+ listings.push([dir, visible]);
58
+ };
59
+ visit(".");
60
+ const state = {
61
+ version: 1, implementation: implementationVersion, schemas: schema.versionInputs, noGit: Boolean(opts.noGit),
62
+ generatedDirs: [...repo.generatedDirs].sort(), commit: repo.headCommit(),
63
+ status: repo.statusRecords()?.filter((r) => r.xy !== "!!" && r.path !== cfg.output.graph) || null, content, listings,
64
+ };
65
+ return { key: sha256Hex(JSON.stringify(state)), repo, cfg, files: new Set(files), digests: new Map(content), symlinks };
66
+ }
67
+
68
+ /** Read-only result consumer API. Extraction-only fields (ctx/discovery) are
69
+ * available on a miss only; callers needing those must use compile().
70
+ * Time-dependent observation gates must run after every call, including hits.
71
+ */
72
+ export function compileSnapshot(root, opts = {}) {
73
+ const started = performance.now();
74
+ const file = path.join(path.resolve(root), ".agentdoc", "cache", "compiled-v1.json");
75
+ for (let attempt = 0; attempt < 2; attempt++) {
76
+ let before;
77
+ try { before = capture(root, opts); }
78
+ catch { return compile(root, { ...opts, snapshotReads: false }); }
79
+ // Explicit authored paths can follow a symlink even though discovery does
80
+ // not. Without a target manifest, reuse would miss changes outside the walk.
81
+ if (before.symlinks) return compile(root, { ...opts, snapshotReads: false });
82
+ const cached = opts.cache === false ? null : readCompiledCache(file, before.key);
83
+ let result;
84
+ if (cached) {
85
+ try {
86
+ const graph = JSON.parse(cached.serialized);
87
+ const invalid = loadSchemaBundle().bundle.validator("agentdoc.dev/schema/graph.schema.json").validate(graph);
88
+ if (!invalid) {
89
+ result = new CompileResult();
90
+ Object.assign(result, { graph, serialized: cached.serialized, stats: cached.stats, diagnostics: graph.diagnostics,
91
+ cfg: before.cfg, repo: before.repo, graphPath: before.cfg.output.graph });
92
+ }
93
+ } catch { /* corrupt cache: compile instead */ }
94
+ }
95
+ const hit = Boolean(result);
96
+ result ||= compile(root, { ...opts, snapshotReads: true, telemetry: true, parseCache: opts.parseCache || sharedParses });
97
+ if (!hit && result.repo) {
98
+ const excluded = [...result.repo.readPaths.keys()].some((p) => !before.files.has(p)) ||
99
+ [...result.repo.probes.keys()].some((p) => p.split("/").some((seg) => before.repo.generatedDirs.has(seg)) || p === before.cfg.output.graph);
100
+ if (excluded) return compile(root, { ...opts, snapshotReads: false });
101
+ }
102
+ let after;
103
+ try { after = capture(root, opts); }
104
+ catch { continue; }
105
+ if (before.key !== after.key) continue;
106
+ if (!hit && result.repo) {
107
+ if ([...result.repo.readPaths].some(([p, digest]) => after.digests.get(p) !== digest)) continue;
108
+ if ([...result.repo.probeRecords.values()].some((probe) => after.repo[probe.kind](probe.path) !== probe.value)) continue;
109
+ if ([...result.repo.listings].some(([p, names]) => JSON.stringify(after.repo.listDir(p)) !== JSON.stringify(names))) continue;
110
+ if ([...result.repo.walkInputs].some(([dir, entries]) => JSON.stringify(after.repo.directoryState(dir)) !== JSON.stringify(entries))) continue;
111
+ }
112
+ if (!hit && !result.errors.length && opts.cache !== false) writeCompiledCache(file, {
113
+ key: before.key, serialized: result.serialized, stats: result.stats,
114
+ });
115
+ // Consumers read evidence from the captured bytes, including on cache hits.
116
+ result.repo = before.repo;
117
+ result.cache = { hit, key: before.key, mode: "whole-compile", persisted: opts.cache !== false };
118
+ result.telemetry = { ...(result.telemetry || {}), durationMs: performance.now() - started,
119
+ captureReads: before.repo.metrics.physicalReads + after.repo.metrics.physicalReads };
120
+ return result;
121
+ }
122
+ throw new AgentDocError(CODES.SNAPSHOT_UNSTABLE, "repository changed during context capture; retry once edits have settled");
123
+ }
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.4.0 —
23
+ # Pin a tag for reproducibility, e.g. github:lbframe/agent-doc-system#v1.5.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
@@ -0,0 +1,136 @@
1
+ # Task context protocol
2
+
3
+ `prepare`, `expand` and `refresh` accept one JSON request on stdin (or `--request file`) and emit one compact JSON packet plus a newline. They operate on the current working tree and do not require a committed graph. They never execute repository verification commands.
4
+
5
+ ```sh
6
+ printf '%s\n' '{"task":"Change the engine API","paths":["crates/engine"],"intent":"change-contract","budget":{"tokens":4096,"encoding":"o200k_base"}}' | agentdoc prepare
7
+ ```
8
+
9
+ Intents are `implement` (default), `change-contract`, `debug-runtime` and `review`. The first implementation conservatively retains the same obligation closure across intents. The packet contains typed, versioned records, evidence references, selected checks, analysis limitations and stale runtime observation IDs. It does not infer that unsupported or dynamic dependencies are absent.
10
+
11
+ ## Budget and continuation
12
+
13
+ The exact serialized output, including its final newline, metadata and continuation, is counted using pinned `js-tiktoken@1.0.21`. Select `o200k_base` or `cl100k_base` explicitly to match the encoding used by your model; AgentDoc does not guess model tokenization. The supported budget is 256–1,000,000 tokens. A budget too small for the envelope or next indivisible record fails with `AGENTDOC_CONTEXT_BUDGET` and must be increased.
14
+
15
+ `delivery.partial` and `delivery.criticalOmitted` report incomplete delivery. Critical obligations are selected first, but a partial page must never be interpreted as the complete check or constraint list. Follow `continuation` until it is null:
16
+
17
+ ```json
18
+ {"packet":"<64-character packet reference>","offset":8,"tokens":8192}
19
+ ```
20
+
21
+ Pass that object to `agentdoc expand`. Continuations are tied to the original request, snapshot and protocol. A changed graph or runtime freshness state rejects expansion with `AGENTDOC_CONTEXT_STALE`; use `refresh` or `prepare`. Larger budgets allow reading a long constraint document without silently shortening it.
22
+
23
+ ## Session refresh
24
+
25
+ ```json
26
+ {"packet":"<previous packet reference>","acknowledged":["<retained record ID>"]}
27
+ ```
28
+
29
+ Send this to `agentdoc refresh`. Acknowledge only records still present in the agent's active context. Without acknowledgements, all records are delivered again. `reset:true` also requests full delivery. Refresh emits new or changed records, explicit retractions and active critical obligations. Critical records are repeated even if acknowledged. The resulting delta has its own packet reference and continuation.
30
+
31
+ The harness must apply retractions, replace changed versions and manage its active context. Merely appending delta messages does not bound the model's history. Following a lost or compacted session, request a reset. A changed snapshot conservatively invalidates prior execution evidence.
32
+
33
+ Packets are stored locally under `.agentdoc/context/`, excluded from compiler inputs. Treat references as local to a repository. The JavaScript API exposes `forget(root, packet)` to remove a retained packet. At most 64 packets are retained per repository; older continuations may require preparing again. The caller can delete packets sooner; packet files are not published with the repository.
34
+
35
+ ## Checks and evidence
36
+
37
+ A recommended shell command is not an execution result. In this initial protocol, checks expose `execution.status:"requires-context"` and `cwd:null` when the compiler has not established a working directory. Resolve that context from the check's owner and evidence before executing it. Identical command text for different checks remains distinct.
38
+
39
+ Constraint and document records carry their content and a content fingerprint. Other records carry graph provenance identifiers or paths. Facts retain their authority data; contradictory claims are not merged into one assumed truth. Stale observations are marked explicitly and must not be treated as current production evidence.
40
+
41
+ ## Cache and compatibility
42
+
43
+ `cache:false` in a prepare request bypasses persistent compilation reuse. The full compiler remains the correctness reference. Cache reuse is conservative and does not imply extractor-level incremental recompilation. Protocol version `agentdoc.dev/context/v1` is separate from graph version `agentdoc.dev/graph/v1`.
44
+
45
+ Existing `query` and `impact` keep their established freshness policies. See [QUERY.md](QUERY.md) for those commands.
46
+
47
+ ## Importing check execution results from JavaScript
48
+
49
+ The result API accepts reports produced by the harness after execution. It does
50
+ not run the command or independently observe its execution. Each report must name
51
+ a registered check and its exact command, an explicit repository-relative working
52
+ directory, a runner name/version, and a status matching its exit code:
53
+
54
+ ```json
55
+ {
56
+ "snapshot": { "graphHash": "<graphHash from the prepared packet>" },
57
+ "checkId": "<registered check ID>",
58
+ "command": "<exact registered command>",
59
+ "cwd": "service",
60
+ "status": "succeeded",
61
+ "exitCode": 0,
62
+ "runner": { "name": "<harness name>", "version": "<harness version>" }
63
+ }
64
+ ```
65
+
66
+ Use `failed` with a positive integer exit code for a failed execution. A
67
+ recommendation, missing working directory or disagreement between the status and
68
+ exit code is refused. Store the report under `.agentdoc/executions/` so writing
69
+ execution evidence does not itself change the source snapshot. The report's
70
+ `graphHash` must be the snapshot actually used for the run, not a new snapshot
71
+ captured after unrelated edits.
72
+
73
+ ```js
74
+ import fs from 'node:fs';
75
+ import path from 'node:path';
76
+ import { createHash } from 'node:crypto';
77
+ import {
78
+ importCheckResults, validResultsForSnapshot, forgetSession, clearSessions,
79
+ } from 'agent-doc-system/core/session/results.mjs';
80
+
81
+ // root is the repository root; packet is its prepared context packet.
82
+ const reportPath = '.agentdoc/executions/check.json';
83
+ const bytes = fs.readFileSync(path.join(root, reportPath));
84
+ const report = JSON.parse(bytes.toString('utf8'));
85
+ const { checkId, command, cwd, status, exitCode, runner } = report;
86
+ importCheckResults(root, {
87
+ session: 'change-engine',
88
+ snapshot: packet.snapshot,
89
+ results: [{ checkId, command, cwd, status, exitCode, runner, evidence: {
90
+ path: reportPath,
91
+ sha256: createHash('sha256').update(bytes).digest('hex'),
92
+ } }],
93
+ });
94
+
95
+ const results = validResultsForSnapshot(root, 'change-engine', packet.snapshot);
96
+ const successes = results.filter(result => result.status === 'succeeded');
97
+ forgetSession(root, 'change-engine'); // Returns whether a result record existed.
98
+ clearSessions(root); // Removes all retained result sessions; returns their count.
99
+ ```
100
+
101
+ Import verifies the current graph, registered command and hash-pinned JSON report.
102
+ Reads verify them again: a changed graph, changed evidence, unavailable execution
103
+ directory or expired runtime observation invalidates prior results. Both valid
104
+ successes and valid failures are returned; the harness must inspect `status`.
105
+ All import validation errors use `AGENTDOC_SESSION_RESULT`.
106
+
107
+ Session names contain 1–64 ASCII letters, digits, underscores or hyphens and start
108
+ with a letter or digit. Result records live under
109
+ `.agentdoc/sessions/check-results/`, independently of context packets. Cleanup
110
+ removes result records only; it preserves context packets and execution reports.
111
+ Use the context API's `forget(root, packet)` separately when dropping a packet.
112
+
113
+ ## TypeScript symbol candidates from JavaScript
114
+
115
+ The optional symbol experiment indexes only explicitly requested TypeScript
116
+ files. It returns declarations and reference candidates with source line/column
117
+ locations and a SHA-256 content fingerprint:
118
+
119
+ ```js
120
+ import { indexTypeScriptSymbols } from 'agent-doc-system/core/context/symbols.mjs';
121
+
122
+ const candidates = indexTypeScriptSymbols(root, ['src/api.ts', 'src/consumer.ts']);
123
+ for (const file of candidates.files) {
124
+ // file.path, file.sha256, file.declarations, file.references
125
+ }
126
+ ```
127
+
128
+ Supported paths end in `.ts`, `.tsx`, `.mts` or `.cts`; paths escaping the
129
+ repository, including through a symlink, are refused. The experiment masks
130
+ comments and quoted text, but does not resolve lexical scopes, imports,
131
+ overloads, dynamic references, regex literals or template interpolation. Its
132
+ output always declares `coverage.partial:true` and
133
+ `coverage.checkSelection:"unchanged"`. Use candidates to navigate source, never
134
+ to infer that an unlisted dependency is absent or to remove a required check.
135
+ The experiment is a JavaScript API and is not enabled by the default context
136
+ commands.