agents-pod 0.3.1 → 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.
@@ -0,0 +1,157 @@
1
+ """Offline tests for the drain's intake rules (issue #27, ADR 0024).
2
+
3
+ Nothing here touches the network, the environment or a store: whether one
4
+ message becomes an Inbox item is a pure decision, so the tests feed hand-written
5
+ message dicts and assert on the JSONL a pass would print.
6
+
7
+ Run from this directory: python3 -m unittest test_feishu_drain -v
8
+ """
9
+
10
+ import io
11
+ import json
12
+ import unittest
13
+ from contextlib import redirect_stderr
14
+
15
+ import feishu_drain as drain
16
+
17
+ CHAT_ID = "oc_chat"
18
+ CHAT_NAME = "测试群"
19
+ BASE_AT = 1_700_000_000
20
+
21
+
22
+ def text_message(msg_id, text, at=BASE_AT, sender_type="user", mentions=None):
23
+ message = {
24
+ "message_id": msg_id,
25
+ "msg_type": "text",
26
+ "create_time": str(at * 1000),
27
+ "sender": {
28
+ "id": "ou_human",
29
+ "id_type": "open_id",
30
+ "sender_type": sender_type,
31
+ "tenant_key": "tenant",
32
+ },
33
+ "body": {"content": json.dumps({"text": text}, ensure_ascii=False)},
34
+ }
35
+ if mentions is not None:
36
+ message["mentions"] = mentions
37
+ return message
38
+
39
+
40
+ def system_message(msg_id, at=BASE_AT + 1):
41
+ """The measured shape: a template body and an all-empty sender."""
42
+ return {
43
+ "message_id": msg_id,
44
+ "msg_type": "system",
45
+ "create_time": str(at * 1000),
46
+ "sender": {"id": "", "id_type": "", "sender_type": "", "tenant_key": ""},
47
+ "body": {
48
+ "content": json.dumps(
49
+ {
50
+ "template": "{from_user} invited {to_chatters} to the chat.",
51
+ "from_user": ["徐福洋"],
52
+ "to_chatters": ["邹文", "大肥鱼"],
53
+ },
54
+ ensure_ascii=False,
55
+ )
56
+ },
57
+ }
58
+
59
+
60
+ def run_pass(messages, *, cursor=None, require_mention=False, open_id=None, max_items=40):
61
+ """One pass over a message list, as main() would assemble it."""
62
+ state = drain.new_pass(require_mention=require_mention, open_id=open_id)
63
+ kinds = [drain.absorb(state, msg, CHAT_ID, CHAT_NAME) for msg in messages]
64
+ collected = state["collected"]
65
+ collected.sort(key=lambda item: item["at"])
66
+ emitted = collected[:max_items]
67
+ lines = drain.output_lines(
68
+ emitted,
69
+ drain.advance_cursor(cursor, emitted, collected, state["consumed_at"]),
70
+ )
71
+ return kinds, [json.loads(line) for line in lines]
72
+
73
+
74
+ class SystemEventTest(unittest.TestCase):
75
+ def test_system_only_pass_prints_cursor_and_no_item(self):
76
+ kinds, lines = run_pass([system_message("om_sys", at=BASE_AT)])
77
+
78
+ self.assertEqual(kinds, ["system"])
79
+ self.assertEqual(lines, [{"cursor": str(BASE_AT)}])
80
+
81
+ def test_system_then_human_yields_only_the_human_and_cursor_past_both(self):
82
+ kinds, lines = run_pass(
83
+ [
84
+ system_message("om_sys", at=BASE_AT),
85
+ text_message("om_human", "大家好", at=BASE_AT + 5),
86
+ ]
87
+ )
88
+
89
+ self.assertEqual(kinds, ["system", "item"])
90
+ self.assertEqual(len(lines), 2)
91
+ self.assertEqual(lines[0]["id"], "om_human")
92
+ self.assertEqual(lines[0]["body"], "大家好")
93
+ self.assertNotIn("system", lines[0]["body"])
94
+ self.assertEqual(lines[1], {"cursor": str(BASE_AT + 5)})
95
+
96
+ def test_system_event_is_logged_once_on_stderr(self):
97
+ stderr = io.StringIO()
98
+ with redirect_stderr(stderr):
99
+ drain.note_system_event(system_message("om_sys"), CHAT_ID)
100
+
101
+ self.assertEqual(
102
+ stderr.getvalue().strip(),
103
+ "[feishu-drain] skip system event om_sys in %s" % CHAT_ID,
104
+ )
105
+
106
+
107
+ class ExistingFiltersTest(unittest.TestCase):
108
+ def test_require_mention_drops_unmentioned_human(self):
109
+ mentioned = [
110
+ {
111
+ "key": "@bot",
112
+ "id": {"union_id": "on_bot", "user_id": "u_bot", "open_id": "ou_bot"},
113
+ "name": "pod",
114
+ }
115
+ ]
116
+ kinds, lines = run_pass(
117
+ [
118
+ text_message("om_plain", "没人叫我", at=BASE_AT),
119
+ text_message("om_at", "@pod 在吗", at=BASE_AT + 1, mentions=mentioned),
120
+ ],
121
+ require_mention=True,
122
+ open_id="ou_bot",
123
+ )
124
+
125
+ self.assertEqual(kinds, ["skip", "item"])
126
+ self.assertEqual([line["id"] for line in lines[:-1]], ["om_at"])
127
+ self.assertEqual(lines[-1], {"cursor": str(BASE_AT + 1)})
128
+
129
+ def test_app_sender_is_still_dropped(self):
130
+ kinds, lines = run_pass(
131
+ [text_message("om_bot", "机器人发言", at=BASE_AT, sender_type="app")]
132
+ )
133
+
134
+ self.assertEqual(kinds, ["skip"])
135
+ self.assertEqual(lines, [{"cursor": str(BASE_AT)}])
136
+
137
+
138
+ class CursorTest(unittest.TestCase):
139
+ def test_nothing_read_prints_nothing(self):
140
+ _, lines = run_pass([], cursor=BASE_AT)
141
+
142
+ self.assertEqual(lines, [])
143
+
144
+ def test_cursor_does_not_jump_a_queued_item(self):
145
+ messages = [
146
+ text_message("om_1", "一", at=BASE_AT),
147
+ text_message("om_2", "二", at=BASE_AT + 1),
148
+ text_message("om_3", "三", at=BASE_AT + 2),
149
+ ]
150
+ _, lines = run_pass(messages, max_items=2)
151
+
152
+ self.assertEqual([line["id"] for line in lines[:-1]], ["om_1", "om_2"])
153
+ self.assertEqual(lines[-1], {"cursor": str(BASE_AT + 1)})
154
+
155
+
156
+ if __name__ == "__main__":
157
+ unittest.main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-pod",
3
- "version": "0.3.1",
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":