@staix/agent-hub 0.12.22 → 0.12.24

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 (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. package/templates/roles/research/statistician.md +26 -0
@@ -1,8 +1,9 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readdirSync, rmSync, type Dirent } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { realPath } from "../hub/project.ts";
6
+ import { hubGitSync } from "../hub/git.ts";
6
7
  import { denyRegexes, hubWriteRegexes, sbplString } from "./deny.ts";
7
8
 
8
9
  const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
@@ -40,12 +41,47 @@ function developerDir(): string | undefined {
40
41
 
41
42
  /** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
42
43
  function externalGitDirs(root: string): string[] {
43
- const out = spawnSync("git", ["-C", root, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" });
44
+ // Runs in the hub process, outside the sandbox: the hardened helper keeps the repo's config from naming a program (#281).
45
+ const out = hubGitSync(["-C", root, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" });
44
46
  if (out.status !== 0) return [];
45
47
  const dirs = out.stdout.trim().split("\n").map((d) => resolve(root, d));
46
48
  return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
47
49
  }
48
50
 
51
+ /**
52
+ * The project's submodule git directories live under the common dir's `modules/` tree (the worktree case included:
53
+ * it is the main repo's `.git/modules`). Returns the modules root and every directory entry that must not be
54
+ * renamed, replaced, symlinked or recreated: each discovered git dir (a directory holding HEAD and config) and
55
+ * every component between it and the modules root (#281). Discovered in the hub process, outside the sandbox.
56
+ */
57
+ function moduleGitDirs(root: string): { root: string; entries: string[] } | undefined {
58
+ const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
59
+ if (out.status !== 0) return undefined;
60
+ const modulesRoot = join(resolve(root, out.stdout.trim()), "modules");
61
+ const dirs: string[] = [];
62
+ const walk = (dir: string, depth: number): void => {
63
+ if (depth > 8) return; // submodules do not nest deeper in practice; the regex rules still cover the names
64
+ let entries: Dirent[];
65
+ try {
66
+ entries = readdirSync(dir, { withFileTypes: true });
67
+ } catch {
68
+ return; // no modules tree (no submodules): nothing to discover
69
+ }
70
+ const names = new Set(entries.map((e) => e.name));
71
+ if (names.has("HEAD") && names.has("config")) {
72
+ dirs.push(dir);
73
+ const nested = join(dir, "modules");
74
+ if (names.has("modules")) walk(nested, depth + 1); // a submodule of a submodule
75
+ return;
76
+ }
77
+ for (const e of entries) if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
78
+ };
79
+ walk(modulesRoot, 0);
80
+ const entries = new Set<string>([modulesRoot]);
81
+ for (const d of dirs) for (let p = d; p.length > modulesRoot.length; p = dirname(p)) entries.add(p);
82
+ return { root: modulesRoot, entries: [...entries] };
83
+ }
84
+
49
85
  /** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
50
86
  const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt", "/private/etc", "/private/var/db/timezone", "/private/var/db/dyld"];
51
87
  /**
@@ -69,11 +105,12 @@ export type SandboxNetwork = boolean | { proxyPort: number };
69
105
  * toolchain and project directories, writing the project and a temp dir of their own. The allow-default profile of
70
106
  * 0.9 and earlier was removed in 0.12.0 (issue #83).
71
107
  */
72
- export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []): string {
108
+ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = [], readOnly: string[] = []): string {
73
109
  const home = homedir();
74
110
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
75
111
  const root = realPath(cwd);
76
112
  const gitDirs = externalGitDirs(root);
113
+ const moduleGit = moduleGitDirs(root);
77
114
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
78
115
  const dev = developerDir();
79
116
  const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
@@ -101,12 +138,13 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
101
138
  "(version 1)",
102
139
  ...start,
103
140
  "(deny file-write*)",
104
- `(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
141
+ ...(readOnly.length ? ["(allow file-write* (literal \"/dev/null\"))"] : [`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`]),
105
142
  // Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub. The .git and
106
143
  // .agenthub patterns tolerate the code points HFS+ ignores in every segment, the .git name itself is refused
107
- // at any depth (creation, rename, symlink, gitfile), and the external git dirs get folded hooks/config rules
108
- // (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
109
- `(deny file-write* ${hubWriteRegexes(root, gitDirs).join(" ")})`,
144
+ // at any depth (creation, rename, symlink, gitfile), a submodule git dir's config and hooks under
145
+ // .git/modules/ are refused at the depth submodules nest (#281), and the external git dirs get folded
146
+ // hooks/config rules (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
147
+ `(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit).join(" ")})`,
110
148
  `(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
111
149
  `(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
112
150
  // Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
@@ -121,14 +159,16 @@ export interface ExecResult {
121
159
  }
122
160
 
123
161
  /** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
124
- export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal }): Promise<ExecResult> {
162
+ export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal; readOnlyData?: boolean }): Promise<ExecResult> {
163
+ if (opts.readOnlyData && /\(allow file-write\* \(subpath /.test(opts.profile)) return Promise.resolve({ code: null, output: "error: data.readonly command profile contains a broad write grant" });
125
164
  if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
126
165
  if (opts.signal?.aborted) return Promise.resolve({ code: null, output: "error: command cancelled before execution" });
127
166
  return new Promise((resolve) => {
128
167
  // A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
129
168
  // an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
130
169
  const own = realPath(mkdtempSync(join(tmpdir(), "ahub-cmd-")));
131
- const sandbox = `${opts.profile}\n(allow file-read* file-write* process-exec (subpath ${q(own)}))`;
170
+ const sandbox = `${opts.profile}\n(allow file-read* ${opts.readOnlyData ? "" : "file-write* "}process-exec (subpath ${q(own)}))`;
171
+ // A path-only deny cannot contain hardlink aliases; data mode allows reads/stdout, never filesystem writes.
132
172
  const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...opts.env, TMPDIR: `${own}/` };
133
173
  // Own process group: a timeout has to take the grandchildren too, or they keep the pipes open and the project writable.
134
174
  const child = spawn(SANDBOX_EXEC, ["-p", sandbox, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
@@ -2,6 +2,7 @@ import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "nod
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
3
  import { realPath } from "../hub/project.ts";
4
4
  import { isHubWritePath } from "../hub/permission-mode.ts";
5
+ import { matchesPolicy } from "../hub/path-policy.ts";
5
6
  import { isDenied } from "./deny.ts";
6
7
  import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
7
8
 
@@ -17,6 +18,7 @@ const approvalError = (picked: ToolApproval, denied: string): string | undefined
17
18
  : picked === "aborted" ? "error: approval withdrawn because the turn ended; no operation was executed" : denied;
18
19
 
19
20
  export interface ToolContext {
21
+ readOnly?: string[];
20
22
  cwd: string;
21
23
  /** Extra denylist entries from config: substrings of the project-relative path. */
22
24
  deny: string[];
@@ -27,7 +29,7 @@ export interface ToolContext {
27
29
  /** Turn cancellation signal, including the execution-budget wall cap. */
28
30
  signal?: AbortSignal;
29
31
  /** Only true permits an effect; expiry and abort remain distinct from a person's denial. */
30
- permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver) => Promise<ToolApproval>;
32
+ permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string) => Promise<ToolApproval>;
31
33
  /** Trusted process status, independent of what its stdout says. */
32
34
  onProcessExit?: (code: number | null) => void;
33
35
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
@@ -44,7 +46,7 @@ const lexists = (p: string) => {
44
46
  };
45
47
 
46
48
  /** Absolute path inside cwd after resolving symlinks, or an Error. `write` also refuses .git and .agenthub. */
47
- export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string, mode: "read" | "write"): string {
49
+ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny" | "readOnly">, path: string, mode: "read" | "write"): string {
48
50
  const root = realPath(ctx.cwd);
49
51
  const abs = resolve(root, path);
50
52
  // Resolve symlinks on the deepest part that exists: a link pointing outside must not pass as an inside path.
@@ -64,6 +66,9 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string,
64
66
  // Not secret, but writing .git or .agenthub changes what runs outside the worker's control. Names are compared
65
67
  // folded: a new `.GIT/config` is `.git/config` to a case-insensitive disk, and git then runs what it names.
66
68
  if (mode === "write" && isHubWritePath(rel)) throw new Error(`${path} is not writable by the local worker`);
69
+ // Identity refusal is stable even when realPath names a protected hardlink alias.
70
+ if (mode === "write" && ctx.readOnly?.length && (lstatSync(real, { throwIfNoEntry: false })?.nlink ?? 1) > 1) throw new Error("hardlinked write target cannot be verified with data.readonly");
71
+ if (mode === "write" && matchesPolicy(root, path, ctx.readOnly ?? [])) throw new Error("target is protected by data.readonly");
67
72
  return real;
68
73
  }
69
74
 
@@ -142,7 +147,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
142
147
  let file = guardPath(ctx, String(a.path), "write");
143
148
  const approvedTarget = file;
144
149
  const content = String(a.content ?? "");
145
- const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file), "error: the user did not approve this write");
150
+ const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this write");
146
151
  if (refused) return refused;
147
152
  if (ctx.signal?.aborted) return "error: turn cancelled before write";
148
153
  file = guardPath(ctx, String(a.path), "write");
@@ -157,7 +162,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
157
162
  let text = readFileSync(file, "utf8");
158
163
  let count = text.split(String(a.old)).length - 1;
159
164
  if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
160
- const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file), "error: the user did not approve this edit");
165
+ const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this edit");
161
166
  if (refused) return refused;
162
167
  if (ctx.signal?.aborted) return "error: turn cancelled before edit";
163
168
  // Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
@@ -176,7 +181,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
176
181
  if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
177
182
  const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal), "error: the user did not approve this command");
178
183
  if (refused) return refused;
179
- const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
184
+ const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
180
185
  try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
181
186
  return `${res.output}\n(exit ${res.code})`;
182
187
  }
@@ -191,7 +196,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
191
196
  if (refused) return refused;
192
197
  }
193
198
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
194
- const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
199
+ const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
195
200
  try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
196
201
  return `${res.output}\n(exit ${res.code})`;
197
202
  }
@@ -27,33 +27,47 @@ export async function related(client: MemoryClient, project: string, query: stri
27
27
  return out;
28
28
  }
29
29
 
30
+ /** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
31
+ export interface NativeItem { id: string; line: string; kind?: string }
32
+ export type NativeBrief = (task: BriefTask) => NativeItem[];
33
+ export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
34
+
30
35
  /**
31
- * Task briefs: what memory already knows about a piece of work, handed over with it. Index first (search), then the
32
- * neighbourhood of the top hit (timeline); details stay in claude-mem for the receiver to fetch by id. A peer is never
33
- * shown the same observation twice in one hub run.
36
+ * Task briefs: what memory already knows about a piece of work, handed over with it. The hub's own store first (#258:
37
+ * entries one or two hops from the task's paths, symbols, issue and task, a linked fail first, superseded entries
38
+ * dropped), then claude-mem: index first (search), then the neighbourhood of the top hit (timeline); details stay in
39
+ * the store for the receiver to fetch by id. `brief_items` caps both together. A peer is never shown the same entry
40
+ * twice in one hub run.
34
41
  */
35
42
  export class Briefs {
36
- private readonly seen = new Map<PeerId, Set<number>>();
43
+ private readonly seen = new Map<PeerId, Set<string>>();
37
44
 
38
45
  constructor(
39
- private readonly client: MemoryClient,
46
+ private readonly client: MemoryClient | undefined,
40
47
  private readonly project: string,
41
48
  private readonly maxItems = 8,
49
+ private readonly native?: NativeBrief,
50
+ /** Counts of what a brief carried, for the research records (#258 AC11); never its text. */
51
+ private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
42
52
  ) {}
43
53
 
44
- async forTask(peer: PeerId, task: { title: string; refs: { paths?: string[] } }): Promise<string | undefined> {
45
- const found = await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" "));
46
- if (!found.length) return undefined;
47
- const seen = this.seen.get(peer) ?? new Set<number>();
54
+ async forTask(peer: PeerId, task: BriefTask): Promise<string | undefined> {
55
+ const seen = this.seen.get(peer) ?? new Set<string>();
48
56
  this.seen.set(peer, seen);
57
+ const own: NativeItem[] = [];
58
+ try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && own.length < this.maxItems) own.push(item); } catch { /* fail-open: the store is optional */ }
59
+ const found = this.client && own.length < this.maxItems ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
49
60
  const items: BriefItem[] = [];
50
61
  for (const item of found) {
51
- if (seen.has(item.id)) continue;
62
+ if (seen.has(`#${item.id}`)) continue;
52
63
  items.push(item);
53
- if (items.length === this.maxItems) break;
64
+ if (own.length + items.length === this.maxItems) break;
54
65
  }
55
- if (!items.length) return undefined;
56
- for (const item of items) seen.add(item.id);
57
- return ["Memory brief (claude-mem; fetch details by id if you need them):", ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
66
+ if (!own.length && !items.length) return undefined;
67
+ for (const item of own) seen.add(item.id);
68
+ for (const item of items) seen.add(`#${item.id}`);
69
+ if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
70
+ const header = own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; claude-mem by #id):" : "Memory brief (claude-mem; fetch details by id if you need them):";
71
+ return [header, ...own.map((i) => i.line), ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
58
72
  }
59
73
  }
@@ -0,0 +1,55 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
6
+
7
+ /** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
8
+ export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
9
+ /** Titles `ahub ask --remember` gives a model's answer (ask.ts ASK_NOTE_TITLE): never imported as a finding. */
10
+ const MODEL_ANSWER = "ahub ask (model answer)";
11
+
12
+ export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number; project: number } }
13
+
14
+ /**
15
+ * #258 phase 3: copy the hub's own notes from claude-mem (observations whose metadata names a `peer`: `hub_remember`
16
+ * and the hub's task notes) into long-term hub memory as `source: claude-mem` under the hub memory key `project`, for
17
+ * the projects in `chain` (claude-mem files them under the repository's basename). Model answers, PII-pattern text and secret-looking text are skipped.
18
+ * Idempotent: an observation always maps to the same entry.
19
+ */
20
+ export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number }): ImportResult {
21
+ const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db");
22
+ if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
23
+ const db = new Database(file, { readonly: true });
24
+ db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
25
+ const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0, project: 0 } };
26
+ try {
27
+ const rows = db.query("SELECT id, project, title, text, narrative, created_at_epoch, metadata FROM observations WHERE metadata LIKE '%\"peer\"%' ORDER BY id LIMIT ?")
28
+ .all(opts.limit ?? 10_000) as { id: number; project: string | null; title: string | null; text: string | null; narrative: string | null; created_at_epoch: number | null; metadata: string | null }[];
29
+ // The hub files its claude-mem notes under the repository's own name (the chain's last); the names above it are other
30
+ // projects (a superproject) whose notes and privacy policy are not this one's. ponytail: claude-mem keys projects by
31
+ // basename, so two unrelated repositories with one name still share it.
32
+ const project = opts.project, names = new Set(opts.chain.slice(-1));
33
+ for (const r of rows) {
34
+ result.read++;
35
+ let meta: Record<string, unknown>;
36
+ try { meta = JSON.parse(r.metadata ?? "{}"); } catch { continue; }
37
+ if (typeof meta.peer !== "string" || !meta.peer) continue;
38
+ if (!r.project || !names.has(r.project)) { result.skipped.project++; continue; }
39
+ const title = r.title ?? "", text = (r.text ?? r.narrative ?? "").trim();
40
+ if (!text) continue;
41
+ if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++; continue; }
42
+ if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++; continue; }
43
+ if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++; continue; }
44
+ const kind: Kind = KINDS.includes(meta.kind as Kind) ? meta.kind as Kind : "finding";
45
+ const at = new Date(r.created_at_epoch ?? 0).toISOString();
46
+ const { added } = store.importNote({ op: "note", project, scope: "project", tier: "long", kind, peer: meta.peer.slice(0, 32), ...(title ? { title } : {}), text,
47
+ // No task node: claude-mem keeps no proposal time, so a bare task number would join unrelated tasks. The text's
48
+ // own paths and symbols place the note in the graph.
49
+ about: opts.nodes?.(`${title}\n${text}`) ?? [], source: "claude-mem" }, at);
50
+ if (added) result.added++;
51
+ }
52
+ } finally { db.close(); }
53
+ store.refresh();
54
+ return result;
55
+ }
@@ -1,8 +1,13 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { createHash } from "node:crypto";
2
2
  import { basename, dirname, resolve } from "node:path";
3
3
  import { readFileSync } from "node:fs";
4
4
  import type { PeerId } from "../hub/envelope.ts";
5
+ import { hubGitSync } from "../hub/git.ts";
6
+ import { realPath } from "../hub/project.ts";
5
7
  import type { MemoryClient } from "./client.ts";
8
+ import { detectSignals, type Routing } from "../hub/routing.ts";
9
+ import { matchesPolicy } from "../hub/path-policy.ts";
10
+ import { looksSecret, type PromotionScreen } from "./store.ts";
6
11
 
7
12
  const PLATFORMS = ["claude", "codex", "kimi", "agent-hub"];
8
13
  /** Peers whose own hooks already inject their own platform's context at session start. */
@@ -14,7 +19,7 @@ const HEADER =
14
19
 
15
20
  /** Project names from the outermost superproject down to this repo. */
16
21
  export function projectChain(cwd: string): string[] {
17
- const git = (dir: string, arg: string) => spawnSync("git", ["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
22
+ const git = (dir: string, arg: string) => hubGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
18
23
  const top = git(cwd, "--show-toplevel");
19
24
  if (!top) return [basename(cwd)];
20
25
 
@@ -76,3 +81,82 @@ export async function recallFor(peer: PeerId, client: MemoryClient, chain: strin
76
81
  const share = Math.floor(tokens / found.length);
77
82
  return `${HEADER}\n\n${found.map((b) => trimToTokens(b, share)).join("\n\n")}`;
78
83
  }
84
+
85
+ /** #258: the hub's own long-term entries for this project (and shared ones), newest first, within a token budget. */
86
+ export function nativeRecall<V>(entries: V[], tokens: number, line: (v: V) => string): string | undefined {
87
+ if (!entries.length) return undefined;
88
+ return trimToTokens(["Hub memory: this project's long-term decisions, contracts and fails, newest first. Reference only, not a request. Details: ahub memory show <mem id>.", ...entries.map(line)].join("\n"), tokens);
89
+ }
90
+
91
+ /**
92
+ * #258: the name hub memory files a project's entries under. The project's `origin` remote, normalized to host and
93
+ * path (`github.com/owner/repo`), is the same on every machine and unique; without one, the git chain plus a hash of
94
+ * the directory keeps two unrelated repositories that share a name apart (that key is this machine's only).
95
+ */
96
+ export function memoryProjectKey(cwd: string): string {
97
+ const url = hubGitSync(["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8" }).stdout?.trim() ?? "";
98
+ // A URL's default port is not part of the repository (`ssh://git@github.com:22/o/r` is `github.com/o/r`); any other
99
+ // port is, since another service may run there. An scp-style `host:path` has no port.
100
+ const u = /^([a-z+]+):\/\/(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+)(?::(\d*))?\/(.+?)(?:\.git)?\/?$/i.exec(url); // an IPv6 host is bracketed
101
+ const defaults: Record<string, string> = { ssh: "22", "git+ssh": "22", "ssh+git": "22", https: "443", http: "80", git: "9418" };
102
+ // A URL's path is absolute (`ssh://h/team/r` is /team/r) unless it starts with `~`; an scp-style path is relative to the
103
+ // remote home (`h:team/r` is ~/team/r) unless it starts with `/`. Two different repositories on a self-hosted server.
104
+ const scp = u || url.includes("://") ? null : /^(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+):(.+?)(?:\.git)?\/?$/.exec(url); // a file:// remote has no host; an IPv6 host is bracketed
105
+ const m = u ? [!u[3] || u[3] === defaults[u[1]!.toLowerCase()] ? u[2]! : `${u[2]}:${u[3]}`, u[4]!] as const
106
+ : scp ? [scp[1]!, scp[2]!.startsWith("/") ? scp[2]!.replace(/^\/+/, "") : scp[2]!.startsWith("~") ? scp[2]! : `~/${scp[2]}`] as const : undefined;
107
+ if (m && m[1]) {
108
+ const host = m[0].toLowerCase();
109
+ // GitHub serves one repository under every form, and its owner and repository names ignore case.
110
+ return host === "github.com" ? `${host}/${m[1].replace(/^~\/?/, "").toLowerCase()}` : `${host}/${m[1]}`;
111
+ }
112
+ let real = cwd;
113
+ try { real = realPath(cwd); } catch { /* as given */ }
114
+ return `${projectChain(cwd).join("/")}@${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;
115
+ }
116
+
117
+ /**
118
+ * Every path-like word of a text (`dir/file`, `file.ext`), whether or not it exists or a denylist hides it from the
119
+ * graph: what a private-path policy is checked against.
120
+ */
121
+ export const pathCandidates = (text: string): string[] => [...new Set(text.normalize("NFC").match(/\/?[\p{L}\p{M}\p{N}_.~-]+(?:\/[\p{L}\p{M}\p{N}_.~-]+)+|[\p{L}\p{M}\p{N}_-]+\.\p{L}[\p{L}\p{M}\p{N}_]{0,9}/gu) ?? [])]; // any script, combining marks included; an absolute path keeps its slash
122
+
123
+ /**
124
+ * Whether a note is about a path the `pii.paths` policy makes private: a stored node's path, a path-like word of its
125
+ * text, or, for a policy entry with no glob that a word split cannot find (a space in it), that entry written out.
126
+ */
127
+ export function mentionsPrivatePath(text: string, about: string[], policy: string[], cwd: string): boolean {
128
+ if (!policy.length) return false;
129
+ const nfc = (v: string) => v.normalize("NFC"), body = nfc(text), entries = policy.map(nfc);
130
+ const paths = [...about.map(nodePath), ...pathCandidates(body)].filter((p): p is string => p !== undefined).map(nfc);
131
+ if (paths.some((p) => matchesPolicy(cwd, p, entries, true))) return true;
132
+ // The entry's literal part (up to its first glob character) written out: catches a path a word split breaks (a space).
133
+ return entries.some((entry) => { const lit = entry.replace(/^\.\//, "").split(/[*?[\]{}]/)[0]!.replace(/\/+$/, ""); return /[/\s]/.test(lit) && body.includes(lit); });
134
+ }
135
+
136
+ /** The file a graph node names: `path:<p>` and `symbol:<name>@<p>`; nothing for other nodes. */
137
+ export const nodePath = (n: string): string | undefined => n.startsWith("path:") ? n.slice(5) : n.startsWith("symbol:") && n.includes("@") ? n.slice(n.lastIndexOf("@") + 1) : undefined;
138
+
139
+ /**
140
+ * Notes whose title, text, nodes, task key or project key match the PII patterns of `routing`, or that are about a path
141
+ * the project's `pii.paths` (`policy`) makes private, left out: what may go to a model.
142
+ */
143
+ export function withoutPii<T extends { title: string | null; text: string; about?: string[]; task?: string | null; project?: string }>(notes: T[], routing: Routing, cwd: string, policy: string[] = []): T[] {
144
+ // A task key's `#<id>@<created ms>` is the hub's own numbers, not note content: a digit-run pattern must not withhold a
145
+ // clean note for it. The project part of the key stays screened.
146
+ const keyText = (k: string) => k.replace(/#\d+(?:@\d+)?$/, "");
147
+ return notes.filter((v) => !mentionsPrivatePath(`${v.title ?? ""}\n${v.text}`, v.about ?? [], policy, cwd) && !detectSignals({ title: v.title ?? "",
148
+ detail: [v.text, ...(v.about ?? []).map((n) => n.startsWith("task:") ? keyText(n) : n), keyText(v.task ?? ""), v.project ?? ""].join("\n"), refs: {} }, routing, cwd).includes("pii"));
149
+ }
150
+
151
+ /**
152
+ * The promotion screen for one project: another project's notes are left to that project's own processes; this one's
153
+ * move only when no secret, PII pattern or private path (`policy()`, read when asked) is in them.
154
+ */
155
+ export function promotionScreen(project: string, cwd: string, routing: () => Routing, policy: () => string[], taskPaths: (task: string) => string[] = () => []): PromotionScreen {
156
+ return (v) => {
157
+ if (v.project !== project) return "skip";
158
+ // The task's current paths too, with no denylist: a path the note's own nodes leave out is still checked.
159
+ const screened = { ...v, about: [...v.about, ...(v.task ? taskPaths(v.task) : []).map((p) => `path:${p}`)] };
160
+ return looksSecret(`${v.title ?? ""}\n${v.text}`) || !withoutPii([screened], routing(), cwd, policy()).length ? "keep" : "promote";
161
+ };
162
+ }