agents-pod 0.3.2 → 0.3.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-pod",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Supervision for long-running coding agents: detached pods, heartbeat liveness, cron-ticked reconcile, and a Pi extension for spawning and handoff",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,303 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { adaptersFile, storeRoot } from "./paths.ts";
6
+
7
+ /**
8
+ * Adapter copies are the operator's files, outside the repository (ADRs 0023,
9
+ * 0024): core ships an example, the operator copies it beside their
10
+ * credentials. The gap this module closes is *provenance*: `--check` proves an
11
+ * adapter answers, never that it is current, so a copy can sit a release behind
12
+ * and every `doctor` line still reads ✓ (issue #26).
13
+ *
14
+ * Nothing here writes into a copy unless a human ran `adapter sync`: a package
15
+ * update must not silently change a command that holds live credentials.
16
+ */
17
+ export interface AdapterFile {
18
+ name: string;
19
+ sha256: string;
20
+ }
21
+
22
+ /** One registered copy: where it lives, and the package state it came from. */
23
+ export interface AdapterRecord {
24
+ name: string;
25
+ dir: string;
26
+ packageVersion: string;
27
+ files: AdapterFile[];
28
+ }
29
+
30
+ /**
31
+ * Files a copy owns but the package must never overwrite: credentials and the
32
+ * caches a platform writes for itself. Enforced even though `examples/` ships
33
+ * none of them — a future example that did would otherwise leak a credential
34
+ * over an operator's real one.
35
+ */
36
+ const RUNTIME_ONLY = new Set([".env", ".token.json", ".bot.json", "state.json"]);
37
+
38
+ /** `same` needs no action; `update` is a pristine copy one version behind. */
39
+ export type AdapterFileState = "same" | "update" | "missing-in-copy" | "local-edit";
40
+
41
+ export interface AdapterFilePlan {
42
+ name: string;
43
+ state: AdapterFileState;
44
+ }
45
+
46
+ export interface AdapterSyncPlan {
47
+ name: string;
48
+ dir: string;
49
+ packageDir: string;
50
+ packageVersion: string;
51
+ files: AdapterFilePlan[];
52
+ /** Package entries that are not files an operator copy can take. */
53
+ skipped: string[];
54
+ }
55
+
56
+ /** A fact for `doctor`; the caller decides how to print it (ADR 0027). */
57
+ export interface AdapterFact {
58
+ mark: "ok" | "bad" | "note";
59
+ text: string;
60
+ next?: string;
61
+ }
62
+
63
+ /**
64
+ * The installed package root, derived from this module's own location: `src/`
65
+ * and `examples/` are siblings there, which is what makes the copy source the
66
+ * *installed* package rather than whatever working tree is nearby.
67
+ */
68
+ export function packageRoot(): string {
69
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
70
+ }
71
+
72
+ export async function readPackageVersion(root = packageRoot()): Promise<string> {
73
+ try {
74
+ const parsed = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
75
+ version?: string;
76
+ };
77
+ return parsed.version ?? "unknown";
78
+ } catch {
79
+ return "unknown";
80
+ }
81
+ }
82
+
83
+ export function adapterPackageDir(name: string, root = packageRoot()): string {
84
+ return join(root, "examples", name);
85
+ }
86
+
87
+ export async function readAdapters(): Promise<AdapterRecord[]> {
88
+ try {
89
+ const parsed: unknown = JSON.parse(await readFile(adaptersFile(), "utf8"));
90
+ return Array.isArray(parsed) ? (parsed as AdapterRecord[]) : [];
91
+ } catch (error) {
92
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ /** Atomic like every other store write: a crash never leaves half a manifest. */
98
+ export async function writeAdapters(records: AdapterRecord[]): Promise<void> {
99
+ const file = adaptersFile();
100
+ await mkdir(dirname(file), { recursive: true });
101
+ const tmp = `${file}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}.tmp`;
102
+ await writeFile(tmp, `${JSON.stringify(records, null, 2)}\n`);
103
+ await rename(tmp, file);
104
+ }
105
+
106
+ export function sha256(content: Buffer | string): string {
107
+ return createHash("sha256").update(content).digest("hex");
108
+ }
109
+
110
+ async function fileSha(path: string): Promise<string | undefined> {
111
+ try {
112
+ return sha256(await readFile(path));
113
+ } catch (error) {
114
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * The files a copy takes: regular files at the top of the package's example
121
+ * directory. `__pycache__` and a `.pyc` would be the package's build residue,
122
+ * not the adapter; runtime caches are excluded by name in `RUNTIME_ONLY`.
123
+ */
124
+ async function packageFiles(packageDir: string): Promise<{ files: string[]; skipped: string[] }> {
125
+ const entries = await readdir(packageDir, { withFileTypes: true });
126
+ const files: string[] = [];
127
+ const skipped: string[] = [];
128
+ for (const entry of entries) {
129
+ if (RUNTIME_ONLY.has(entry.name) || entry.name === "__pycache__" || entry.name.endsWith(".pyc")) {
130
+ skipped.push(entry.name);
131
+ continue;
132
+ }
133
+ if (!entry.isFile()) {
134
+ skipped.push(entry.name);
135
+ continue;
136
+ }
137
+ files.push(entry.name);
138
+ }
139
+ files.sort();
140
+ return { files, skipped };
141
+ }
142
+
143
+ /**
144
+ * What a sync would do, without doing it. A file whose copy matches the
145
+ * manifest but not the package is one version behind (`update`); a file that
146
+ * matches neither is the operator's own edit (`local-edit`) and is reported,
147
+ * never overwritten without `--force`.
148
+ */
149
+ export async function planAdapterSync(
150
+ name: string,
151
+ options: { root?: string; dest?: string } = {},
152
+ ): Promise<AdapterSyncPlan> {
153
+ const root = options.root ?? packageRoot();
154
+ const packageDir = adapterPackageDir(name, root);
155
+ const info = await stat(packageDir).catch(() => undefined);
156
+ if (info === undefined || !info.isDirectory()) {
157
+ throw new Error(`the package ships no adapter named ${name} (${packageDir})`);
158
+ }
159
+ // The store root, not $HOME: a test store and a secondary install must not
160
+ // sync into the machine-wide copy (ADR 0004).
161
+ const dir = resolve(options.dest ?? join(storeRoot(), "adapters"));
162
+ const packageVersion = await readPackageVersion(root);
163
+ const { files, skipped } = await packageFiles(packageDir);
164
+ const recorded = (await readAdapters()).find((record) => record.name === name);
165
+
166
+ const plan: AdapterFilePlan[] = [];
167
+ for (const file of files) {
168
+ const wanted = await fileSha(join(packageDir, file));
169
+ const present = await fileSha(join(dir, file));
170
+ if (present === undefined) {
171
+ plan.push({ name: file, state: "missing-in-copy" });
172
+ continue;
173
+ }
174
+ if (present === wanted) {
175
+ plan.push({ name: file, state: "same" });
176
+ continue;
177
+ }
178
+ const known = recorded?.files.find((entry) => entry.name === file)?.sha256;
179
+ plan.push({ name: file, state: known !== undefined && known === present ? "update" : "local-edit" });
180
+ }
181
+ return { name, dir, packageDir, packageVersion, files: plan, skipped };
182
+ }
183
+
184
+ /** Writes the copy and records its provenance; returns what it changed. */
185
+ export async function applyAdapterSync(
186
+ plan: AdapterSyncPlan,
187
+ options: { force?: boolean } = {},
188
+ ): Promise<{ copied: string[]; kept: string[]; manifest: string }> {
189
+ await mkdir(plan.dir, { recursive: true });
190
+ const copied: string[] = [];
191
+ const kept: string[] = [];
192
+ const files: AdapterFile[] = [];
193
+
194
+ for (const file of plan.files) {
195
+ const from = join(plan.packageDir, file.name);
196
+ const to = join(plan.dir, file.name);
197
+ if (file.state === "local-edit" && options.force !== true) {
198
+ kept.push(file.name);
199
+ } else if (file.state !== "same") {
200
+ await copyFile(from, to);
201
+ copied.push(file.name);
202
+ }
203
+ // The manifest records the *package* hash, never what a kept local edit
204
+ // happens to be: otherwise a kept file would later read as "stale" instead
205
+ // of "the operator edited this", which is the fact worth reporting.
206
+ files.push({ name: file.name, sha256: sha256(await readFile(from)) });
207
+ }
208
+
209
+ const records = await readAdapters();
210
+ const record: AdapterRecord = {
211
+ name: plan.name,
212
+ dir: plan.dir,
213
+ packageVersion: plan.packageVersion,
214
+ files: files.sort((left, right) => left.name.localeCompare(right.name)),
215
+ };
216
+ const remaining = records.filter((entry) => entry.name !== plan.name);
217
+ remaining.push(record);
218
+ await writeAdapters(remaining);
219
+ return { copied, kept, manifest: adaptersFile() };
220
+ }
221
+
222
+ /**
223
+ * Provenance facts for `doctor` (ADR 0027). Only registered copies are
224
+ * reported: an unregistered directory is not something core was asked to
225
+ * watch, and guessing would put a platform name into core.
226
+ */
227
+ export async function adapterFacts(
228
+ records: AdapterRecord[],
229
+ root = packageRoot(),
230
+ ): Promise<AdapterFact[]> {
231
+ const facts: AdapterFact[] = [];
232
+ const version = await readPackageVersion(root);
233
+
234
+ for (const record of records) {
235
+ const dirInfo = await stat(record.dir).catch(() => undefined);
236
+ if (dirInfo === undefined || !dirInfo.isDirectory()) {
237
+ facts.push({
238
+ mark: "bad",
239
+ text: `adapters ${record.name}: ${record.dir} is gone (a registered copy)`,
240
+ next: `next: agents-pod adapter sync ${record.name}`,
241
+ });
242
+ continue;
243
+ }
244
+
245
+ if (record.files.length === 0) {
246
+ // A record without hashes cannot be compared, so it must not read as ✓:
247
+ // an empty manifest is the shape of "registered before hashes were kept".
248
+ facts.push({
249
+ mark: "note",
250
+ text: `adapters ${record.name}: no file hashes recorded, so nothing can be compared`,
251
+ next: `next: agents-pod adapter sync ${record.name} --apply`,
252
+ });
253
+ continue;
254
+ }
255
+
256
+ const stale: string[] = [];
257
+ const edited: string[] = [];
258
+ const absent: string[] = [];
259
+ for (const file of record.files) {
260
+ const wanted = await fileSha(join(adapterPackageDir(record.name, root), file.name));
261
+ const present = await fileSha(join(record.dir, file.name));
262
+ if (present === undefined) {
263
+ absent.push(file.name);
264
+ continue;
265
+ }
266
+ if (wanted === undefined) {
267
+ // The package stopped shipping it: the copy's file is now the only one.
268
+ continue;
269
+ }
270
+ if (present === wanted) continue;
271
+ if (present === file.sha256) stale.push(file.name);
272
+ else edited.push(file.name);
273
+ }
274
+
275
+ if (absent.length > 0) {
276
+ facts.push({
277
+ mark: "bad",
278
+ text: `adapters ${record.name}: ${absent.join(", ")} missing from ${record.dir}`,
279
+ next: `next: agents-pod adapter sync ${record.name}`,
280
+ });
281
+ }
282
+ if (stale.length > 0) {
283
+ facts.push({
284
+ mark: "bad",
285
+ text:
286
+ `adapters ${record.name}: ${stale.join(", ")} is from ${record.packageVersion}, ` +
287
+ `the package ships ${version}`,
288
+ next: `next: agents-pod adapter sync ${record.name}`,
289
+ });
290
+ }
291
+ if (edited.length > 0) {
292
+ facts.push({
293
+ mark: "note",
294
+ text: `adapters ${record.name}: ${edited.join(", ")} differs from the package (kept: a local edit)`,
295
+ next: ` agents-pod adapter sync ${record.name} --dry-run names it; --force overwrites it`,
296
+ });
297
+ }
298
+ if (absent.length === 0 && stale.length === 0 && edited.length === 0) {
299
+ facts.push({ mark: "ok", text: `adapters ${record.name}: matches package ${version}` });
300
+ }
301
+ }
302
+ return facts;
303
+ }
package/src/cli.ts CHANGED
@@ -46,6 +46,17 @@ import {
46
46
  readProjects,
47
47
  writeProjects,
48
48
  } from "./projects.ts";
49
+ import {
50
+ applyProjectMove,
51
+ planProjectMove,
52
+ type ProjectMovePlan,
53
+ } from "./relocate.ts";
54
+ import {
55
+ applyAdapterSync,
56
+ planAdapterSync,
57
+ type AdapterFileState,
58
+ type AdapterSyncPlan,
59
+ } from "./adapters.ts";
49
60
  import type { PodRecord } from "./registry.ts";
50
61
  import { readPods, withRegistryLock, writePods } from "./registry.ts";
51
62
  import {
@@ -1285,7 +1296,169 @@ async function spawnCmd(
1285
1296
  return 0;
1286
1297
  }
1287
1298
 
1288
- /** Per-project wake-up capacity: a small persistent guardrail, not a scheduler. */
1299
+ const PROJECT_USAGE =
1300
+ "Usage: agents-pod project cap [n|unlimited] [--project <dir>]\n" +
1301
+ " agents-pod project move <old> <new> [--apply] [--move-artifacts]";
1302
+
1303
+ function printProjectMove(
1304
+ plan: ProjectMovePlan,
1305
+ out: (line: string) => void,
1306
+ err: (line: string) => void,
1307
+ ): void {
1308
+ for (const pod of plan.pods) {
1309
+ if (pod.artifact === "missing") continue; // its refusal below is the pod's line
1310
+ const artifact =
1311
+ pod.artifact === "present"
1312
+ ? `artifacts at ${pod.to}`
1313
+ : `move artifacts from ${pod.from}`;
1314
+ out(`✓ relocate ${pod.id} ${plan.oldPath} → ${plan.newPath} (${artifact})`);
1315
+ }
1316
+ for (const listener of plan.listeners) {
1317
+ out(`✓ relocate ${listener.id} (template) ${plan.oldPath} → ${plan.newPath}`);
1318
+ }
1319
+ for (const project of plan.projects) {
1320
+ out(
1321
+ `✓ relocate capacity record ${plan.oldPath} → ${plan.newPath} (maxActive ${project.maxActive})`,
1322
+ );
1323
+ }
1324
+ for (const refusal of plan.refusals) err(`✗ ${refusal.reason}`);
1325
+ }
1326
+
1327
+ /** Relocate records after the operator moved the tree; identity stays put (ADR 0028). */
1328
+ async function projectMoveCmd(
1329
+ words: string[],
1330
+ deps: CliDeps,
1331
+ out: (line: string) => void,
1332
+ err: (line: string) => void,
1333
+ ): Promise<number> {
1334
+ const apply = words.includes("--apply");
1335
+ const moveArtifacts = words.includes("--move-artifacts");
1336
+ const positional = words.filter((word) => word !== "--apply" && word !== "--move-artifacts");
1337
+ if (
1338
+ positional.length !== 2 ||
1339
+ positional.some((word) => word.startsWith("--")) ||
1340
+ positional[0] === undefined ||
1341
+ positional[1] === undefined
1342
+ ) {
1343
+ err(PROJECT_USAGE);
1344
+ return 2;
1345
+ }
1346
+
1347
+ const cwd = deps.cwd ?? process.cwd();
1348
+ const oldPath = resolve(cwd, positional[0]);
1349
+ const newPath = resolve(cwd, positional[1]);
1350
+ if (oldPath === newPath) {
1351
+ err("old and new paths are the same; nothing to move");
1352
+ err(PROJECT_USAGE);
1353
+ return 2;
1354
+ }
1355
+
1356
+ const plan = await planProjectMove(oldPath, newPath, { moveArtifacts });
1357
+ if (plan.matched === 0) {
1358
+ out(`no record is bound to ${oldPath}`);
1359
+ if (apply) out("nothing to do");
1360
+ return 0;
1361
+ }
1362
+ printProjectMove(plan, out, err);
1363
+ if (plan.refusals.length > 0) return 1;
1364
+ if (!apply) {
1365
+ out("run again with --apply to write");
1366
+ return 0;
1367
+ }
1368
+
1369
+ try {
1370
+ const result = await applyProjectMove(plan);
1371
+ if (result.files.length === 0) {
1372
+ out("nothing to do");
1373
+ return 0;
1374
+ }
1375
+ for (const file of result.files) {
1376
+ out(`wrote ${file.name}: ${file.changed} record${file.changed === 1 ? "" : "s"} changed`);
1377
+ }
1378
+ return 0;
1379
+ } catch (error) {
1380
+ err(`project move failed: ${(error as Error).message}`);
1381
+ return 1;
1382
+ }
1383
+ }
1384
+
1385
+ const ADAPTER_USAGE =
1386
+ "Usage: agents-pod adapter sync <name> [--dest <dir>] [--apply] [--force]";
1387
+
1388
+ /** One mark per file, so a dry run reads like a diff (ADR 0029). */
1389
+ const ADAPTER_MARKS: Record<AdapterFileState, string> = {
1390
+ same: "=",
1391
+ update: "↑",
1392
+ "missing-in-copy": "+",
1393
+ "local-edit": "!",
1394
+ };
1395
+
1396
+ function printAdapterPlan(plan: AdapterSyncPlan, out: (line: string) => void): void {
1397
+ out(`adapter ${plan.name} ${plan.packageDir} → ${plan.dir} (package ${plan.packageVersion})`);
1398
+ for (const file of plan.files) {
1399
+ const kept = file.state === "local-edit" ? " (kept: a local edit; --force overwrites)" : "";
1400
+ out(` ${ADAPTER_MARKS[file.state]} ${file.name} ${file.state}${kept}`);
1401
+ }
1402
+ }
1403
+
1404
+ /**
1405
+ * The package ships, the operator copies, `doctor` reports drift (ADR 0029).
1406
+ * Dry run by default: nothing writes into a directory that holds a live
1407
+ * credential unless a human asked for it in this invocation.
1408
+ */
1409
+ async function adapterCmd(
1410
+ words: string[],
1411
+ deps: CliDeps,
1412
+ out: (line: string) => void,
1413
+ err: (line: string) => void,
1414
+ ): Promise<number> {
1415
+ const [sub, ...rest] = words;
1416
+ if (sub !== "sync") {
1417
+ err(`Unknown adapter command: ${sub ?? "(none)"}. Try: sync`);
1418
+ err(ADAPTER_USAGE);
1419
+ return 2;
1420
+ }
1421
+ const apply = rest.includes("--apply");
1422
+ const force = rest.includes("--force");
1423
+ const { flags, rest: positional } = parseFlags(
1424
+ rest.filter((word) => word !== "--apply" && word !== "--force"),
1425
+ ["dest"],
1426
+ );
1427
+ const name = positional[0];
1428
+ if (positional.length !== 1 || name === undefined) {
1429
+ err(ADAPTER_USAGE);
1430
+ return 2;
1431
+ }
1432
+
1433
+ let plan: AdapterSyncPlan;
1434
+ try {
1435
+ plan = await planAdapterSync(name, {
1436
+ dest: flags.dest === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), flags.dest),
1437
+ });
1438
+ } catch (error) {
1439
+ err((error as Error).message);
1440
+ return 2;
1441
+ }
1442
+
1443
+ printAdapterPlan(plan, out);
1444
+ const pending = plan.files.filter((file) => file.state !== "same").length;
1445
+ if (pending === 0) {
1446
+ out(`already matches package ${plan.packageVersion}`);
1447
+ return 0;
1448
+ }
1449
+ if (!apply) {
1450
+ out("run again with --apply to write");
1451
+ return 0;
1452
+ }
1453
+
1454
+ const result = await applyAdapterSync(plan, { force });
1455
+ if (result.copied.length > 0) out(`copied ${result.copied.join(", ")}`);
1456
+ if (result.kept.length > 0) out(`kept ${result.kept.join(", ")} (local edits; --force overwrites)`);
1457
+ out(`recorded package ${plan.packageVersion} in ${result.manifest}`);
1458
+ return 0;
1459
+ }
1460
+
1461
+ /** Per-project capacity and project-location record maintenance (ADR 0022, 0028). */
1289
1462
  async function projectCmd(
1290
1463
  words: string[],
1291
1464
  deps: CliDeps,
@@ -1293,10 +1466,17 @@ async function projectCmd(
1293
1466
  err: (line: string) => void,
1294
1467
  ): Promise<number> {
1295
1468
  const [sub, ...rest] = words;
1469
+ if (sub === "move") return projectMoveCmd(rest, deps, out, err);
1470
+ if (sub !== "cap") {
1471
+ err(`Unknown project command: ${sub ?? "(none)"}. Try: cap | move`);
1472
+ err(PROJECT_USAGE);
1473
+ return 2;
1474
+ }
1475
+
1296
1476
  const { flags, rest: positional } = parseFlags(rest, ["project"]);
1297
1477
  const project = resolve(flags.project ?? deps.cwd ?? process.cwd());
1298
- if (sub !== "cap" || positional.length > 1) {
1299
- err("Usage: agents-pod project cap [n|unlimited] [--project <dir>]");
1478
+ if (positional.length > 1) {
1479
+ err(PROJECT_USAGE);
1300
1480
  return 2;
1301
1481
  }
1302
1482
 
@@ -1928,7 +2108,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
1928
2108
  out("");
1929
2109
  out("start here agents-pod doctor what is missing on this machine, and the command that fixes it");
1930
2110
  out("pods spawn | list | status <id> | logs <id> | wake <id> | cancel <id>");
1931
- out("records agent | listener | source | deliver | project | reconcile");
2111
+ out("records agent | listener | source | deliver | adapter | project | reconcile");
1932
2112
  return 0;
1933
2113
  }
1934
2114
 
@@ -1955,6 +2135,8 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
1955
2135
  return sourceCmd(rest, deps, out, err);
1956
2136
  case "deliver":
1957
2137
  return deliverCmd(rest, deps, out, err);
2138
+ case "adapter":
2139
+ return adapterCmd(rest, deps, out, err);
1958
2140
  case "project":
1959
2141
  return projectCmd(rest, deps, out, err);
1960
2142
  case "doctor":
package/src/doctor.ts CHANGED
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
3
  import { access, readdir, readFile, stat } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { adapterFacts, readAdapters, type AdapterRecord } from "./adapters.ts";
5
6
  import { readListeners, type ListenerRecord } from "./listeners.ts";
6
7
  import { readLock } from "./locks.ts";
7
8
  import { loadNotifyConfig } from "./notify/config.ts";
@@ -89,6 +90,20 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
89
90
  } else {
90
91
  const active = registry.pods.filter((pod) => pod.state === "active").length;
91
92
  storeLines.push({ mark: "ok", text: `${registry.pods.length} pods, ${active} active` });
93
+ // A Pod Record bound to no Listener has no other line to carry its path
94
+ // (ADR 0028): terminal Pods are exempt, they block no conversation.
95
+ const bound = new Set(
96
+ listeners.filter((listener) => listener.mode === "pod").map((listener) => listener.podId),
97
+ );
98
+ for (const pod of registry.pods) {
99
+ if (pod.state === "done" || pod.state === "cancelled" || bound.has(pod.id)) continue;
100
+ if (await exists(pod.project)) continue;
101
+ storeLines.push({
102
+ mark: "bad",
103
+ text: `pod ${pod.id}: its project ${pod.project} does not exist`,
104
+ detail: [`next: agents-pod project move ${pod.project} <new>`],
105
+ });
106
+ }
92
107
  }
93
108
  const lock = await readLock(reconcileLockPath());
94
109
  if (lock !== undefined) {
@@ -102,6 +117,29 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
102
117
 
103
118
  sections.push({ title: "pass", lines: await passLines(lastPass, now, sources, listeners, registry.pods) });
104
119
  sections.push({ title: "intake", lines: await intakeLines(sources, listeners, now) });
120
+ // A corrupt provenance file is a fact, not silence: swallowing it would
121
+ // restore exactly the blindness adapters.json exists to end (ADR 0029).
122
+ let adapters: AdapterRecord[] = [];
123
+ let adaptersError: string | undefined;
124
+ try {
125
+ adapters = await readAdapters();
126
+ } catch (error) {
127
+ adaptersError = (error as Error).message;
128
+ }
129
+ if (adaptersError !== undefined) {
130
+ sections.push({
131
+ title: "adapters",
132
+ lines: [
133
+ {
134
+ mark: "note",
135
+ text: `adapters.json is unreadable: ${adaptersError}`,
136
+ detail: ["next: agents-pod adapter sync <name> --apply rewrites it"],
137
+ },
138
+ ],
139
+ });
140
+ } else if (adapters.length > 0) {
141
+ sections.push({ title: "adapters", lines: await adapterLines(adapters) });
142
+ }
105
143
  sections.push({ title: "listeners", lines: await listenerLines(listeners, sources, registry.pods) });
106
144
  sections.push({ title: "outbound", lines: await outboundLines(deliverer, registry.pods) });
107
145
  sections.push({ title: "notify", lines: notifyLines(notify, warnings, env) });
@@ -249,6 +287,8 @@ async function listenerLines(
249
287
  const lines: DoctorLine[] = [];
250
288
  for (const listener of listeners) {
251
289
  const problems: string[] = [];
290
+ /** The path a `project move` would fix; `undefined` means "not a path problem". */
291
+ let deadPath: string | undefined;
252
292
  const origin =
253
293
  listener.source !== undefined
254
294
  ? `source ${listener.source}`
@@ -265,15 +305,32 @@ async function listenerLines(
265
305
  }
266
306
  if (listener.project !== undefined && !(await exists(listener.project))) {
267
307
  problems.push(`its project ${listener.project} does not exist`);
308
+ deadPath = listener.project;
268
309
  }
269
310
  } else if (!pods.some((pod) => pod.id === listener.podId)) {
270
311
  problems.push(`its Pod ${listener.podId ?? "(none)"} is not in the registry`);
312
+ } else {
313
+ // #24: a bound Pod's project is what a wake-up starts in, so a renamed
314
+ // directory makes the whole channel inert while this line used to read ✓.
315
+ const pod = pods.find((candidate) => candidate.id === listener.podId)!;
316
+ if (!(await exists(pod.project))) {
317
+ problems.push(`its Pod ${pod.id} project ${pod.project} does not exist`);
318
+ deadPath = pod.project;
319
+ } else if (!(await exists(join(pod.project, ".agents", "pods", pod.id)))) {
320
+ problems.push(`its Pod ${pod.id} workspace is missing under ${pod.project}`);
321
+ }
271
322
  }
272
323
  if (problems.length > 0) {
273
324
  lines.push({
274
325
  mark: "bad",
275
326
  text: `${listener.id}: ${problems.join("; ")}`,
276
- detail: [`next: agents-pod listener rm ${listener.id}`, " then add it again with a path that exists"],
327
+ detail:
328
+ deadPath === undefined
329
+ ? ["next: agents-pod listener rm " + listener.id, " then add it again with a path that exists"]
330
+ : [
331
+ `next: agents-pod project move ${deadPath} <new>`,
332
+ " a renamed repository carries <project>/.agents/pods/<id> with it (ADR 0028)",
333
+ ],
277
334
  });
278
335
  continue;
279
336
  }
@@ -292,6 +349,23 @@ async function listenerLines(
292
349
  return lines;
293
350
  }
294
351
 
352
+ /**
353
+ * Provenance, not behaviour (issue #26): an adapter's `--check` proves it
354
+ * answers, never that the package's fix reached the copy. Only registered
355
+ * copies appear — an unregistered directory is not something core watches.
356
+ */
357
+ async function adapterLines(adapters: AdapterRecord[]): Promise<DoctorLine[]> {
358
+ const lines: DoctorLine[] = [];
359
+ for (const fact of await adapterFacts(adapters)) {
360
+ lines.push({
361
+ mark: fact.mark,
362
+ text: fact.text,
363
+ ...(fact.next === undefined ? {} : { detail: [fact.next] }),
364
+ });
365
+ }
366
+ return lines;
367
+ }
368
+
295
369
  async function outboundLines(deliverer: DelivererRecord | undefined, pods: PodRecord[]): Promise<DoctorLine[]> {
296
370
  const lines: DoctorLine[] = [];
297
371
  const { pending, dead } = await outboxFacts(pods);
package/src/paths.ts CHANGED
@@ -36,6 +36,11 @@ export function sourcesFile(): string {
36
36
  return join(storeRoot(), "sources.json");
37
37
  }
38
38
 
39
+ /** Registered adapter copies and their provenance (issue #26): beside, not inside. */
40
+ export function adaptersFile(): string {
41
+ return join(storeRoot(), "adapters.json");
42
+ }
43
+
39
44
  /** The single outbound delivery command, beside the Source records (ADR 0025). */
40
45
  export function delivererFile(): string {
41
46
  return join(storeRoot(), "deliverer.json");