@staix/agent-hub 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +23 -9
  3. package/docs/agent-notes/README.md +8 -0
  4. package/docs/agent-notes/adapters.md +21 -4
  5. package/docs/agent-notes/budget.md +6 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +20 -9
  8. package/docs/agent-notes/local-worker.md +2 -2
  9. package/docs/agent-notes/tasks.md +21 -4
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/dogfood.md +19 -5
  12. package/docs/events.md +21 -7
  13. package/docs/memory.md +78 -10
  14. package/docs/operations.md +197 -39
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +16 -6
  17. package/docs/sdlc.md +17 -0
  18. package/docs/security.md +110 -21
  19. package/docs/smoke.md +7 -3
  20. package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
  21. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  22. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  23. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  24. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  25. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  26. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  27. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  28. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  29. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  30. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  31. package/docs/verified.json +40 -19
  32. package/package.json +2 -2
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +3109 -2046
  35. package/src/adapters/acp.ts +46 -7
  36. package/src/adapters/claude-channel.ts +26 -4
  37. package/src/adapters/claude-implementer.ts +243 -0
  38. package/src/adapters/claude-print-child.ts +90 -0
  39. package/src/adapters/claude-task-sessions.ts +80 -0
  40. package/src/adapters/codex-appserver.ts +69 -1
  41. package/src/adapters/compat.ts +6 -2
  42. package/src/adapters/conformance/enrollment.ts +2 -1
  43. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  44. package/src/adapters/definitions/builtin-metadata.ts +17 -5
  45. package/src/adapters/definitions/native-channel-state.ts +1 -1
  46. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  47. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/kimi-wire-usage.ts +303 -0
  50. package/src/adapters/local-worker.ts +5 -4
  51. package/src/adapters/registry-types.ts +8 -4
  52. package/src/adapters/registry.ts +14 -2
  53. package/src/adapters/reviewer-print.ts +30 -53
  54. package/src/cli/console-config.ts +123 -0
  55. package/src/cli/console-state.ts +48 -3
  56. package/src/cli/console-symbols.ts +3 -2
  57. package/src/cli/console-usage.ts +5 -2
  58. package/src/cli/console.ts +30 -2
  59. package/src/cli/facts-hook.ts +9 -5
  60. package/src/cli/help.ts +10 -7
  61. package/src/cli/identity.ts +3 -1
  62. package/src/cli/launch.ts +10 -4
  63. package/src/cli/main.ts +91 -19
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +40 -4
  66. package/src/cli/reset.ts +1 -1
  67. package/src/cli/settings-edit.ts +89 -0
  68. package/src/cli/terminal-recovery.ts +1 -6
  69. package/src/hub/action-class.ts +211 -17
  70. package/src/hub/action-enforce.ts +90 -0
  71. package/src/hub/action-policy.ts +40 -17
  72. package/src/hub/ask.ts +16 -4
  73. package/src/hub/board.ts +110 -31
  74. package/src/hub/budget-notes.ts +65 -0
  75. package/src/hub/budget.ts +44 -7
  76. package/src/hub/bus.ts +6 -6
  77. package/src/hub/claude-launch-proof.ts +33 -0
  78. package/src/hub/code-facts.ts +56 -16
  79. package/src/hub/conductor.ts +5 -2
  80. package/src/hub/config-apply.ts +12 -0
  81. package/src/hub/config-edit-host.ts +173 -0
  82. package/src/hub/config-edit.ts +282 -0
  83. package/src/hub/config-tier.ts +89 -0
  84. package/src/hub/control-client.ts +24 -3
  85. package/src/hub/daemon.ts +614 -104
  86. package/src/hub/deliverable-files.ts +131 -0
  87. package/src/hub/deliverables.ts +125 -0
  88. package/src/hub/delivery-journal.ts +18 -2
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +5 -3
  91. package/src/hub/hub-tools.ts +22 -6
  92. package/src/hub/instances.ts +7 -4
  93. package/src/hub/kimi-usage.ts +156 -0
  94. package/src/hub/lifecycle.ts +10 -5
  95. package/src/hub/manager.ts +1 -1
  96. package/src/hub/memory-code-head.ts +44 -0
  97. package/src/hub/memory-code-overlay.ts +119 -0
  98. package/src/hub/memory-plan-guard.ts +10 -1
  99. package/src/hub/memory-stale-anchors.ts +159 -0
  100. package/src/hub/memory-symbols.ts +74 -0
  101. package/src/hub/peers.ts +7 -0
  102. package/src/hub/permission-payload.ts +15 -0
  103. package/src/hub/planning.ts +10 -4
  104. package/src/hub/read-tools.ts +21 -3
  105. package/src/hub/routing.ts +118 -3
  106. package/src/hub/sdlc.ts +34 -0
  107. package/src/hub/settings.ts +18 -3
  108. package/src/hub/settled-roles.ts +15 -3
  109. package/src/hub/task-sweep.ts +25 -3
  110. package/src/hub/tasks.ts +551 -75
  111. package/src/hub/token-window.ts +46 -0
  112. package/src/hub/usage-rollup.ts +16 -6
  113. package/src/hub/window-kind.ts +13 -0
  114. package/src/hub/workflow.ts +16 -0
  115. package/src/local/tools.ts +5 -5
  116. package/src/memory/brief.ts +22 -12
  117. package/src/memory/capture.ts +26 -40
  118. package/src/memory/client.ts +137 -65
  119. package/src/memory/recall.ts +13 -22
  120. package/src/ui/index.html +215 -19
@@ -328,7 +328,7 @@ export function recordPath(stateDir: string): string {
328
328
  return join(stateDir, RECORD_FILE);
329
329
  }
330
330
 
331
- /** Extra terminal ownership is supported only for the native proxy transport; other I2 extras stay headless. */
331
+ /** Resolve declared terminal roots without treating a launch candidate as daemon ownership proof. */
332
332
  function executionRoot(peer: string, projectRoot: string, captured?: string): string {
333
333
  const builtin = definitionOf(peer);
334
334
  if (builtin?.session.launchRecord) return projectRoot;
@@ -336,11 +336,6 @@ function executionRoot(peer: string, projectRoot: string, captured?: string): st
336
336
  const definition = catalog.definitionOf(peer, declarations);
337
337
  if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
338
338
  if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
339
- // #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
340
- if (definition.transport === "claude-channel") {
341
- if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
342
- return projectRoot;
343
- }
344
339
  const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles, catalog), catalog);
345
340
  if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
346
341
  return expected;
@@ -5,7 +5,8 @@ export type ActionClass = typeof ACTION_CLASSES[number];
5
5
 
6
6
  const SHELL_TOOLS = new Set(["bash", "shell", "shell_command", "exec_command", "run_shell_command"]);
7
7
  const LIMIT = 16_384;
8
- type Command = { words: string[]; outputs: string[] };
8
+ /** `nested`: the commands of each substitution in the segment (`$(...)`, a backtick). They run in a subshell, so their `cd` never moves the outer cwd. */
9
+ type Command = { words: string[]; outputs: string[]; nested?: Command[][] };
9
10
 
10
11
  /** Bound the whole input before accepting any prefix, including unsupported suffixes. */
11
12
  function segmentLimit(text: string): boolean {
@@ -28,8 +29,79 @@ function segmentLimit(text: string): boolean {
28
29
  return true;
29
30
  }
30
31
 
31
- /** Literal shell words only; unsupported syntax discards only its unfinished segment. */
32
- function commands(text: string): Command[] | undefined {
32
+ /**
33
+ * An expansion (`$VAR`, `${...}`, `$(...)`, a backtick) stands in a word as this one character. The input itself may not contain it
34
+ * (`commands` refuses a NUL), so a word has it only where the hub put it: the word's value is unknown (#762).
35
+ */
36
+ const OPAQUE = "\0";
37
+ const opaque = (word: string | undefined): boolean => word !== undefined && word.includes(OPAQUE);
38
+
39
+ /** The index after the bracket that closes `text[at]` (`(` or `{`), quotes and nested expansions respected, or -1 when it never closes. */
40
+ function matching(text: string, at: number, nest = 0): number {
41
+ if (nest > 16) return -1;
42
+ const open = text[at]!, close = open === "(" ? ")" : "}";
43
+ let depth = 0;
44
+ for (let i = at; i < text.length; i++) {
45
+ const c = text[i]!;
46
+ if (c === "\\") { i++; continue; }
47
+ if (c === "'") { const end = text.indexOf("'", i + 1); if (end < 0) return -1; i = end; continue; }
48
+ if (c === "`") { const end = text.indexOf("`", i + 1); if (end < 0) return -1; i = end; continue; }
49
+ if (c === '"') {
50
+ for (i++; i < text.length && text[i] !== '"'; i++) {
51
+ if (text[i] === "\\") i++;
52
+ else if (text[i] === "$" && (text[i + 1] === "(" || text[i + 1] === "{")) { const end = matching(text, i + 1, nest + 1); if (end < 0) return -1; i = end - 1; }
53
+ }
54
+ if (i >= text.length) return -1;
55
+ continue;
56
+ }
57
+ if (c === "$" && (text[i + 1] === "(" || text[i + 1] === "{") && i + 1 !== at) { const end = matching(text, i + 1, nest + 1); if (end < 0) return -1; i = end - 1; continue; }
58
+ if (c === open) depth++;
59
+ else if (c === close && --depth === 0) return i + 1;
60
+ }
61
+ return -1;
62
+ }
63
+
64
+ /**
65
+ * The expansion that starts at `text[at]` (a `$` or a backtick): where it ends, the command texts it runs (`inners`: `$(...)` and a backtick
66
+ * run their text; `${...}` runs only the substitutions nested in its parameter text, never the text itself), and `drop` for a `$"` that only
67
+ * opens a quote. Undefined for a `$` that is a literal dollar. An expansion that never closes ends at the end of the text.
68
+ */
69
+ function expansion(text: string, at: number, quoted: boolean, nest = 0): { end: number; inners: string[]; drop?: true } | undefined {
70
+ if (text[at] === "`") {
71
+ for (let i = at + 1; i < text.length; i++) {
72
+ if (text[i] === "\\") { i++; continue; }
73
+ if (text[i] === "`") return { end: i + 1, inners: [text.slice(at + 1, i)] };
74
+ }
75
+ return { end: text.length, inners: [] };
76
+ }
77
+ const next = text[at + 1];
78
+ if (next === "(") {
79
+ const end = matching(text, at + 1);
80
+ return end < 0 ? { end: text.length, inners: [] } : { end, inners: [text.slice(at + 2, end - 1)] };
81
+ }
82
+ if (next === "{") { // parameter text is data: only the substitutions inside it run
83
+ const end = matching(text, at + 1);
84
+ const body = end < 0 ? text.slice(at + 2) : text.slice(at + 2, end - 1), inners: string[] = [];
85
+ for (let i = 0; i < body.length && nest < 4; i++) {
86
+ if (body[i] === "\\") { i++; continue; }
87
+ if (body[i] !== "$" && body[i] !== "`") continue;
88
+ const inner = expansion(body, i, false, nest + 1);
89
+ if (inner) { inners.push(...inner.inners); i = inner.end - 1; }
90
+ }
91
+ return { end: end < 0 ? text.length : end, inners };
92
+ }
93
+ if (next === "'") { // $'...': ANSI-C quoting, whose escapes build the word
94
+ for (let i = at + 2; i < text.length; i++) { if (text[i] === "\\") i++; else if (text[i] === "'") return { end: i + 1, inners: [] }; }
95
+ return { end: text.length, inners: [] };
96
+ }
97
+ if (next === '"') return quoted ? undefined : { end: at + 1, inners: [], drop: true };
98
+ if (next !== undefined && /[A-Za-z_]/.test(next)) { let end = at + 2; while (end < text.length && /[A-Za-z0-9_]/.test(text[end]!)) end++; return { end, inners: [] }; }
99
+ if (next !== undefined && /[0-9@*#?$!-]/.test(next)) return { end: at + 2, inners: [] };
100
+ return undefined;
101
+ }
102
+
103
+ /** Shell words, literal where they can be read. An expansion becomes an opaque word and the commands it runs are read too; unsupported syntax discards only its unfinished segment. */
104
+ function commands(text: string, nest = 0): Command[] | undefined {
33
105
  if (text.length > LIMIT || text.includes("\0") || !segmentLimit(text)) return;
34
106
  const result: Command[] = [];
35
107
  let words: string[] = [], outputs: string[] = [], word = "", active = false, quote = "";
@@ -40,7 +112,8 @@ function commands(text: string): Command[] | undefined {
40
112
  else if (!redirect) words.push(word);
41
113
  word = ""; active = false; redirect = false;
42
114
  };
43
- const finish = () => { flush(); if (words.length || outputs.length) result.push({ words, outputs }); words = []; outputs = []; };
115
+ let nested: Command[][] = [];
116
+ const finish = () => { flush(); if (words.length || outputs.length || nested.length) result.push({ words, outputs, ...(nested.length ? { nested } : {}) }); words = []; outputs = []; nested = []; };
44
117
  for (let i = 0; i < text.length; i++) {
45
118
  const c = text[i]!;
46
119
  if (quote === "'") { if (c === "'") quote = ""; else word += c; continue; }
@@ -51,7 +124,13 @@ function commands(text: string): Command[] | undefined {
51
124
  if (next !== "\n") word += next;
52
125
  active = true; continue;
53
126
  }
54
- if (c === "$" || c === "`") return result;
127
+ if (c === "$" || c === "`") {
128
+ const found = expansion(text, i, quote === '"');
129
+ if (!found) { word += c; active = true; continue; } // a lone `$` is a literal dollar
130
+ if (nest < 4) for (const text of found.inners) { const inner = commands(text, nest + 1); if (inner?.length) nested.push(inner); }
131
+ if (!found.drop) { word += OPAQUE; active = true; }
132
+ i = found.end - 1; continue;
133
+ }
55
134
  if (quote) { if (c === quote) quote = ""; else word += c; continue; }
56
135
  if (c === "'" || c === '"') { quote = c; active = true; continue; }
57
136
  if (c === "#" && !active) { while (i < text.length && text[i] !== "\n") i++; finish(); continue; }
@@ -97,6 +176,14 @@ function outside(target: string, root: string, cwd: string): boolean {
97
176
  const rel = relative(resolve(root), resolve(cwd, target));
98
177
  return rel === ".." || rel.startsWith("../") || isAbsolute(rel);
99
178
  }
179
+ /**
180
+ * A word that starts with an expansion may expand to a dash word (`$OPTS` as `-rf`), so a class that depends on a flag counts it as that flag
181
+ * (#762). Only the words before `--` can be options. A word that merely has an expansion later (`build/$X`, `--name=$X`) cannot start one.
182
+ */
183
+ function mayBeFlag(words: readonly string[]): boolean {
184
+ const end = words.indexOf("--");
185
+ return words.slice(0, end < 0 ? undefined : end).some(w => w.startsWith(OPAQUE));
186
+ }
100
187
  function flags(words: string[], letter: string): boolean {
101
188
  const end = words.indexOf("--");
102
189
  return words.slice(0, end < 0 ? undefined : end).some(w => /^-[^-]/.test(w) && w.slice(1).includes(letter));
@@ -130,7 +217,7 @@ function commandHeads(words: string[], values: readonly string[], booleans: read
130
217
  continue;
131
218
  }
132
219
  if (!uncertain) return [{ name: word, args: words.slice(i + 1) }];
133
- if (actions.includes(word)) heads.push({ name: word, args: words.slice(i + 1) });
220
+ if (actions.includes(word) || opaque(word)) heads.push({ name: word, args: words.slice(i + 1) });
134
221
  }
135
222
  return heads;
136
223
  }
@@ -153,6 +240,7 @@ function forcedClean(words: string[]): boolean {
153
240
  if (flag === "n") dry = true;
154
241
  }
155
242
  }
243
+ else if (word.startsWith(OPAQUE)) force = true; // an expansion may be `-f`; a literal dry run still wins (#762)
156
244
  }
157
245
  return force && !dry;
158
246
  }
@@ -182,12 +270,110 @@ function unwrapped(input: string[]): string[] {
182
270
  else if (flag.startsWith("--") && !flag.includes("=") && !["--ignore-environment", "--foreground", "--preserve-status", "--no-run-if-empty", "--verbose"].includes(flag)) return [];
183
271
  }
184
272
  if (name === "timeout") {
185
- if (!/^\d+(?:\.\d+)?[smhd]?$/.test(words.shift() ?? "")) return [];
273
+ const duration = words.shift() ?? "";
274
+ if (!/^\d+(?:\.\d+)?[smhd]?$/.test(duration) && !opaque(duration)) return [];
186
275
  }
187
276
  }
188
277
  return [];
189
278
  }
190
279
 
280
+ /**
281
+ * Whether every definition of a GraphQL document is a read: a `query` (or shorthand `{...}`) or a `fragment`. Strings and comments are skipped, so
282
+ * a keyword inside one counts for nothing. A `mutation`, a `subscription`, any other top-level word, unbalanced text or no operation is false:
283
+ * unknown is never a read.
284
+ */
285
+ function graphqlQueryOnly(text: string): boolean {
286
+ const skipString = (at: number): number => { // `at` is the opening quote; the index after the string, or -1 when it never ends
287
+ // A block string ends at its first `"""`: GraphQL's escaped `\"""` ends it later, so a lexer that ends early only turns a read into a write (the strict direction).
288
+ if (text.startsWith('"""', at)) { const end = text.indexOf('"""', at + 3); return end < 0 ? -1 : end + 3; }
289
+ // GraphQL's line terminators are LF, CR and CRLF, and none may appear in a single-line string: ending early is the strict direction.
290
+ for (let i = at + 1; i < text.length; i++) { if (text[i] === "\\") { i++; if (text[i] === "\n" || text[i] === "\r") return -1; } else if (text[i] === '"') return i + 1; else if (text[i] === "\n" || text[i] === "\r") return -1; }
291
+ return -1;
292
+ };
293
+ const skipComment = (at: number): number => { let i = at; while (i < text.length && text[i] !== "\n" && text[i] !== "\r") i++; return i; }; // a comment ends at LF or CR (so also CRLF), never later than GraphQL ends it
294
+ const closers: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
295
+ const skipBlock = (at: number): number => { // `at` is an opening bracket; the index after its matching close, or -1
296
+ const close = closers[text[at]!]!;
297
+ for (let i = at + 1; i < text.length; i++) {
298
+ const c = text[i]!;
299
+ if (c === '"') { const end = skipString(i); if (end < 0) return -1; i = end - 1; }
300
+ else if (c === "#") i = skipComment(i) - 1;
301
+ else if (c in closers) { const end = skipBlock(i); if (end < 0) return -1; i = end - 1; }
302
+ else if (c === "}" || c === ")" || c === "]") return c === close ? i + 1 : -1;
303
+ }
304
+ return -1;
305
+ };
306
+ let start = true, operations = 0; // `start`: the next token begins a top-level definition
307
+ for (let i = 0; i < text.length;) {
308
+ const c = text[i]!;
309
+ if (/[\s,\uFEFF]/.test(c)) { i++; continue; }
310
+ if (c === "#") { i = skipComment(i); continue; }
311
+ if (c === "{") { const end = skipBlock(i); if (end < 0) return false; if (start) operations++; i = end; start = true; continue; } // shorthand query, or the body that ends a definition
312
+ if (c === "(") { if (start) return false; const end = skipBlock(i); if (end < 0) return false; i = end; continue; }
313
+ const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(i, i + 256))?.[0];
314
+ if (name) {
315
+ if (start) { if (name === "query") operations++; else if (name !== "fragment") return false; start = false; }
316
+ i += name.length; continue;
317
+ }
318
+ if (!start && (c === "@" || c === ":")) { i++; continue; } // a directive, or `fragment X on Y`; nothing else is read at the top level
319
+ return false;
320
+ }
321
+ return start && operations > 0;
322
+ }
323
+
324
+ /**
325
+ * `gh api graphql` with fields (#756): undefined when the call does not name the graphql endpoint, true only for ONE literal `query` field whose
326
+ * every operation is a read. A query from a file or stdin (`@file`, `--input`), a second `query` field, an option this does not know,
327
+ * another positional and every unparsable document are false: the caller keeps them external-write.
328
+ */
329
+ function ghGraphqlRead(words: string[]): boolean | undefined {
330
+ const rest = words.slice(1); // `words` is `api ...`
331
+ if (!rest.includes("graphql")) return undefined;
332
+ const fieldOptions = ["-f", "-F", "--field", "--raw-field"];
333
+ const valued = ["-H", "--header", "--hostname", "-q", "--jq", "-t", "--template", "--cache", "-p", "--preview", "-X", "--method"];
334
+ const booleans = ["--paginate", "--slurp", "--silent", "-i", "--include", "--verbose"];
335
+ const positional: string[] = [], queries: string[] = [];
336
+ for (let i = 0; i < rest.length; i++) {
337
+ const word = rest[i]!;
338
+ if (word === "--" || !word.startsWith("-")) { positional.push(word); continue; }
339
+ let field: string | undefined;
340
+ if (fieldOptions.includes(word)) { field = rest[++i]; if (field === undefined) return false; }
341
+ else if (word.startsWith("--field=") || word.startsWith("--raw-field=")) field = word.slice(word.indexOf("=") + 1);
342
+ else if (/^-[fF][^=]/.test(word)) field = word.slice(2);
343
+ else if (valued.includes(word)) { if (++i >= rest.length) return false; }
344
+ else if (valued.some(key => key.startsWith("--") ? word.startsWith(`${key}=`) : /^-[HqtpX]./.test(word) && word.startsWith(key))) continue;
345
+ else if (!booleans.includes(word)) return false;
346
+ if (field !== undefined) {
347
+ const eq = field.indexOf("=");
348
+ if (eq < 0) return false;
349
+ if (field.slice(0, eq) === "query") queries.push(field.slice(eq + 1));
350
+ }
351
+ }
352
+ const method = option(rest, "-X", "--method");
353
+ if (method !== undefined && method.toUpperCase() !== "POST") return false;
354
+ const [query] = queries;
355
+ if (queries.some(opaque)) return false; // an expansion can close a brace or add an operation: unknown is never a read
356
+ if (positional.length !== 1 || positional[0] !== "graphql" || queries.length !== 1 || query === undefined || query.startsWith("@")) return false;
357
+ return graphqlQueryOnly(query);
358
+ }
359
+
360
+ /**
361
+ * `gh api` with a word the hub cannot read where an option may stand (#762): an option name with an expansion in it, or a later operand that
362
+ * starts with one (`gh api repos/o/r $FLAGS`, which can hold `-X POST`). The endpoint (the first operand) and the values of the known valued
363
+ * options are data. Method, field and query values are judged by the ordinary rules, where an unknown method is never GET.
364
+ */
365
+ function ghApiDynamic(words: string[]): boolean {
366
+ const valued = ["-H", "--header", "--hostname", "-q", "--jq", "-t", "--template", "--cache", "-p", "--preview", "-X", "--method", "-f", "-F", "--field", "--raw-field", "--input"];
367
+ let operands = 0;
368
+ for (let i = 1; i < words.length; i++) {
369
+ const word = words[i]!;
370
+ if (word === "--") return words.slice(i + 1).some((w, n) => opaque(w) && operands + n > 0);
371
+ if (word.startsWith("-")) { if (opaque(word)) return true; if (valued.includes(word)) i++; continue; }
372
+ if (operands++ > 0 && word.startsWith(OPAQUE)) return true;
373
+ }
374
+ return false;
375
+ }
376
+
191
377
  /** Scan curl options once: data, headers and other option values never become options. */
192
378
  function curlWrites(input: string[]): boolean {
193
379
  const longValues = new Set([
@@ -249,7 +435,7 @@ function sequence(parsed: Command[], root: string, initial: string[], found: Set
249
435
  const [program, ...args] = unwrapped(command.words);
250
436
  const target = args[0] === "--" ? args[1] : args[0];
251
437
  // Retain both outcomes: cd may fail, or be in a pipeline/conditional branch.
252
- if (program === "cd" && (!target || target.startsWith("~"))) {
438
+ if (program === "cd" && (!target || target.startsWith("~") || opaque(target))) { // an unknown directory is an unknown cwd
253
439
  if (!cwd.has("") && cwd.size >= 128) cwd.delete(cwd.values().next().value!);
254
440
  cwd.add("");
255
441
  }
@@ -262,6 +448,7 @@ function sequence(parsed: Command[], root: string, initial: string[], found: Set
262
448
  }
263
449
  }
264
450
  function classify(command: Command, root: string, cwd: string[], found: Set<ActionClass>, depth = 0): void {
451
+ for (const group of command.nested ?? []) sequence(group, root, cwd, found, depth); // a substitution's own cwd state; only its classes come back
265
452
  const words = unwrapped(command.words);
266
453
  const program = basename(words.shift() ?? "");
267
454
  if (["sh", "bash", "zsh"].includes(program) && depth < 2) {
@@ -270,7 +457,7 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
270
457
  const mode = shell.shift();
271
458
  if (mode && /^-[A-Za-z]*c[A-Za-z]*$/.test(mode)) {
272
459
  if (shell[0] === "--") shell.shift();
273
- const nested = shell[0] === undefined ? undefined : commands(shell[0]);
460
+ const nested = shell[0] === undefined ? undefined : commands(shell[0].replaceAll(OPAQUE, "$_"));
274
461
  if (nested) sequence(nested, root, cwd, found, depth + 1);
275
462
  }
276
463
  }
@@ -278,11 +465,12 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
278
465
  if (program === "git") {
279
466
  const heads = commandHeads(words, ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"],
280
467
  ["-P", "-p", "--no-pager", "--paginate", "--literal-pathspecs", "--bare", "--no-optional-locks", "--no-replace-objects"], ["push", "reset", "clean", "branch"]);
468
+ if (heads.some(head => opaque(head.name))) { found.add("publish"); found.add("destroy"); } // `git $CMD`: the strictest classes git reaches (#762)
281
469
  for (const { name: sub, args } of heads) {
282
470
  if (sub === "push") found.add("publish");
283
- if (sub === "reset" && args.some(w => w === "--hard" || w.startsWith("--hard="))) found.add("destroy");
471
+ if (sub === "reset" && (args.some(w => w === "--hard" || w.startsWith("--hard=")) || mayBeFlag(args))) found.add("destroy");
284
472
  if (sub === "clean" && forcedClean(args)) found.add("destroy");
285
- if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force"))) found.add("destroy");
473
+ if (sub === "branch" && (flags(args, "D") || args.includes("--delete") && args.includes("--force") || mayBeFlag(args))) found.add("destroy");
286
474
  }
287
475
  }
288
476
  if (["npm", "cargo", "pnpm", "yarn", "bun"].includes(program)) {
@@ -300,8 +488,9 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
300
488
  for (const head of heads) {
301
489
  const candidates = program === "yarn" && head.name === "npm" ? commandHeads(head.args, values, booleans, actions) : [head];
302
490
  for (const { name: sub, args } of candidates) {
491
+ if (opaque(sub) && program !== "bun") { found.add("publish"); found.add("install"); } // `npm $CMD`; bun runs a file by default, so its expansion is no subcommand (#762)
303
492
  if (sub === "publish") found.add("publish");
304
- const global = words.includes("--global") || flags(words, "g");
493
+ const global = words.includes("--global") || flags(words, "g") || mayBeFlag(words);
305
494
  if ((program === "cargo" && sub === "install") || (["install", "i", "add"].includes(sub) && global) || (program === "yarn" && sub === "global" && args[0] === "add")) found.add("install");
306
495
  }
307
496
  }
@@ -310,20 +499,25 @@ function classify(command: Command, root: string, cwd: string[], found: Set<Acti
310
499
  if (program === "gh") {
311
500
  if (words[0] === "release" && ["create", "upload", "edit", "delete"].includes(words[1] ?? "")) found.add("publish");
312
501
  if (["issue", "pr", "gist"].includes(words[0] ?? "") && ["create", "comment", "edit", "close", "reopen", "merge", "review", "delete"].includes(words[1] ?? "")) found.add("external-write");
313
- if (words[0] === "api") {
502
+ if (opaque(words[0])) { found.add("publish"); found.add("external-write"); } // `gh $GROUP ...`: the strictest classes gh reaches (#762)
503
+ else if (opaque(words[1]) && ["release"].includes(words[0] ?? "")) found.add("publish");
504
+ else if (opaque(words[1]) && ["issue", "pr", "gist", "run", "workflow", "repo", "secret", "variable", "label"].includes(words[0] ?? "")) found.add("external-write");
505
+ if (words[0] === "repo" && ["delete", "archive", "edit"].includes(words[1] ?? "") || ["secret", "variable"].includes(words[0] ?? "") && ["set", "delete"].includes(words[1] ?? "") || words[0] === "label" && ["create", "edit", "delete"].includes(words[1] ?? "")) found.add("external-write");
506
+ if (words[0] === "run" && ["rerun", "cancel", "delete"].includes(words[1] ?? "") || words[0] === "workflow" && ["run", "enable", "disable"].includes(words[1] ?? "")) found.add("external-write");
507
+ if (words[0] === "api" && ghGraphqlRead(words) !== true) { // a GraphQL document of queries only is a read (#756); everything else keeps the REST rule
314
508
  const method = option(words, "-X", "--method");
315
509
  const field = words.some(w => w === "-f" || w === "-F" || w.startsWith("-f") || w.startsWith("-F") || w === "--input" || w.startsWith("--input=") || w.startsWith("--field") || w.startsWith("--raw-field"));
316
- if (method ? !["GET", "HEAD"].includes(method.toUpperCase()) : field) found.add("external-write");
510
+ if (method ? !["GET", "HEAD"].includes(method.toUpperCase()) : field || ghApiDynamic(words)) found.add("external-write");
317
511
  }
318
512
  }
319
513
  if (program === "curl" && curlWrites(words)) found.add("external-write");
320
514
  if (["http", "https"].includes(program) && words.some(w => /^(?:POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)$/.test(w))) found.add("external-write");
321
515
  if (["mail", "mailx", "sendmail"].includes(program) && operands(words).length > 0) found.add("external-write");
322
516
  if (["slack", "telegram", "discord"].includes(program) && words.some(w => ["send", "post", "chat.postMessage"].includes(w))) found.add("external-write");
323
- if (program === "rm" && (flags(words, "r") || flags(words, "R") || words.includes("--recursive")) && operands(words).some(p => cwd.some(at => outside(p, root, at)))) found.add("destroy");
324
- if (["cp", "mv"].includes(program) && (flags(words, "f") || words.includes("--force"))) {
517
+ if (program === "rm" && (flags(words, "r") || flags(words, "R") || words.includes("--recursive") || operands(words).length > 1 && mayBeFlag(words)) && operands(words).some(p => opaque(p) /* an expansion may hold `..` or an absolute path (#762) */ || cwd.some(at => outside(p, root, at)))) found.add("destroy");
518
+ if (["cp", "mv"].includes(program) && (flags(words, "f") || words.includes("--force") || words.length > 2 && mayBeFlag(words.slice(0, -1)))) { // with two words the first cannot be a flag: `cp -f x` has no target
325
519
  const target = option(words, "-t", "--target-directory") ?? operands(words).at(-1);
326
- if (target && cwd.some(at => outside(target, root, at))) found.add("destroy");
520
+ if (target && (opaque(target) || cwd.some(at => outside(target, root, at)))) found.add("destroy");
327
521
  }
328
522
  }
329
523
 
@@ -0,0 +1,90 @@
1
+ import { ACTION_CLASSES, classifyAction, type ActionClass } from "./action-class.ts";
2
+ import { actionApprovalView, actionPolicyConfig, type ActionBudgets, type ActionPolicyConfig } from "./action-policy.ts";
3
+ import type { ActionGateVerdict } from "../adapters/acp.ts";
4
+
5
+ /**
6
+ * Where the always-ask policy (#636) meets the two surfaces that see a tool call before it runs: the Claude PreToolUse hook and ACP
7
+ * `session/request_permission`. The classifier (`action-class.ts`) and the budget ledger (`action-policy.ts`) decide; this file only
8
+ * connects them, and only ever adds an approval: an empty result keeps the native permission policy.
9
+ */
10
+
11
+ /** One native tool call as an adapter saw it: every name it is known by, the ACP tool kind when reported, and its arguments. */
12
+ export interface ActionCall { names: readonly string[]; kind?: string; input: unknown }
13
+
14
+ /** The classes of one call. ACP names a shell call `execute` by kind whatever its tool is called, so that kind is also read as a shell. */
15
+ export function classifyCall(call: ActionCall, root: string): ActionClass[] {
16
+ const names = new Set(call.names.filter(name => typeof name === "string" && name.length > 0));
17
+ if (call.kind === "execute") names.add("bash");
18
+ const found = new Set<ActionClass>();
19
+ for (const name of names) for (const key of classifyAction(name, call.input, root)) found.add(key);
20
+ return ACTION_CLASSES.filter(key => found.has(key));
21
+ }
22
+
23
+ /**
24
+ * The classes the Claude PreToolUse hook must `ask` about, in every permission mode. The budget never relaxes it: Claude's own dialog
25
+ * cannot carry a class-budget choice, so every classed Claude call asks (a count of zero approved calls, always). A handle-less request
26
+ * selects the classes without touching any session ledger.
27
+ */
28
+ export function claudeActionClasses(budgets: ActionBudgets, tool: string, input: unknown, root: string): ActionClass[] {
29
+ const classes = classifyAction(tool, input, root);
30
+ if (!classes.length) return [];
31
+ const decision = budgets.request(undefined, classes);
32
+ return decision.kind === "ask" ? decision.classes : [];
33
+ }
34
+
35
+ /** The command line an approval shows: the shell command when there is one, else the arguments. Bounded by the caller's screen. */
36
+ export function commandText(input: unknown): string {
37
+ let value: unknown = input;
38
+ if (typeof input === "string") { try { value = JSON.parse(input) as unknown; } catch { return input; } }
39
+ if (typeof value === "string") return value;
40
+ if (value && typeof value === "object" && !Array.isArray(value)) {
41
+ const record = value as Record<string, unknown>, command = record.command ?? record.cmd;
42
+ if (typeof command === "string") return command;
43
+ if (Array.isArray(command) && command.every(word => typeof word === "string")) return (command as string[]).join(" ");
44
+ if (Array.isArray(record.args) && record.args.every(word => typeof word === "string")) return ["git", ...(record.args as string[])].join(" ");
45
+ }
46
+ try { return JSON.stringify(value) ?? ""; } catch { return ""; }
47
+ }
48
+
49
+ export interface ActionGatePorts<T> {
50
+ budgets: ActionBudgets;
51
+ policy(): ActionPolicyConfig | undefined;
52
+ /** The peer's execution root: a delete outside it is classed. */
53
+ root(peer: string): string;
54
+ /** The task the peer is working on, for the approval line. */
55
+ task(peer: string): T | undefined;
56
+ /** The task's public view (`Tasks.publicView`): the only way a task title reaches an approval. */
57
+ publicView(task: T): Record<string, unknown>;
58
+ /** A private turn or an open PII task: the command is withheld. */
59
+ isPrivate(peer: string): boolean;
60
+ /** The PII screen for text (`Tasks.nameable`). */
61
+ nameable(text: string): boolean;
62
+ }
63
+
64
+ /**
65
+ * The ACP gate of one peer. It returns a verdict only when a person must be asked: a classed call with no session budget left. A call
66
+ * within an approved class budget, and every unclassed call, return undefined and follow the permission mode as before (the budget
67
+ * spends here, once per request).
68
+ */
69
+ export function actionGate<T>(ports: ActionGatePorts<T>, peer: string): (call: ActionCall, session: string, onPremToolTitle?: string) => ActionGateVerdict | undefined {
70
+ return (call, session, onPremToolTitle) => {
71
+ const classes = classifyCall(call, ports.root(peer));
72
+ if (!classes.length) return undefined;
73
+ const decision = ports.budgets.request(ports.budgets.bindSession(peer, session), classes);
74
+ if (decision.kind !== "ask") return undefined; // native, or within an approved class budget
75
+ // Only the host's local/Pi permit callback supplies this full title. Match the exact executed tool form before bypassing public redaction.
76
+ const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? call.input as Record<string, unknown> : undefined;
77
+ const expected = call.names.includes("bash") && typeof input?.command === "string" ? `bash: ${input.command}`
78
+ : call.names.includes("git") && Array.isArray(input?.args) && input.args.every(word => typeof word === "string") ? `git ${input.args.join(" ")}` : undefined;
79
+ const complete = expected !== undefined && expected === onPremToolTitle;
80
+ const view = onPremToolTitle === undefined ? actionApprovalView(decision.classes, commandText(call.input), ports.task(peer), {
81
+ publicView: task => ports.publicView(task), private: ports.isPrivate(peer), commandView: command => ports.nameable(command) ? command : undefined })
82
+ : { title: `Action approval (${decision.classes.join(", ")})\n${complete ? onPremToolTitle : "[complete command unavailable]"}`, complete };
83
+ const { title } = view;
84
+ if (!view.complete) return { title, denyOnly: true };
85
+ const challenge = decision.challenge, limits = actionPolicyConfig(ports.policy()).budgets;
86
+ // A budget of one is this call alone: the ordinary allow already says that, so no second choice is offered for it.
87
+ if (!challenge || !decision.classes.some(key => limits[key] > 1)) return { title };
88
+ return { title, budget: { label: decision.classes.map(key => `${key} x${limits[key]}`).join(", "), approve: () => ports.budgets.approve(challenge) } };
89
+ };
90
+ }
@@ -5,8 +5,7 @@ export interface ActionPolicyConfig { enabled: boolean; always_ask: ActionClass[
5
5
  const DEFAULT_BUDGETS: Record<ActionClass, number> = { publish: 1, destroy: 3, "external-write": 5, install: 2 };
6
6
  /** Defaults are host policy; overrides belong only in untracked config.local.json.
7
7
  * ponytail: startup adopts that local policy without a retained person confirmation.
8
- * Before adapter enforcement (#312/#313), bind weakened startup policy to person
9
- * confirmation or surface it as attention; slice 1 never enforces these grants.
8
+ * Status surfaces every weakening as attention; upgrade: retain a person-bound startup confirmation.
10
9
  */
11
10
  export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
12
11
  const fail = (): never => { throw new Error("action_policy: expected enabled, always_ask and class budgets (integers 0..1000)"); };
@@ -31,24 +30,25 @@ export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
31
30
  export interface ActionSessionBudget { readonly peer: string; readonly session: string }
32
31
  export interface ActionChallenge { readonly classes: readonly ActionClass[] }
33
32
  export type ActionDecision = { kind: "native" } | { kind: "ask"; classes: ActionClass[]; challenge?: ActionChallenge } | { kind: "budget"; classes: ActionClass[] };
34
- interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number> }
33
+ /** `grants` counts the class-budget approvals of this session and policy: a challenge remembers it, so a later approval can tell that another one landed first. */
34
+ interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number>; grants: Record<ActionClass, number> }
35
35
  const empty = (): Record<ActionClass, number> => ({ publish: 0, destroy: 0, "external-write": 0, install: 0 });
36
36
  /** Host-only ledger: session ids and policy come from current runtime ports, never a tool argument. */
37
37
  export class ActionBudgets {
38
38
  private sessions = new Map<string, Session>();
39
- private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[] }>();
39
+ private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[]; grants: Record<ActionClass, number> }>();
40
40
  constructor(private readonly config: () => ActionPolicyConfig | undefined, private readonly currentSession: (peer: string) => string | undefined) {}
41
41
  bindSession(peer: string, session: string): ActionSessionBudget | undefined {
42
42
  if (!session || this.currentSession(peer) !== session) return undefined;
43
43
  const previous = this.sessions.get(peer);
44
44
  if (previous?.handle.session === session) return previous.handle;
45
45
  const handle = Object.freeze({ peer, session });
46
- this.sessions.set(peer, { handle, policy: "", remaining: empty() });
46
+ this.sessions.set(peer, { handle, policy: "", remaining: empty(), grants: empty() });
47
47
  return handle;
48
48
  }
49
49
  revoke(peer: string): void { this.sessions.delete(peer); }
50
50
  policyChanged(): void {
51
- for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); }
51
+ for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); state.grants = empty(); }
52
52
  this.challenges = new WeakMap();
53
53
  }
54
54
  private current(handle: ActionSessionBudget | undefined): Session | undefined {
@@ -56,7 +56,7 @@ export class ActionBudgets {
56
56
  const state = this.sessions.get(handle.peer);
57
57
  if (state?.handle !== handle || this.currentSession(handle.peer) !== handle.session) return undefined;
58
58
  const policy = JSON.stringify(actionPolicyConfig(this.config()));
59
- if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); }
59
+ if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); state.grants = empty(); }
60
60
  return state;
61
61
  }
62
62
  private selected(actions: readonly ActionClass[]): ActionClass[] {
@@ -70,37 +70,60 @@ export class ActionBudgets {
70
70
  if (!state) return { kind: "ask", classes };
71
71
  if (classes.some(key => state.remaining[key] <= 0)) {
72
72
  const challenge = Object.freeze({ classes: Object.freeze([...classes]) });
73
- this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes] });
73
+ this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes], grants: { ...state.grants } });
74
74
  return { kind: "ask", classes, challenge };
75
75
  }
76
76
  for (const key of classes) state.remaining[key]--;
77
77
  return { kind: "budget", classes };
78
78
  }
79
- /** Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. */
79
+ /**
80
+ * Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. Requests run concurrently, so
81
+ * two challenges can be issued while a class is spent: the first approval opens the allowance (budget minus this call), and an approval of
82
+ * a challenge issued before it is a later call inside that allowance, which consumes one slot instead of opening another. With no slot
83
+ * left it is refused (the person asks again and sees a fresh challenge), so N approved calls is the most any budget lets through.
84
+ * Every class is checked before any is spent: a refusal changes nothing.
85
+ */
80
86
  approve(challenge: ActionChallenge | undefined): boolean {
81
87
  if (!challenge) return false;
82
88
  const grant = this.challenges.get(challenge); this.challenges.delete(challenge);
83
89
  if (!grant) return false;
84
90
  const state = this.current(grant.handle), config = actionPolicyConfig(this.config());
85
91
  if (!state || state.policy !== grant.policy) return false;
86
- for (const key of grant.classes) state.remaining[key] = Math.max(0, config.budgets[key] - 1);
92
+ const late = (key: ActionClass) => state.grants[key] !== grant.grants[key];
93
+ if (grant.classes.some(key => late(key) && state.remaining[key] <= 0)) return false;
94
+ for (const key of grant.classes) {
95
+ state.remaining[key] = late(key) ? state.remaining[key] - 1 : Math.max(0, config.budgets[key] - 1);
96
+ state.grants[key]++;
97
+ }
87
98
  return true;
88
99
  }
89
100
  /** Read-only projection: no session is created, rebound or reset by a status poll. */
90
- status(peer: string, enforced: boolean) {
101
+ status(peer: string, enforced: boolean, reason = "adapter integration pending") {
91
102
  const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
92
103
  const bound = !!state && this.currentSession(peer) === state.handle.session && state.policy === JSON.stringify(config);
104
+ const weakened = !config.enabled || ACTION_CLASSES.some(key => !config.always_ask.includes(key) || config.budgets[key] > DEFAULT_BUDGETS[key]);
105
+ const attention = weakened ? "; attention: machine-local policy weakens defaults; no retained startup confirmation" : "";
93
106
  return { enabled: config.enabled, alwaysAsk: [...config.always_ask], limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
94
- enforceable: enforced, note: !enforced ? `always-ask not enforceable: adapter integration pending${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled" };
107
+ enforceable: enforced, note: (!enforced ? `always-ask not enforceable: ${reason}${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled") + attention };
95
108
  }
96
109
  }
97
110
 
98
- /** Adapter integrations must pass the host's publicView and current command/private-turn screens. */
99
- export function actionApprovalText<T>(classes: readonly ActionClass[], command: string, task: T | undefined,
100
- ports: { publicView(task: T): Record<string, unknown>; private: boolean; commandView(command: string): string | undefined }): string {
111
+ type ActionApprovalPorts<T> = { publicView(task: T): Record<string, unknown>; private: boolean; commandView(command: string): string | undefined };
112
+
113
+ /** Public/native presentation: incomplete command views grant no approval, not even once. */
114
+ export function actionApprovalView<T>(classes: readonly ActionClass[], command: string, task: T | undefined,
115
+ ports: ActionApprovalPorts<T>): { title: string; complete: boolean } {
101
116
  const view = task === undefined ? undefined : ports.publicView(task);
102
117
  const title = typeof view?.title === "string" ? sanitize(view.title).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 160) : undefined;
103
118
  const shown = ports.private ? undefined : ports.commandView(command);
104
- const line = shown === undefined ? "[private command withheld]" : sanitize(shown).replace(/[\p{Cc}\p{Cf}]/gu, " ").slice(0, 512);
105
- return `Action approval (${ACTION_CLASSES.filter(key => classes.includes(key)).join(", ")})${title ? `; task ${title}` : ""}: ${line}`;
119
+ const full = shown === undefined ? undefined : Array.from(sanitize(shown).replace(/[\p{Cc}\p{Cf}]/gu, " "));
120
+ const marker = ` [cut, ${full?.length ?? 0} chars]`;
121
+ const line = full === undefined ? "[private command withheld]" : full.length > 512 ? full.slice(0, 512 - marker.length).join("") + marker : full.join("");
122
+ return { title: `Action approval (${ACTION_CLASSES.filter(key => classes.includes(key)).join(", ")})${title ? `; task ${title}` : ""}: ${line}`,
123
+ complete: shown === command && full !== undefined && full.length <= 512 && full.join("") === command };
124
+ }
125
+
126
+ /** Adapter integrations must pass the host's publicView and current command/private-turn screens. */
127
+ export function actionApprovalText<T>(classes: readonly ActionClass[], command: string, task: T | undefined, ports: ActionApprovalPorts<T>): string {
128
+ return actionApprovalView(classes, command, task, ports).title;
106
129
  }
package/src/hub/ask.ts CHANGED
@@ -104,7 +104,7 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
104
104
  return { id: `task #${t.id}`, kind: "task", text: `[${t.class}] ${t.state}, owner ${t.owner ?? "none"}, reviewer ${t.reviewer ?? "none"}: ${text}`.slice(0, ITEM_CHARS) };
105
105
  });
106
106
 
107
- // A question that carries PII is not sent to the memory worker, whose observer is a cloud model.
107
+ // A PII question never queries the shared memory view; local storage does not relax the hub privacy fence.
108
108
  const memoryRows = d.memory && !piiQuestion ? related(d.memory, d.project, question, 2).catch(() => []) : Promise.resolve([]);
109
109
  // The log carries message bodies and is written by agents too: a line with PII in it is treated like a PII task row.
110
110
  const matching = thisRunsLog(d.logFile).filter((l) => hit(l) > 0).slice(-12);
@@ -119,12 +119,24 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
119
119
  try { native = piiQuestion ? [] : (d.native?.(keys) ?? []).map((r) => ({ id: r.id, kind: "memory" as const, text: r.text.slice(0, ITEM_CHARS) })); } catch { /* fail-open */ }
120
120
  const memory: Evidence[] = (await memoryRows)
121
121
  .filter((r) => !r.title.startsWith(ASK_NOTE_TITLE))
122
- .map((r) => ({ id: `#${r.id}`, kind: "memory" as const, text: `${r.time} ${r.type} ${r.title}`.slice(0, ITEM_CHARS) }));
122
+ .map((r) => ({ id: typeof r.id === "string" ? r.id : `#${r.id}`, kind: "memory" as const, text: `${r.time} ${r.type} ${r.title}`.slice(0, ITEM_CHARS) }));
123
123
 
124
+ // Native briefs keep short ids; the client supplies full ids for the same rows. Resolve only a unique
125
+ // prefix before dedupe/budget accounting. An ambiguous short id stays separate: it identifies neither full row.
126
+ const fullMemoryIds = new Map<string, Set<string>>();
127
+ for (const row of [...native, ...memory]) if (/^mem:[0-9a-f]{32}$/.test(row.id)) {
128
+ const prefix = row.id.slice(0, 12), ids = fullMemoryIds.get(prefix) ?? new Set<string>();
129
+ ids.add(row.id); fullMemoryIds.set(prefix, ids);
130
+ }
131
+ const memoryId = (row: Evidence): string => {
132
+ const full = row.kind === "memory" && /^mem:[0-9a-f]{8}$/.test(row.id) ? fullMemoryIds.get(row.id) : undefined;
133
+ return full?.size === 1 ? full.values().next().value! : row.id;
134
+ };
124
135
  // Every item is capped, so one long row cannot empty the list; what does not fit is skipped, not everything after it.
125
136
  const evidence: Evidence[] = [];
126
137
  let size = 0;
127
- for (const e of [...taskRows, ...native, ...memory, ...logRows]) {
138
+ for (const row of [...taskRows, ...native, ...memory, ...logRows]) {
139
+ const e = { ...row, id: memoryId(row) };
128
140
  if (evidence.some((x) => x.id === e.id)) continue;
129
141
  if (size + e.text.length + e.id.length > EVIDENCE_CHARS) continue;
130
142
  size += e.text.length + e.id.length;
@@ -133,7 +145,7 @@ export async function gather(question: string, d: AskDeps): Promise<{ evidence:
133
145
  return { evidence, pii: piiQuestion || piiShown, onCampus: campus };
134
146
  }
135
147
 
136
- const ID = /^(task #\d+|#\d+|mem:[0-9a-f]{8}|log \d\d-\d\d \d\d:\d\d:\d\d\.\d{3})$/;
148
+ const ID = /^(task #\d+|#\d+|mem:(?:[0-9a-f]{8}|[0-9a-f]{32})|log \d\d-\d\d \d\d:\d\d:\d\d\.\d{3})$/;
137
149
 
138
150
  /** Every id the answer cites, grouped brackets included: `[#1, #2, task #3]`. */
139
151
  export function citedIds(answer: string): string[] {