@staix/agent-hub 0.12.36 → 0.12.38

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +18 -11
  3. package/docs/agent-notes/adapters.md +19 -6
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/bus.md +3 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/tasks.md +8 -4
  8. package/docs/agent-notes/tests.md +2 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +57 -10
  11. package/docs/quickstart.md +15 -7
  12. package/docs/sdlc.md +29 -3
  13. package/docs/security.md +10 -2
  14. package/docs/smoke.md +38 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
  16. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  17. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  18. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  19. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  20. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  21. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  22. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  23. package/docs/verified.json +27 -15
  24. package/package.json +2 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +5627 -3670
  27. package/src/adapters/acp.ts +3 -2
  28. package/src/adapters/agy/definition.ts +61 -0
  29. package/src/adapters/agy/entry.ts +76 -0
  30. package/src/adapters/agy/hooks.ts +81 -0
  31. package/src/adapters/claude-channel.ts +1 -0
  32. package/src/adapters/claude-print.ts +18 -156
  33. package/src/adapters/codex-review-isolation.ts +290 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +9 -6
  35. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  36. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
  38. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  39. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  40. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  41. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  42. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  43. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  44. package/src/adapters/definitions/native-factories.ts +17 -0
  45. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  46. package/src/adapters/definitions/qwen.ts +1 -1
  47. package/src/adapters/definitions/reviewer-variants.ts +33 -0
  48. package/src/adapters/jcode.ts +20 -5
  49. package/src/adapters/kimi-review.ts +161 -0
  50. package/src/adapters/registry-types.ts +36 -4
  51. package/src/adapters/registry.ts +28 -7
  52. package/src/adapters/reviewer-mcp.ts +85 -0
  53. package/src/adapters/reviewer-print.ts +382 -0
  54. package/src/cli/agy-mcp.ts +370 -0
  55. package/src/cli/console-state.ts +6 -0
  56. package/src/cli/console.ts +2 -0
  57. package/src/cli/facts-hook.ts +15 -2
  58. package/src/cli/help.ts +2 -0
  59. package/src/cli/identity.ts +1 -1
  60. package/src/cli/init.ts +7 -0
  61. package/src/cli/launch.ts +3 -1
  62. package/src/cli/main.ts +20 -0
  63. package/src/cli/memory.ts +13 -12
  64. package/src/cli/output.ts +5 -0
  65. package/src/hub/board.ts +34 -6
  66. package/src/hub/bus.ts +17 -6
  67. package/src/hub/config-apply.ts +3 -2
  68. package/src/hub/control-client.ts +7 -1
  69. package/src/hub/daemon.ts +266 -46
  70. package/src/hub/envelope.ts +1 -1
  71. package/src/hub/host-executable.ts +1 -1
  72. package/src/hub/hub-tools.ts +5 -0
  73. package/src/hub/instances.ts +13 -3
  74. package/src/hub/memory-profile.ts +67 -11
  75. package/src/hub/native-hook-launch.ts +37 -0
  76. package/src/hub/peer-catalog.ts +2 -3
  77. package/src/hub/planning.ts +2 -0
  78. package/src/hub/release-admission.ts +64 -0
  79. package/src/hub/reviewer-pool-config.ts +11 -0
  80. package/src/hub/reviewer-pool.ts +76 -0
  81. package/src/hub/routing.ts +45 -3
  82. package/src/hub/sdlc-release.ts +113 -0
  83. package/src/hub/tasks.ts +261 -36
  84. package/src/ui/index.html +6 -1
  85. package/templates/AGENTS.block.md +1 -1
@@ -0,0 +1,370 @@
1
+ import type { Stats } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { hubGitSync } from "../hub/git.ts";
7
+ import { realPath } from "../hub/project.ts";
8
+
9
+ export interface AgyMcpOptions {
10
+ projectRoot: string;
11
+ stateDir: string;
12
+ serverJs: string;
13
+ hookScript: string;
14
+ instanceId: string;
15
+ launchId: string;
16
+ peer?: string;
17
+ }
18
+
19
+ const PLUGIN_DIR = ".agents/plugins/agent-hub";
20
+ const PLUGIN_KEY = "agent-hub";
21
+ const MANIFEST_FILE = `${PLUGIN_DIR}/plugin.json`;
22
+ const MCP_FILE = `${PLUGIN_DIR}/mcp_config.json`;
23
+ const HOOKS_FILE = `${PLUGIN_DIR}/hooks.json`;
24
+ const FILES = [MANIFEST_FILE, MCP_FILE, HOOKS_FILE] as const;
25
+ export const AGY_PLUGIN_FILES = FILES;
26
+ const EVENTS = ["SessionStart", "PreInvocation", "PostInvocation", "PreToolUse", "PostToolUse", "Stop"] as const;
27
+ const MANAGED_DESCRIPTION = "Managed by ahub agy launcher.";
28
+ const MAX_CONFIG_BYTES = 256 * 1024;
29
+ type JsonObject = Record<string, any>;
30
+ type Target = { path: string; relative: typeof FILES[number]; current?: Buffer; stat?: Stats; next: Buffer; changed: boolean };
31
+ type DirectoryIdentity = { path: string; stat: Stats };
32
+ type Plan = { file: string; tracked: boolean; changed: boolean; action: string; root: string; pluginDir: string; canonicalRoot: string; canonicalPluginDir?: string; directories?: DirectoryIdentity[]; targets: Target[] };
33
+
34
+ function object(value: unknown): value is JsonObject { return !!value && typeof value === "object" && !Array.isArray(value); }
35
+ function jsonFile(value: unknown, name: string): JsonObject {
36
+ if (!object(value)) throw new Error(`${name} must be a JSON object`);
37
+ return value;
38
+ }
39
+ function parseJson(bytes: Buffer, name: string): JsonObject {
40
+ try { return jsonFile(JSON.parse(bytes.toString("utf8")), name); }
41
+ catch (error) { if (error instanceof SyntaxError) throw new Error(`${name} is invalid JSON`); throw error; }
42
+ }
43
+ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }
44
+ function shellUnquote(value: string): string | undefined {
45
+ if (!value.startsWith("'") || !value.endsWith("'")) return undefined;
46
+ let out = "";
47
+ for (let index = 1; index < value.length - 1;) {
48
+ if (value.startsWith("'\\''", index)) { out += "'"; index += 4; }
49
+ else if (value[index] === "'") return undefined;
50
+ else out += value[index++];
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function validateOptions(options: AgyMcpOptions) {
56
+ const peer = options.peer ?? "agy";
57
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer)) throw new Error("agy peer id is invalid");
58
+ for (const [name, value] of Object.entries({ projectRoot: options.projectRoot, stateDir: options.stateDir, serverJs: options.serverJs, hookScript: options.hookScript })) {
59
+ if (typeof value !== "string" || !isAbsolute(value) || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) throw new Error(`${name} must be an absolute control-free path`);
60
+ }
61
+ for (const [name, value] of Object.entries({ instanceId: options.instanceId, launchId: options.launchId })) {
62
+ if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(value)) throw new Error(`${name} is invalid`);
63
+ }
64
+ const root = resolve(options.projectRoot);
65
+ return { root, peer };
66
+ }
67
+
68
+ function safeDirectory(path: string): Stats | undefined {
69
+ const stat = lstatSync(path, { throwIfNoEntry: false });
70
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`${path} must be a project directory, not a link`);
71
+ return stat;
72
+ }
73
+
74
+ function safeFile(path: string): { stat: Stats; bytes: Buffer } | undefined {
75
+ const stat = lstatSync(path, { throwIfNoEntry: false });
76
+ if (!stat) return undefined;
77
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_CONFIG_BYTES) throw new Error(`${path} must be a bounded regular file, not a link`);
78
+ const bytes = readFileSync(path);
79
+ if (bytes.length > MAX_CONFIG_BYTES) throw new Error(`${path} exceeds the configuration size limit`);
80
+ return { stat, bytes };
81
+ }
82
+
83
+ function pluginDirectories(root: string): DirectoryIdentity[] {
84
+ const directories: DirectoryIdentity[] = [];
85
+ for (const relative of [".", ".agents", ".agents/plugins", PLUGIN_DIR]) {
86
+ const path = relative === "." ? root : join(root, relative);
87
+ const stat = safeDirectory(path);
88
+ if (!stat) throw new Error(`${path} must be an existing project directory`);
89
+ directories.push({ path, stat });
90
+ }
91
+ return directories;
92
+ }
93
+
94
+ function verifyPluginDirectory(plan: Pick<Plan, "root" | "pluginDir" | "canonicalRoot" | "canonicalPluginDir" | "directories">, target: string): void {
95
+ if (!plan.canonicalPluginDir || !plan.directories) throw new Error("managed agy plugin directory identity is unavailable");
96
+ if (realPath(plan.root) !== plan.canonicalRoot || realPath(plan.pluginDir) !== plan.canonicalPluginDir || plan.canonicalPluginDir !== join(plan.canonicalRoot, PLUGIN_DIR)) {
97
+ throw new Error("managed agy plugin path changed; inspect it before writing");
98
+ }
99
+ for (const expected of plan.directories) {
100
+ const current = safeDirectory(expected.path);
101
+ if (!current || current.dev !== expected.stat.dev || current.ino !== expected.stat.ino) throw new Error("managed agy plugin directory changed; inspect it before writing");
102
+ }
103
+ if (!FILES.some(file => join(plan.root, file) === target)) {
104
+ throw new Error("atomic write target is outside the managed agy plugin files");
105
+ }
106
+ }
107
+
108
+ function hookCommand(phase: typeof EVENTS[number], options: AgyMcpOptions, peer: string): string {
109
+ return `AGENTHUB_HOOK_EVENT='${phase}' AGENTHUB_INSTANCE_ID='${options.instanceId}' AGENTHUB_LAUNCH_ID='${options.launchId}' AGENTHUB_HOOK_PURPOSE='facts' AGENTHUB_PEER_ID=${shellQuote(peer)} AGENTHUB_STATE_DIR=${shellQuote(options.stateDir)} AGENTHUB_PROJECT_DIR=${shellQuote(resolve(options.projectRoot))} bun ${shellQuote(options.hookScript)}`;
110
+ }
111
+
112
+ function desired(options: AgyMcpOptions, root: string, peer: string): Record<typeof FILES[number], JsonObject> {
113
+ const events: JsonObject = {};
114
+ for (const phase of EVENTS) {
115
+ if (phase === "PostInvocation") { events[phase] = []; continue; }
116
+ const command = { type: "command", command: hookCommand(phase, options, peer), timeout: 5 };
117
+ events[phase] = phase === "PreToolUse" || phase === "PostToolUse"
118
+ ? [{ matcher: ".*", hooks: [command] }]
119
+ : [command];
120
+ }
121
+ return {
122
+ [MANIFEST_FILE]: { name: PLUGIN_KEY, description: MANAGED_DESCRIPTION },
123
+ [MCP_FILE]: { mcpServers: { [PLUGIN_KEY]: { command: "bun", args: ["run", options.serverJs], env: { AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: options.stateDir, AGENTHUB_PROJECT_DIR: root } } } },
124
+ [HOOKS_FILE]: { [PLUGIN_KEY]: events },
125
+ };
126
+ }
127
+
128
+ function parseManagedHookCommand(command: unknown, phase: typeof EVENTS[number], options: AgyMcpOptions, peer: string): { instanceId: string; launchId: string } | undefined {
129
+ if (typeof command !== "string") return undefined;
130
+ const prefix = `AGENTHUB_HOOK_EVENT='${phase}' AGENTHUB_INSTANCE_ID='`;
131
+ const middle = `' AGENTHUB_LAUNCH_ID='`;
132
+ const suffix = `' AGENTHUB_HOOK_PURPOSE='facts' AGENTHUB_PEER_ID=${shellQuote(peer)} AGENTHUB_STATE_DIR=${shellQuote(options.stateDir)} AGENTHUB_PROJECT_DIR=${shellQuote(resolve(options.projectRoot))} bun `;
133
+ if (!command.startsWith(prefix)) return undefined;
134
+ const rest = command.slice(prefix.length);
135
+ const launchSplit = rest.indexOf(middle);
136
+ if (launchSplit < 1) return undefined;
137
+ const instanceId = rest.slice(0, launchSplit);
138
+ const afterInstance = rest.slice(launchSplit + middle.length);
139
+ const pathSplit = afterInstance.indexOf(suffix);
140
+ if (pathSplit < 1) return undefined;
141
+ const launchId = afterInstance.slice(0, pathSplit);
142
+ const hookScript = shellUnquote(afterInstance.slice(pathSplit + suffix.length));
143
+ if (!/^[A-Za-z0-9._:-]{1,128}$/.test(instanceId) || !/^[A-Za-z0-9._:-]{1,128}$/.test(launchId) || !hookScript || !isAbsolute(hookScript) || !hookScript.endsWith("/src/cli/facts-hook.ts")) return undefined;
144
+ return { instanceId, launchId };
145
+ }
146
+
147
+ function managedHookLaunch(value: JsonObject, options: AgyMcpOptions, peer: string): { instanceId: string; launchId: string } | undefined {
148
+ if (Object.keys(value).length !== 1 || !Object.hasOwn(value, PLUGIN_KEY)) return undefined;
149
+ const events = value[PLUGIN_KEY];
150
+ if (!object(events) || Object.keys(events).length !== EVENTS.length || EVENTS.some(phase => !Object.hasOwn(events, phase))) return undefined;
151
+ let identity: { instanceId: string; launchId: string } | undefined;
152
+ for (const phase of EVENTS) {
153
+ const rows = events[phase];
154
+ if (phase === "PostInvocation") { if (!Array.isArray(rows) || rows.length !== 0) return undefined; continue; }
155
+ if (!Array.isArray(rows) || rows.length !== 1 || !object(rows[0])) return undefined;
156
+ let command: unknown;
157
+ if (phase === "PreToolUse" || phase === "PostToolUse") {
158
+ const row = rows[0];
159
+ if (Object.keys(row).sort().join(",") !== "hooks,matcher" || row.matcher !== ".*" || !Array.isArray(row.hooks) || row.hooks.length !== 1 || !object(row.hooks[0])) return undefined;
160
+ command = row.hooks[0];
161
+ } else command = rows[0];
162
+ if (!object(command) || Object.keys(command).sort().join(",") !== "command,timeout,type" || command.type !== "command" || command.timeout !== 5) return undefined;
163
+ const found = parseManagedHookCommand(command.command, phase, options, peer);
164
+ if (!found || (identity && (identity.instanceId !== found.instanceId || identity.launchId !== found.launchId))) return undefined;
165
+ identity = found;
166
+ }
167
+ return identity;
168
+ }
169
+
170
+ function nextDocuments(current: Record<typeof FILES[number], JsonObject | undefined>, wanted: Record<typeof FILES[number], JsonObject>): Record<typeof FILES[number], JsonObject> {
171
+ const nextMcp = current[MCP_FILE] ? { ...current[MCP_FILE] } : {};
172
+ const currentServers = nextMcp.mcpServers;
173
+ const servers = currentServers === undefined ? {} : currentServers;
174
+ if (!object(servers)) throw new Error(`${MCP_FILE} mcpServers must be an object`);
175
+ nextMcp.mcpServers = { ...servers, [PLUGIN_KEY]: wanted[MCP_FILE].mcpServers[PLUGIN_KEY] };
176
+ return { [MANIFEST_FILE]: wanted[MANIFEST_FILE], [MCP_FILE]: nextMcp, [HOOKS_FILE]: wanted[HOOKS_FILE] };
177
+ }
178
+
179
+ function internalPlan(options: AgyMcpOptions): Plan {
180
+ const { root, peer } = validateOptions(options);
181
+ const rootStat = safeDirectory(root);
182
+ if (!rootStat) throw new Error("project root must be an existing directory");
183
+ const canonicalRoot = realPath(root);
184
+ const pluginDir = join(root, PLUGIN_DIR);
185
+ let parentsPresent = true;
186
+ for (const relative of [".agents", ".agents/plugins", PLUGIN_DIR]) {
187
+ if (!parentsPresent) break;
188
+ parentsPresent = !!safeDirectory(join(root, relative));
189
+ }
190
+ let directories: DirectoryIdentity[] | undefined;
191
+ let canonicalPluginDir: string | undefined;
192
+ if (parentsPresent) {
193
+ directories = pluginDirectories(root);
194
+ canonicalPluginDir = realPath(pluginDir);
195
+ if (canonicalPluginDir !== join(canonicalRoot, PLUGIN_DIR)) throw new Error("managed agy plugin path traverses a symlink");
196
+ }
197
+ const snapshots = Object.fromEntries(FILES.map(relative => {
198
+ const path = join(root, relative);
199
+ const found = parentsPresent ? safeFile(path) : undefined;
200
+ return [relative, found];
201
+ })) as Record<typeof FILES[number], { stat: Stats; bytes: Buffer } | undefined>;
202
+ const current = {} as Record<typeof FILES[number], JsonObject | undefined>;
203
+ for (const relative of FILES) {
204
+ const found = snapshots[relative];
205
+ current[relative] = found ? parseJson(found.bytes, relative) : undefined;
206
+ }
207
+
208
+ const manifest = current[MANIFEST_FILE];
209
+ if (manifest && (Object.keys(manifest).sort().join(",") !== "description,name" || manifest.name !== PLUGIN_KEY || manifest.description !== MANAGED_DESCRIPTION)) {
210
+ throw new Error(`${MANIFEST_FILE} is a foreign agent-hub plugin manifest; it was not changed`);
211
+ }
212
+ const mcp = current[MCP_FILE];
213
+ if (mcp) {
214
+ if (Object.hasOwn(mcp, "mcpServers") && !object(mcp.mcpServers)) throw new Error(`${MCP_FILE} mcpServers must be an object`);
215
+ const entry = mcp.mcpServers?.[PLUGIN_KEY];
216
+ if (entry !== undefined && (!object(entry) || Object.keys(entry).sort().join(",") !== "args,command,env" || entry.command !== "bun" || !Array.isArray(entry.args) || entry.args.length !== 2 || entry.args[0] !== "run" || typeof entry.args[1] !== "string" || !isAbsolute(entry.args[1]) || !object(entry.env) || Object.keys(entry.env).sort().join(",") !== "AGENTHUB_PEER_ID,AGENTHUB_PROJECT_DIR,AGENTHUB_STATE_DIR" || entry.env.AGENTHUB_PEER_ID !== peer || entry.env.AGENTHUB_PROJECT_DIR !== root || entry.env.AGENTHUB_STATE_DIR !== options.stateDir)) {
217
+ throw new Error(`${MCP_FILE} agent-hub is a foreign MCP entry; it was not changed`);
218
+ }
219
+ }
220
+ const hooks = current[HOOKS_FILE];
221
+ const oldLaunch = hooks ? managedHookLaunch(hooks, options, peer) : undefined;
222
+ if (hooks && !oldLaunch) throw new Error(`${HOOKS_FILE} agent-hub is a foreign hooks entry; it was not changed`);
223
+
224
+ const wanted = desired(options, root, peer);
225
+ const docs = nextDocuments(current, wanted);
226
+ const targets = FILES.map(relative => {
227
+ const old = snapshots[relative];
228
+ const next = Buffer.from(`${JSON.stringify(docs[relative], null, 2)}\n`);
229
+ return { path: join(root, relative), relative, ...(old ? { current: old.bytes, stat: old.stat } : {}), next, changed: !old || !isDeepStrictEqual(current[relative], docs[relative]) };
230
+ });
231
+ const git = hubGitSync(["ls-files", "--", ...FILES], { cwd: root, encoding: "utf8", timeout: 3000 });
232
+ const tracked = git.status === 0 && !!git.stdout.trim();
233
+ const changed = targets.some(target => target.changed);
234
+ return { file: pluginDir, tracked, changed, action: changed ? "create or update the managed agent-hub plugin" : "keep the managed agent-hub plugin", root, pluginDir, canonicalRoot, ...(canonicalPluginDir ? { canonicalPluginDir } : {}), ...(directories ? { directories } : {}), targets };
235
+ }
236
+
237
+ /** Preview the native plugin's three project-scoped files without writing or exposing foreign contents. */
238
+ export function agyMcpPlan(options: AgyMcpOptions): { file: string; tracked: boolean; changed: boolean; action: string } {
239
+ const { file, tracked, changed, action } = internalPlan(options);
240
+ return { file, tracked, changed, action };
241
+ }
242
+
243
+ function identityMatches(path: string, before?: Stats): boolean {
244
+ const now = lstatSync(path, { throwIfNoEntry: false });
245
+ return before ? !!now && now.dev === before.dev && now.ino === before.ino : !now;
246
+ }
247
+
248
+ function atomicWrite(path: string, bytes: Buffer, expected: Stats | undefined, plan: Plan): { dev: number; ino: number } {
249
+ const temporary = `${path}.${randomUUID()}.tmp`;
250
+ let fd: number | undefined;
251
+ let tempIdentity: { dev: number; ino: number } | undefined;
252
+ try {
253
+ verifyPluginDirectory(plan, path);
254
+ fd = openSync(temporary, "wx", 0o600);
255
+ const tempStat = fstatSync(fd);
256
+ tempIdentity = { dev: tempStat.dev, ino: tempStat.ino };
257
+ writeFileSync(fd, bytes);
258
+ closeSync(fd); fd = undefined;
259
+ verifyPluginDirectory(plan, path);
260
+ if (!identityMatches(path, expected)) throw new Error(`${path} changed while the managed plugin was being written`);
261
+ renameSync(temporary, path);
262
+ const stat = lstatSync(path);
263
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error(`${path} changed while the managed plugin was written`);
264
+ return { dev: stat.dev, ino: stat.ino };
265
+ } finally {
266
+ if (fd !== undefined) closeSync(fd);
267
+ try {
268
+ const current = lstatSync(temporary, { throwIfNoEntry: false });
269
+ if (current && tempIdentity && current.dev === tempIdentity.dev && current.ino === tempIdentity.ino) unlinkSync(temporary);
270
+ } catch { /* A temp path that no longer names our file is not ours to remove. */ }
271
+ }
272
+ }
273
+
274
+ function rollback(plan: Plan, written: { target: Target; own: { dev: number; ino: number } }[], createdDirs: { path: string; dev: number; ino: number }[]): void {
275
+ for (const { target, own } of written.reverse()) {
276
+ try {
277
+ const now = lstatSync(target.path, { throwIfNoEntry: false });
278
+ if (!now || now.dev !== own.dev || now.ino !== own.ino) continue;
279
+ if (target.current) atomicWrite(target.path, target.current, now, plan);
280
+ else unlinkSync(target.path);
281
+ } catch { /* Never replace a path whose ownership changed during rollback. */ }
282
+ }
283
+ for (const { path, dev, ino } of createdDirs.reverse()) {
284
+ try { const now = lstatSync(path); if (now.isDirectory() && !now.isSymbolicLink() && now.dev === dev && now.ino === ino) rmdirSync(path); }
285
+ catch { /* Keep a directory that is no longer empty or no longer ours. */ }
286
+ }
287
+ }
288
+
289
+ export function ensureAgyMcp(options: AgyMcpOptions): { changed: boolean; tracked: boolean } {
290
+ const plan = internalPlan(options);
291
+ if (!plan.changed) return { changed: false, tracked: plan.tracked };
292
+ const createdDirs: { path: string; dev: number; ino: number }[] = [];
293
+ const written: { target: Target; own: { dev: number; ino: number } }[] = [];
294
+ try {
295
+ for (const relative of [".agents", ".agents/plugins", PLUGIN_DIR]) {
296
+ const path = join(plan.root, relative);
297
+ const prior = safeDirectory(path);
298
+ if (prior) continue;
299
+ mkdirSync(path, { mode: 0o700 });
300
+ const stat = safeDirectory(path)!;
301
+ createdDirs.push({ path, dev: stat.dev, ino: stat.ino });
302
+ }
303
+ plan.directories = pluginDirectories(plan.root);
304
+ plan.canonicalPluginDir = realPath(plan.pluginDir);
305
+ if (plan.canonicalRoot !== realPath(plan.root) || plan.canonicalPluginDir !== join(plan.canonicalRoot, PLUGIN_DIR)) throw new Error("managed agy plugin path changed; inspect it before writing");
306
+ for (const target of plan.targets) {
307
+ if (!target.changed) continue;
308
+ if (!identityMatches(target.path, target.stat)) throw new Error(`${target.path} changed while the managed plugin was being prepared`);
309
+ const own = atomicWrite(target.path, target.next, target.stat, plan);
310
+ written.push({ target, own });
311
+ }
312
+ } catch (error) {
313
+ rollback(plan, written, createdDirs);
314
+ throw error;
315
+ }
316
+ return { changed: true, tracked: plan.tracked };
317
+ }
318
+
319
+ export function agyMcpReady(options: AgyMcpOptions): boolean {
320
+ return !internalPlan(options).changed;
321
+ }
322
+
323
+ export function cleanupAgyMcp(options: AgyMcpOptions): void {
324
+ const plan = internalPlan(options);
325
+ const present = plan.targets.filter(target => target.current !== undefined);
326
+ if (!present.length) return;
327
+ if (present.length !== FILES.length) throw new Error("managed agy plugin is incomplete; inspect it before cleanup");
328
+ const currentHooks = parseJson(readFileSync(join(plan.root, HOOKS_FILE)), HOOKS_FILE);
329
+ const launch = managedHookLaunch(currentHooks, options, options.peer ?? "agy");
330
+ if (!launch || launch.instanceId !== options.instanceId || launch.launchId !== options.launchId) return;
331
+ // A stale cleanup never removes files a newer config update has replaced.
332
+ if (plan.changed) return;
333
+ const deleted: { path: string; bytes: Buffer; own?: { dev: number; ino: number } }[] = [];
334
+ try {
335
+ for (const target of plan.targets) {
336
+ const stat = lstatSync(target.path);
337
+ if (!target.stat || stat.dev !== target.stat.dev || stat.ino !== target.stat.ino) throw new Error(`${target.path} changed before agy plugin cleanup`);
338
+ }
339
+ for (const target of plan.targets) {
340
+ if (!identityMatches(target.path, target.stat)) throw new Error(`${target.path} changed before agy plugin cleanup`);
341
+ if (target.relative === MCP_FILE) {
342
+ const currentMcp = parseJson(target.current!, MCP_FILE);
343
+ const servers = { ...currentMcp.mcpServers };
344
+ delete servers[PLUGIN_KEY];
345
+ const nextMcp = { ...currentMcp, mcpServers: servers };
346
+ const retain = Object.keys(servers).length > 0 || Object.keys(currentMcp).some(key => key !== "mcpServers");
347
+ if (retain) {
348
+ const own = atomicWrite(target.path, Buffer.from(`${JSON.stringify(nextMcp, null, 2)}\n`), target.stat, plan);
349
+ deleted.push({ path: target.path, bytes: target.current!, own });
350
+ } else {
351
+ unlinkSync(target.path);
352
+ deleted.push({ path: target.path, bytes: target.current! });
353
+ }
354
+ } else {
355
+ unlinkSync(target.path);
356
+ deleted.push({ path: target.path, bytes: target.current! });
357
+ }
358
+ }
359
+ } catch (error) {
360
+ for (const item of deleted.reverse()) {
361
+ try {
362
+ const current = lstatSync(item.path, { throwIfNoEntry: false });
363
+ if (item.own) {
364
+ if (current?.dev === item.own.dev && current.ino === item.own.ino) atomicWrite(item.path, item.bytes, current, plan);
365
+ } else if (!current) atomicWrite(item.path, item.bytes, undefined, plan);
366
+ } catch { /* Restore only a path still owned by this cleanup or a path that remains missing. */ }
367
+ }
368
+ throw error;
369
+ }
370
+ }
@@ -22,6 +22,7 @@ export interface ConsoleState {
22
22
  settingsRows?: SettingRow[]; settingsKnown?: boolean; settingsUndo?: string; settingsProblem?: string;
23
23
  settingsEdit?: SettingsEdit; settingsSequence?: number;
24
24
  pendingRestart?: { key: string; reason: string }[];
25
+ retainedConfig?: { key: string; reason: string }[];
25
26
  features?: readonly string[];
26
27
  /** Older embedding callers omit choices and retain the original STAI/off toggle. */
27
28
  petChoices?: readonly CharacterId[]; petCharacter?: CharacterId; petSize?: PetSize; petGlyphs?: "ascii" | "blocks";
@@ -893,6 +894,7 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
893
894
  if (p.toolsOnly) summary.push(span(definitionOf(id)?.attach?.pullOnlyForms?.includes("tui") ? ` pull-only, read ${typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never"}` : " tools-only: ahub claude", "attention"));
894
895
  }
895
896
  if (s.pendingRestart?.length) summary.push(span(`${summary.length ? " | " : ""}restart pending: ${s.pendingRestart.map(row => row.key).join(", ")}`, "attention"));
897
+ if (s.retainedConfig?.length) summary.push(span(`${summary.length ? " | " : ""}config retained: ${s.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, "attention"));
896
898
  const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
897
899
  if (quotas) summary.push(span(`${summary.length ? " | " : ""}${quotas}`));
898
900
  const approvals = [span(plural(s.approvals.length, "approval"), s.approvals.length ? "attention" : undefined)];
@@ -947,6 +949,10 @@ export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24,
947
949
  const info = s.settingsProblem ?? (!s.settingsKnown ? "settings loading..." : row?.restartReason ?? row?.note ?? row?.label ?? "Settings");
948
950
  lines.push(...wrap(info, columns).slice(0, 2).map(text => [span(text, s.settingsProblem ? "failure" : "info")]));
949
951
  if (s.pendingRestart?.length) lines.push(fitLine([span(`pending restart: ${s.pendingRestart.map(row => `${row.key}: ${row.reason}`).join("; ")}`, "attention")], columns));
952
+ if (s.retainedConfig?.length) {
953
+ lines.push(fitLine([span(`config retained: ${s.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, "attention")], columns));
954
+ for (const pending of s.retainedConfig) lines.push(fitLine([span(`${pending.key}: ${pending.reason}`, "attention")], columns));
955
+ }
950
956
  }
951
957
  if (s.panel === 3) {
952
958
  const done = progress.total ? Math.floor(progress.counts.approved * 20 / progress.total) : 0;
@@ -196,6 +196,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
196
196
  captureConsoleSelection(state);
197
197
  if (status.status?.tasks && status.ok !== false) state.taskCounts = status.status.tasks;
198
198
  if (status.status && status.ok !== false) state.pendingRestart = Array.isArray(status.status.pendingRestart) ? status.status.pendingRestart : [];
199
+ if (status.status && status.ok !== false) state.retainedConfig = Array.isArray(status.status.retainedConfig) ? status.status.retainedConfig : [];
199
200
  if (status.status) state.features = Array.isArray(status.status.features) ? status.status.features : [];
200
201
  if (status.status?.peers) state.peers = Object.fromEntries(Object.entries(status.status.peers).map(([peer, value]) => [peer, { ...(value as object), ...status.status.roleInfo?.[peer] }]));
201
202
  if (status.status && status.ok !== false) {
@@ -216,6 +217,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
216
217
  state.settingsRows = settings.rows; state.settingsKnown = true; state.settingsProblem = undefined;
217
218
  state.settingsUndo = typeof settings.undo === "string" ? settings.undo : undefined;
218
219
  state.pendingRestart = Array.isArray(settings.pendingRestart) ? settings.pendingRestart : [];
220
+ state.retainedConfig = Array.isArray(settings.retainedConfig) ? settings.retainedConfig : [];
219
221
  } else state.settingsProblem = String(settings.error ?? "settings are unavailable");
220
222
  }
221
223
  reconcileDetail();
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { definitionOf } from "../adapters/registry.ts";
2
3
  // Managed Claude hook transport for turn-free facts, native observations and runtime permissions.
3
4
  // PreToolUse preserves facts additionalContext and adds permission decisions; other phases observe only.
4
5
  // A failed or timed-out hub decides nothing, leaving Claude's native permission rules in effect.
@@ -57,7 +58,19 @@ export function scopedWriteBlocked(phase: string, permission: unknown, tool: unk
57
58
  }
58
59
 
59
60
  /** The hook's stdout for one Claude Code hook input, or undefined for none. */
60
- export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000): Promise<string | undefined> {
61
+ export async function factsHook(stdin: string, stateDir: string, peer: string, timeoutMs = 2000, nativeEvent?: string): Promise<string | undefined> {
62
+ if (nativeEvent !== undefined) {
63
+ if (Buffer.byteLength(stdin, "utf8") > 1024 * 1024) return undefined;
64
+ const port = definitionOf(peer)?.hooks?.factsHook;
65
+ const payload: unknown = JSON.parse(stdin);
66
+ if (!port?.decode(nativeEvent, payload)) return undefined;
67
+ const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
68
+ try {
69
+ const reply = await hub.request({ t: "native_facts", event: nativeEvent, payload, ...nativeHookIdentity(stateDir, peer) }, timeoutMs);
70
+ const output = reply.ok ? port.encode(nativeEvent, typeof reply.text === "string" ? reply.text : undefined) : undefined;
71
+ return output ? JSON.stringify(output) : undefined;
72
+ } finally { hub.close(); }
73
+ }
61
74
  const input = JSON.parse(stdin) as { hook_event_name?: unknown; tool_name?: unknown; tool_input?: unknown; tool_use_id?: unknown; session_id?: unknown; transcript_path?: unknown };
62
75
  const phase = ({ SessionStart: "session", UserPromptSubmit: "start", PostToolUse: "post", Stop: "stop" } as Record<string, string>)[String(input.hook_event_name)] ?? "pre";
63
76
  const hub = await ControlClient.connect(stateDir, { role: "tools", peer }, timeoutMs);
@@ -88,7 +101,7 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
88
101
  if (import.meta.main) {
89
102
  try {
90
103
  const stateDir = process.env.AGENTHUB_STATE_DIR;
91
- const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? "claude") : undefined;
104
+ const out = stateDir ? await factsHook(await Bun.stdin.text(), stateDir, process.env.AGENTHUB_PEER_ID ?? "claude", 2000, process.env.AGENTHUB_HOOK_EVENT) : undefined;
92
105
  if (out) console.log(out);
93
106
  } catch {
94
107
  // a hook that fails must not get in the way of the tool call
package/src/cli/help.ts CHANGED
@@ -25,6 +25,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
25
25
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
26
26
  ["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
27
27
  ["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N --ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
28
+ ["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
29
+ ["ahub sdlc release-assign <task> <revision> <release-id|none> <reason>", "assign approved/in-flight work without changing execution"],
28
30
  ["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
29
31
  ["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
30
32
  ["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
@@ -13,7 +13,7 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
13
13
 
14
14
  /** Person verdicts and user-authored workflow content are never delegated. */
15
15
  export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
16
- if (command === "sdlc" && args[0] === "plan-review") return true;
16
+ if (command === "sdlc" && (args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "train" && args[1] !== "report")) return true;
17
17
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
18
18
  const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
19
19
  return ["task", "role", "goal"].includes(command)
package/src/cli/init.ts CHANGED
@@ -5,6 +5,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, unlinkSync, w
5
5
  import { join } from "node:path";
6
6
  import { hubGitSync } from "../hub/git.ts";
7
7
  import { KIMI_MCP_FILE } from "./kimi-mcp.ts";
8
+ import { AGY_PLUGIN_FILES } from "./agy-mcp.ts";
8
9
 
9
10
  const TEMPLATES = join(import.meta.dir, "..", "..", "templates");
10
11
  const BEGIN = "<!-- AGENT_HUB:BEGIN (managed by `ahub init`, edits inside are overwritten) -->";
@@ -96,6 +97,12 @@ function changes(cwd: string, requested?: ProfileName, shadow = false): PlannedW
96
97
  if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
97
98
  }
98
99
  if (existsSync(join(cwd, KIMI_MCP_FILE)) && hubGitSync(["ls-files", "--error-unmatch", "--", KIMI_MCP_FILE], { cwd, encoding: "utf8", timeout: 3000 }).status !== 0 && !lines.split("\n").includes(KIMI_MCP_FILE)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${KIMI_MCP_FILE}\n`;
100
+ const agyPlugin = ".agents/plugins/agent-hub/";
101
+ if (existsSync(join(cwd, agyPlugin))) {
102
+ const tracked = new Set(hubGitSync(["ls-files", "--", agyPlugin], { cwd, encoding: "utf8", timeout: 3000 }).stdout.trim().split("\n").filter(Boolean));
103
+ const entries = tracked.size ? AGY_PLUGIN_FILES.filter(file => !tracked.has(file)) : [agyPlugin];
104
+ for (const entry of entries) if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
105
+ }
99
106
  if (existsSync(join(cwd, JCODE_MCP_FILE)) && hubGitSync(["ls-files", "--error-unmatch", "--", JCODE_MCP_FILE], { cwd, encoding: "utf8", timeout: 3000 }).status !== 0 && !lines.split("\n").includes(JCODE_MCP_FILE)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${JCODE_MCP_FILE}\n`;
100
107
  write(ignore, lines, "ignore runtime state and machine-local configuration");
101
108
  return changed;
package/src/cli/launch.ts CHANGED
@@ -12,6 +12,8 @@ import type { InstancesConfig } from "../hub/instances.ts";
12
12
  import { kimiAcpCommand } from "../adapters/definitions/native-daemon-kimi.ts";
13
13
  export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
14
14
 
15
+ export { retainedLaunchWarning } from "../adapters/definitions/native-cli-retained.ts";
16
+
15
17
  /** Consume only ahub's selector, never native arguments after the passthrough delimiter. */
16
18
  export function instanceLaunchArguments(command: string, raw: readonly string[], instances?: InstancesConfig): { instance?: string; args: string[]; hubStarted?: true } {
17
19
  if (!definitionOf(command)) return { args: [...raw] };
@@ -52,7 +54,7 @@ export function nativeLaunchEnv(tool: string, source: NodeJS.ProcessEnv, instanc
52
54
  delete env.AGENTHUB_UNATTENDED;
53
55
  // Channel evidence for the plugin (issue #205): set beside the development-channel flag `buildLaunch` always passes.
54
56
  const definition = definitionOf(tool, instances);
55
- if (definition?.identity.mcp === "channel-plugin") env.AGENTHUB_CHANNEL = "1";
57
+ if (definition?.identity.mcp === "channel-plugin" && !definition.attach?.pullOnlyForms?.includes("tui")) env.AGENTHUB_CHANNEL = "1";
56
58
  if (definition?.attach?.pullOnlyForms?.includes("tui")) delete env.AGENTHUB_MODE; // its MCP child must attach pull-only, never as a tools socket
57
59
  return env;
58
60
  }
package/src/cli/main.ts CHANGED
@@ -906,6 +906,7 @@ const commands: Record<string, () => Promise<void> | void> = {
906
906
  hub.close();
907
907
  if (!res.ok) fail(res.error);
908
908
  if (res.recorded) return console.log("recorded only ([FYI]); no peer was interrupted");
909
+ if (res.dropped === "hop") return console.log(res.notice);
909
910
  console.log(paint([{ text: res.targets.length ? `queued for: ${res.targets.join(", ")}` : "no peers attached; nothing delivered" }], false));
910
911
  if (typeof res.notice === "string" && res.notice) console.log(paint([{ text: res.notice }], false));
911
912
  },
@@ -1118,6 +1119,25 @@ const commands: Record<string, () => Promise<void> | void> = {
1118
1119
  finally { hub.close(); }
1119
1120
  return;
1120
1121
  }
1122
+ if (sub === "train") {
1123
+ const operation = args[1], releaseId = Number(args[2]), revision = Number(args[3]);
1124
+ if (!Number.isSafeInteger(releaseId) || releaseId <= 0) fail("usage: ahub sdlc train report|configure|cut <release-id> [revision] [configuration-json]");
1125
+ if (operation === "report" && args.length === 3) return console.log(await taskOp("hub_release_report", { id: releaseId }));
1126
+ if (!Number.isSafeInteger(revision) || revision < 0) fail("train mutation requires its current revision");
1127
+ if (operation === "cut" && args.length === 4) return console.log(await taskOp("hub_release_cut", { id: releaseId, revision }));
1128
+ if (operation === "configure" && args.length === 5) {
1129
+ let fields;
1130
+ try { fields = JSON.parse(args[4]!); } catch { return fail("release train configuration must be JSON"); }
1131
+ if (!fields || typeof fields !== "object" || Array.isArray(fields) || Object.keys(fields).some(key => !["state", "cut", "hotfix"].includes(key))) fail("invalid release train configuration fields");
1132
+ return console.log(await taskOp("hub_release_configure", { ...fields, id: releaseId, revision }));
1133
+ }
1134
+ fail("usage: ahub sdlc train report|configure|cut <release-id> [revision] [configuration-json]");
1135
+ }
1136
+ if (sub === "release-assign") {
1137
+ const revision = Number(args[2]), release = args[3] === "none" ? null : Number(args[3]);
1138
+ if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || release !== null && (!Number.isSafeInteger(release) || release <= 0) || args.length < 5) fail("usage: ahub sdlc release-assign <task> <planning-revision> <release-id|none> <reason...>");
1139
+ return console.log(await taskOp("hub_release_assign", { id, revision, release, reason: args.slice(4).join(" ") }));
1140
+ }
1121
1141
  if (sub === "costs") {
1122
1142
  if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
1123
1143
  return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
package/src/cli/memory.ts CHANGED
@@ -1,13 +1,12 @@
1
1
  import { detectCliIdentity } from "./identity.ts";
2
2
  import { createStalenessProbe } from "../hub/memory-staleness.ts";
3
- import { looksSecret, type MemoryStore, type StoreOptions } from "@staix/agent-mem";
3
+ import { looksSecret, type MemoryStore } from "@staix/agent-mem";
4
4
  import type { SyncOptions } from "@staix/agent-mem";
5
5
  import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
6
- import { memoryProjectKey } from "@staix/agent-mem";
7
6
  import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
8
7
  import { currentRouting, detectSignals } from "../hub/routing.ts";
9
8
  import { loadConfig } from "../hub/daemon.ts";
10
- import { bindProfileMemory, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, type MemoryProfileReceipt } from "../hub/memory-profile.ts";
9
+ import { bindProfileMemory, memoryProjectFor, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
11
10
 
12
11
  export { GOAL_USAGE } from "@staix/agent-mem";
13
12
  export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
@@ -18,9 +17,9 @@ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch
18
17
 
19
18
 
20
19
  /** Bind each command at entry; the CLI closes default opens and injected stores remain caller-owned. */
21
- function commandStores(provided?: MemoryStore, options: StoreOptions = {}) {
20
+ function commandStores(provided: MemoryStore | undefined, options: ProfileMemoryOptions) {
22
21
  try { return { ...(provided ? bindProfileMemory(provided) : openProfileMemory(options)), owned: !provided }; }
23
- catch { throw new Error(MEMORY_PROFILE_UNAVAILABLE); }
22
+ catch (error) { throw new Error(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : MEMORY_PROFILE_UNAVAILABLE); }
24
23
  }
25
24
  function profileOutput(text: string, json: boolean, profile: MemoryProfileReceipt | undefined, person: boolean): string {
26
25
  if (!profile) return text;
@@ -44,9 +43,9 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
44
43
  const cutoffMs = (opts.now ?? Date.now)() - days * 24 * 3600_000;
45
44
  if (!Number.isSafeInteger(days) || !Number.isFinite(cutoffMs) || Math.abs(cutoffMs) > 8_640_000_000_000_000) fail("--days is outside the supported date range");
46
45
  const cutoff = new Date(cutoffMs).toISOString();
47
- const project = memoryProjectKey(cwd), routing = currentRouting(cwd), paths = privatePaths();
46
+ const project = memoryProjectFor(cwd), routing = currentRouting(cwd), paths = privatePaths();
48
47
  // Review must not promote a held source while reading it: only explicit person actions may append rows.
49
- const context = commandStores(opts.store, { deferPromotions: true, now: opts.now });
48
+ const context = commandStores(opts.store, { project, deferPromotions: true, now: opts.now });
50
49
  const store = context.store;
51
50
  try {
52
51
  context.binding.assertCurrent();
@@ -68,11 +67,12 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
68
67
  } finally { if (context.owned) store.close(); }
69
68
  }
70
69
  let showStale = false;
71
- const context = commandStores(opts.store, { ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
72
- screen: promotionScreen(memoryProjectKey(cwd), cwd, () => currentRouting(cwd), privatePaths) });
70
+ const project = memoryProjectFor(cwd);
71
+ const context = commandStores(opts.store, { project, ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
72
+ screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) });
73
73
  try {
74
74
  context.binding.assertCurrent();
75
- const text = await rawMemoryCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
75
+ const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
76
76
  readable: notes => {
77
77
  const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
78
78
  if (args[0] !== "show" || args.includes("--json")) return visible;
@@ -92,10 +92,11 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
92
92
  export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string>; person?: boolean } = {}): Promise<string> {
93
93
  const person = opts.person ?? detectCliIdentity(process.env).role === "console";
94
94
  const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
95
- const context = commandStores(opts.store);
95
+ const project = memoryProjectFor(cwd);
96
+ const context = commandStores(opts.store, { project });
96
97
  try {
97
98
  context.binding.assertCurrent();
98
- const text = await rawGoalCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
99
+ const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
99
100
  clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
100
101
  });
101
102
  return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);