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/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/ADAPTERS.md CHANGED
@@ -107,7 +107,7 @@ cannot name the file a fact came from, you cannot add the fact.
107
107
  | `dockerfile` | `Dockerfile`, `Containerfile` | deployable evidence |
108
108
  | `deploy-config` | `fly.toml`, `Procfile`, … | deployable evidence |
109
109
  | `wrangler` | `wrangler.toml/json` | bindings, crons, service bindings, trigger targets |
110
- | `database` | migrations, ORM config, pool evidence | logical-database ownership, or a candidate |
110
+ | `database` | migrations, ORM config, pool evidence | logical-database ownership, or a candidate; the unit's migration directories |
111
111
  | `object-store` | bucket literals | logical-dataset edges, or an ambiguity |
112
112
  | `cache` | cache client evidence | cache bindings |
113
113
  | `events` | subject literals, declared constants, event-contract files | producers, consumers, reserved subjects |
@@ -312,6 +312,45 @@ the result is `AGENTDOC_BINDING_AMBIGUOUS` and no edge.
312
312
  file pulled in with `-r` / `-c`; a lockfile's resolved versions; a PDM
313
313
  workspace; a workspace manifest deeper than the search bound.
314
314
 
315
+ ### `database`
316
+
317
+ Migration authority and a connection string in one unit resolve to an authored
318
+ logical-database Resource (by name: `<component>-postgres`, `-database`,
319
+ `-db`, or the single Resource whose name starts with the component's).
320
+ Migrations alone give a candidate and `AGENTDOC_RESOURCE_CANDIDATE`, never an
321
+ edge.
322
+
323
+ The adapter also records where the unit's migrations live, as
324
+ `derived.migrationDirs` (`path`, `via`, `provenanceIds`). `impact` classifies a
325
+ change under one of them as a resource change. Two routes, each with its own
326
+ evidence:
327
+
328
+ | `via` | directory | evidence |
329
+ |---|---|---|
330
+ | `directory-name` | a directory named `migrations` or `migration` inside the unit, at most five levels down; Prisma Migrate's `prisma/migrations` is one | the first file in it |
331
+ | `alembic-versions` | `alembic/versions` | the first file in it |
332
+ | `drizzle-config` | Drizzle Kit's `out` in the unit-root `drizzle.config.ts`, `.js` or `.mjs`; `./drizzle` when `out` is absent | the config file |
333
+
334
+ The Drizzle config is code and is never executed. `out` is read only as a
335
+ single string literal (a template literal without `${...}` counts), after
336
+ comments are removed, and resolved against the config's directory. An `out`
337
+ that is an expression, an environment variable, a shorthand property, set more
338
+ than once, or possibly brought in by a spread is not guessed: the adapter
339
+ records nothing and reports `AGENTDOC_MIGRATION_DIR_UNRESOLVED`, a warning that
340
+ can be accepted. An `out` outside the unit is reported the same way. An `out`
341
+ directory that does not exist yet records nothing; the existence probe is an
342
+ input, so generating the first migration invalidates the graph.
343
+
344
+ A recorded directory is migration authority, not ownership: it creates no
345
+ binding. A migration change reaches a database only through the enclosing
346
+ component's existing `usesResource` relations, or through a Resource that
347
+ declares the directory in `spec.migrationPaths` (see `SPEC.md`).
348
+
349
+ **What it cannot see:** a Drizzle config passed with `--config` under another
350
+ name, a config below the unit root, and the migration layouts of Knex, Goose,
351
+ Sequelize or TypeORM outside a `migrations` directory. Declare those with
352
+ `spec.migrationPaths`.
353
+
315
354
  `asyncapi` is a contract **type** (declared on an API descriptor, validated by
316
355
  the contract layer), not an adapter — there is no `asyncapi` entry in
317
356
  `adapters: [...]`.
@@ -373,9 +412,14 @@ places, and those are the honest exceptions worth naming:
373
412
  part of the input hash. A repository that does not enable `python` is not
374
413
  probed, and its graph does not change.
375
414
  - `core/impact.mjs` classifies a changed file by consulting the compiled graph —
376
- its contracts, its provenance paths, its extractor identities — rather than a
377
- hand-written list of technology filenames. A list would silently under-report
378
- for every repository that is not Node-and-Go.
415
+ its contracts, its provenance paths, its extractor identities, its declared
416
+ and detected migration directories — rather than a hand-written list of
417
+ technology filenames. A list would silently under-report for every
418
+ repository that is not Node-and-Go. One name rule remains as a fallback: a
419
+ path under a `migrations/`, `migration/` or `alembic/versions/` segment is a
420
+ resource change even where the graph records no migration directory (the
421
+ `database` adapter is off, or the directory is deeper than its walk), so
422
+ those repositories keep the answer they had.
379
423
  - `core/discover.mjs` excludes `agentdoc`, `docs`, `scripts`, `node_modules`, and
380
424
  `.github` by default. `discovery.notUnitRoots` replaces that list (`[]`
381
425
  disables it). These roots and their descendants cannot be inferred from
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.3.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
@@ -93,6 +93,8 @@ YAML or schema error names its file and line and needs no table.
93
93
  | `AGENTDOC_COMPILER_MISMATCH` | the compiler was upgraded | `compile` and commit |
94
94
  | `AGENTDOC_OVERRIDE_STALE` | a reviewed override's cited evidence changed | review the interpretation, then `pin-evidence` and update the digest |
95
95
  | `AGENTDOC_VERIFICATION_UNTIERED` | a check task has no recognisable tier | name the task, or extend the adapter |
96
+ | `AGENTDOC_MIGRATION_PATH` | a Resource's `spec.migrationPaths` is a glob, is on a non-database Resource, or is shared by two Resources | declare the directory itself, once, on the logical database it migrates |
97
+ | `AGENTDOC_MIGRATION_DIR_UNRESOLVED` | a Drizzle config's `out` is not a single string literal inside the unit | declare the directory with `spec.migrationPaths`, or accept the warning |
96
98
 
97
99
  ## Ordering inside `check`
98
100
 
@@ -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.