@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
@@ -84,6 +84,9 @@ async function available(base: number): Promise<boolean> {
84
84
  }
85
85
  }
86
86
 
87
+ /** The signals the project daemon stops on (see `runProjectDaemon`). */
88
+ export const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"] as const;
89
+
87
90
  /** Called only in the detached daemon process. A competing starter exits without touching state. */
88
91
  export async function runProjectDaemon(project: Project, unattended = false): Promise<void> {
89
92
  assertLifecycleAvailable();
@@ -92,8 +95,8 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
92
95
  let claimed = false;
93
96
  let daemon: Awaited<ReturnType<typeof startDaemon>> | undefined;
94
97
  // ponytail: shutdown is bounded as a whole, not per peer; a peer whose stop never settles
95
- // escapes only through this process-level deadline (issue #56). 15 s leaves the claude-mem
96
- // session-end work awaited in stop() its chance. Upgrade path: per-peer stop deadlines.
98
+ // escapes only through this process-level deadline (issue #56). 15 s leaves the amem
99
+ // SessionEnd capture row each peer's stop() awaits (`Capture.end`) its chance. Upgrade path: per-peer stop deadlines.
97
100
  const SHUTDOWN_DEADLINE_MS = 15_000;
98
101
  let forcedExit: ReturnType<typeof setTimeout> | undefined;
99
102
  const onShutdownStart = () => {
@@ -128,10 +131,12 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
128
131
  }
129
132
  if (!daemon) throw new Error("no free hub port range after 100 attempts");
130
133
  const stop = () => { void daemon!.stop().catch((error) => console.error(`shutdown incomplete: ${(error as Error).message}`)); };
131
- process.on("SIGINT", stop);
132
- process.on("SIGTERM", stop);
134
+ // Every signal the hub exits on goes through stop(), never the default action: a default action ends the process with no `exit` event and no stop(), so memory sync's git (which
135
+ // agent-mem 0.7.0 runs in its own process group, stopped only through the daemon's `syncStop` signal) would outlive the hub holding the memory repository. SIGHUP is a closing
136
+ // terminal, SIGQUIT is Ctrl-\; a handler also keeps SIGQUIT from its default core dump (agent-mem #435: Bun honours it, and a core file of the whole image delays the exit).
137
+ for (const name of STOP_SIGNALS) process.on(name, stop);
133
138
  try { await daemon.stopped; }
134
- finally { if (forcedExit) clearTimeout(forcedExit); process.off("SIGINT", stop); process.off("SIGTERM", stop); }
139
+ finally { if (forcedExit) clearTimeout(forcedExit); for (const name of STOP_SIGNALS) process.off(name, stop); }
135
140
  } finally {
136
141
  if (claimed) registry.release(project.id, instanceId);
137
142
  registry.close();
@@ -155,7 +155,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
155
155
  return { ok: true };
156
156
  }
157
157
  // The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
158
- if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
158
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
159
159
  client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
160
160
  const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
161
161
  let nominalMs = 10_000;
@@ -0,0 +1,44 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { hubGitSync } from "./git.ts";
4
+ import { realPath } from "./project.ts";
5
+ export interface MemoryCodeHead { head: string; current(): string | undefined }
6
+ const SHA = /^[a-f0-9]{40}$/;
7
+ function text(file: string, cap: number): string | null {
8
+ let fd: number;
9
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
10
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
11
+ try {
12
+ const before = fstatSync(fd); if (!before.isFile() || before.size > cap) throw Error();
13
+ const bytes = Buffer.alloc(cap + 1), count = readSync(fd, bytes, 0, bytes.length, 0), after = fstatSync(fd), named = lstatSync(file);
14
+ if (count !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || named.ino !== before.ino || named.dev !== before.dev || named.isSymbolicLink()) throw Error();
15
+ return bytes.subarray(0, count).toString("utf8").trim();
16
+ } finally { closeSync(fd); }
17
+ }
18
+ /** One hardened Git probe per admitted derive; cache validation reads bounded Git-owned HEAD/ref files, never launches Git. */
19
+ export function readMemoryCodeHead(repo: string): MemoryCodeHead | undefined {
20
+ try {
21
+ const found = hubGitSync(["-C", repo, "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "HEAD"], { encoding: "utf8", timeout: 250 });
22
+ const lines = found.stdout.trim().split("\n");
23
+ if (found.status !== 0 || lines.length !== 3 || !SHA.test(lines[2]!)) return;
24
+ const git = realPath(lines[0]!), common = realPath(lines[1]!), marker = join(repo, ".git"), markerStat = lstatSync(marker);
25
+ if (markerStat.isSymbolicLink()) return;
26
+ const binding = markerStat.isDirectory() ? `${markerStat.dev}:${markerStat.ino}` : text(marker, 4096);
27
+ const headFile = join(git, "HEAD"), initial = text(headFile, 1024); if (!initial) return;
28
+ const branch = initial.startsWith("ref: ") ? initial.slice(5) : undefined;
29
+ if (branch && (!branch.startsWith("refs/heads/") || /[\\\p{Cc}\p{Cf}]/u.test(branch) || branch.split("/").some(part => !part || part === "." || part === ".."))) return;
30
+ const current = (): string | undefined => {
31
+ try {
32
+ const now = lstatSync(marker); if (now.isSymbolicLink() || (now.isDirectory() ? `${now.dev}:${now.ino}` : text(marker, 4096)) !== binding || text(headFile, 1024) !== initial) return;
33
+ if (!branch) return SHA.test(initial) ? initial : undefined;
34
+ const loose = text(join(common, branch), 128);
35
+ if (loose !== null) return SHA.test(loose) ? loose : undefined;
36
+ // ponytail: very large packed-ref inventories remain unknown; use a bounded native ref service if this ceiling matters.
37
+ const packed = text(join(common, "packed-refs"), 1024 * 1024);
38
+ const matches = (packed ?? "").split("\n").filter(line => line.slice(41) === branch && SHA.test(line.slice(0, 40)) && line[40] === " ");
39
+ return matches.length === 1 ? matches[0]!.slice(0, 40) : undefined;
40
+ } catch { return undefined; }
41
+ };
42
+ return current() === lines[2] ? { head: lines[2]!, current } : undefined;
43
+ } catch { return undefined; }
44
+ }
@@ -0,0 +1,119 @@
1
+ import type { NativeItem } from "../memory/brief.ts";
2
+ import { unindexedSymbolSyntax, type CodeFactResult, type CodeSymbol } from "./code-facts.ts";
3
+ import { canonicalMemorySymbolNode, MEMORY_SYMBOL_START_BUDGET_MS, type MemorySymbolContext } from "./memory-symbols.ts";
4
+
5
+ export const MEMORY_CODE_SEEDS = 4, MEMORY_CODE_NEIGHBOURS = 32, MEMORY_CODE_EDGES = 64;
6
+ const NOTE_LIMIT = 16, NOTE_SCAN_LIMIT = 128, SHA = /^[a-f0-9]{40}$/;
7
+ export interface MemoryCodeNode { id: string; type: "symbol"; label: string; derived: true; head: string }
8
+ export interface MemoryCodeEdge { from: string; to: string; rel: "calls"; derived: true; head: string }
9
+ export interface MemoryCodeOverlay {
10
+ state: "known" | "unknown";
11
+ head?: string;
12
+ nodes: MemoryCodeNode[];
13
+ edges: MemoryCodeEdge[];
14
+ notes: NativeItem[];
15
+ queried: number;
16
+ unknown: number;
17
+ truncated: boolean;
18
+ countsFloor: boolean;
19
+ }
20
+ export interface CodeNeighbourhood { callers: CodeSymbol[]; callees: CodeSymbol[]; hiddenAmbiguous: number; countsFloor: boolean }
21
+ export interface MemoryCodeOverlayOptions {
22
+ context(): Omit<MemorySymbolContext, "allowed">;
23
+ head(): string | undefined;
24
+ /** Current binding, invocation, repository and task provenance, checked across every await. */
25
+ valid(): boolean;
26
+ private?(): boolean;
27
+ publicMetadataText(text: string): boolean;
28
+ neighbours(repo: string, head: string, selector: string, depth: 1): Promise<CodeFactResult<CodeNeighbourhood>>;
29
+ /** Fully screened current notes only; no authoritative memory/candidate writes. */
30
+ related?(nodes: readonly string[], limit: number): readonly NativeItem[];
31
+ now?: () => number;
32
+ }
33
+ export function unavailableMemoryCodeOverlay(): MemoryCodeOverlay {
34
+ return { state: "unknown", nodes: [], edges: [], notes: [], queried: 0, unknown: 0, truncated: false, countsFloor: false };
35
+ }
36
+
37
+ /** One read-time call hop, distinct from the stored memory graph. Never persists nodes, edges or notes. */
38
+ export async function deriveMemoryCodeOverlay(starts: readonly string[], options: MemoryCodeOverlayOptions): Promise<MemoryCodeOverlay> {
39
+ const out = unavailableMemoryCodeOverlay(), now = options.now ?? performance.now.bind(performance);
40
+ const deadline = now() + MEMORY_SYMBOL_START_BUDGET_MS;
41
+ try {
42
+ const initial = options.context(), head = options.head();
43
+ const valid = () => {
44
+ const current = options.context();
45
+ return !options.private?.() && options.valid() && options.head() === head
46
+ && current.cwd === initial.cwd && current.project === initial.project && current.repo === initial.repo;
47
+ };
48
+ if (!head || !SHA.test(head) || !valid()) return out;
49
+ out.head = head; out.state = "known"; out.truncated = starts.length > MEMORY_CODE_SEEDS;
50
+ const canonical = (raw: string) => canonicalMemorySymbolNode(raw, { ...options.context(), allowed: options.publicMetadataText });
51
+ const nodes = new Map<string, MemoryCodeNode>(), edges = new Map<string, MemoryCodeEdge>(), neighbours = new Set<string>(), seen = new Set<string>();
52
+ const node = (id: string): MemoryCodeNode => ({ id, type: "symbol", label: id.slice(7), derived: true, head });
53
+ for (const raw of starts.slice(0, MEMORY_CODE_SEEDS)) {
54
+ if (!valid()) return { ...unavailableMemoryCodeOverlay(), queried: out.queried, truncated: out.truncated };
55
+ const seed = canonical(raw);
56
+ if (!seed) { out.unknown++; continue; }
57
+ const at = seed.lastIndexOf("@"), name = seed.slice(7, at), path = seed.slice(at + 1);
58
+ // The CLI's file:name selector cannot certify a colon-bearing file or unsupported host name syntax.
59
+ if (path.includes(":") || unindexedSymbolSyntax(name)) { out.unknown++; continue; }
60
+ if (seen.has(seed)) continue;
61
+ seen.add(seed);
62
+ if (now() >= deadline) { out.unknown++; out.truncated = true; continue; }
63
+ try {
64
+ out.queried++;
65
+ const answer = await options.neighbours(initial.cwd, head, `${path}:${name}`, 1);
66
+ if (!valid()) return { ...unavailableMemoryCodeOverlay(), queried: out.queried, truncated: out.truncated };
67
+ if (canonical(raw) !== seed) { out.unknown++; continue; }
68
+ if (answer.state !== "known") { out.unknown++; continue; }
69
+ const facts = answer.value;
70
+ if (!Array.isArray(facts.callers) || !Array.isArray(facts.callees) || !Number.isSafeInteger(facts.hiddenAmbiguous) || facts.hiddenAmbiguous < 0 || typeof facts.countsFloor !== "boolean") { out.unknown++; continue; }
71
+ nodes.set(seed, node(seed)); out.countsFloor ||= facts.countsFloor;
72
+ if (facts.hiddenAmbiguous) out.unknown++;
73
+ for (const [items, incoming] of [[facts.callers, true], [facts.callees, false]] as const) {
74
+ if (items.length > 256) out.truncated = true;
75
+ for (const item of items.slice(0, 256)) {
76
+ if (item.ambiguous !== false) { out.unknown++; continue; }
77
+ const id = canonical(`symbol:${item.name}@${item.path}`);
78
+ if (!id) { out.unknown++; continue; }
79
+ if (id === seed) continue;
80
+ const from = incoming ? id : seed, to = incoming ? seed : id, key = JSON.stringify([from, to]);
81
+ if (edges.has(key)) continue;
82
+ if (edges.size >= MEMORY_CODE_EDGES || (!neighbours.has(id) && neighbours.size >= MEMORY_CODE_NEIGHBOURS)) { out.truncated = true; continue; }
83
+ neighbours.add(id); nodes.set(id, node(id)); edges.set(key, { from, to, rel: "calls", derived: true, head });
84
+ }
85
+ }
86
+ } catch { out.unknown++; }
87
+ }
88
+ if (!valid()) return { ...unavailableMemoryCodeOverlay(), queried: out.queried, truncated: out.truncated };
89
+ // Later queries can tighten policy for an earlier result. Reproject everything synchronously under current policy.
90
+ const kept = new Set([...nodes.keys()].filter(id => canonical(id) === id));
91
+ if (kept.size !== nodes.size) out.unknown++;
92
+ out.edges = [...edges.values()].filter(edge => kept.has(edge.from) && kept.has(edge.to));
93
+ const connected = new Set(out.edges.flatMap(edge => [edge.from, edge.to]));
94
+ out.nodes = [...nodes.values()].filter(value => kept.has(value.id) && (connected.has(value.id) || seen.has(value.id)));
95
+ const neighbourIds = [...neighbours].filter(id => kept.has(id) && connected.has(id));
96
+ if (options.related && neighbourIds.length) {
97
+ try {
98
+ const rows = options.related(neighbourIds, NOTE_SCAN_LIMIT);
99
+ if (rows.length > NOTE_SCAN_LIMIT) out.truncated = true;
100
+ const safe = rows.slice(0, NOTE_SCAN_LIMIT).filter(item => /^mem:[a-f0-9]{32}$/.test(item.id) && Buffer.byteLength(item.line) <= 1024
101
+ && item.about && item.about.length <= 64 && item.about.some(id => neighbourIds.includes(id))
102
+ && [item.id, item.line, item.kind ?? "", ...item.about].every(options.publicMetadataText));
103
+ const ordered = [...new Map(safe.map(item => [item.id, item])).values()].sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || a.id.localeCompare(b.id));
104
+ out.truncated ||= ordered.length > NOTE_LIMIT;
105
+ out.notes = ordered.slice(0, NOTE_LIMIT).map(item => ({ ...item, about: [...item.about!] }));
106
+ } catch { out.unknown++; }
107
+ }
108
+ if (!valid()) return { ...unavailableMemoryCodeOverlay(), queried: out.queried, truncated: out.truncated };
109
+ const finalIds = new Set(out.nodes.filter(value => canonical(value.id) === value.id).map(value => value.id));
110
+ if (finalIds.size !== out.nodes.length) out.unknown++;
111
+ out.edges = out.edges.filter(edge => finalIds.has(edge.from) && finalIds.has(edge.to));
112
+ const finalConnected = new Set(out.edges.flatMap(edge => [edge.from, edge.to]));
113
+ out.nodes = out.nodes.filter(value => finalIds.has(value.id) && (seen.has(value.id) || finalConnected.has(value.id)));
114
+ out.notes = out.notes.filter(item => item.about?.some(id => neighbours.has(id) && finalIds.has(id) && finalConnected.has(id))
115
+ && [item.id, item.line, item.kind ?? "", ...(item.about ?? [])].every(options.publicMetadataText));
116
+ if (out.unknown || out.truncated) out.state = "unknown";
117
+ return out;
118
+ } catch { return { ...unavailableMemoryCodeOverlay(), queried: out.queried, truncated: out.truncated }; }
119
+ }
@@ -1,3 +1,4 @@
1
+ import { normalizeMemorySymbolInput } from "./memory-symbols.ts";
1
2
  import { extractNodes, nodePath, type View } from "@staix/agent-mem";
2
3
  import type { BriefTask } from "../memory/brief.ts";
3
4
 
@@ -13,10 +14,18 @@ export function planGuardNodes(task: BriefTask, context: PlanGuardContext): stri
13
14
  }
14
15
  const onlyPath = nodes.size === 1 ? nodePath([...nodes][0]!) : undefined;
15
16
  for (const symbol of task.plan?.symbols ?? []) {
16
- for (const node of extractNodes("", { ...context, paths: onlyPath ? [onlyPath] : [], symbols: [symbol], checkExists: false })) nodes.add(node);
17
+ if (typeof symbol !== "string") continue;
18
+ for (const form of new Set([symbol, normalizeMemorySymbolInput(symbol)])) {
19
+ for (const node of extractNodes("", { ...context, paths: onlyPath ? [onlyPath] : [], symbols: [form], checkExists: false })) nodes.add(node);
20
+ }
17
21
  }
18
22
  return [...nodes].sort();
19
23
  }
24
+ /** Retrieval nodes retain the title/task context as well as the explicit plan query. */
25
+ export function taskMemoryQueryNodes(task: BriefTask, context: PlanGuardContext): string[] {
26
+ const ordinary = extractNodes(task.title, { ...context, paths: [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])], ...(task.id !== undefined && task.created !== undefined ? { task: { id: task.id, created: task.created } } : {}) });
27
+ return [...new Set([...ordinary, ...planGuardNodes(task, context)])].sort();
28
+ }
20
29
  const samePath = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
21
30
 
22
31
  /** Fail.about is the source of the graph's rules_out edges in v1; there is no additional stored rules_out field. */
@@ -0,0 +1,159 @@
1
+ import { ASK_NOTE_TITLE } from "./ask.ts";
2
+ import { looksSecret, taskKey, type MemoryStore, type View } from "@staix/agent-mem";
3
+ import { unindexedSymbolSyntax, type CodeFactResult } from "./code-facts.ts";
4
+ import { canonicalMemorySymbolNode, MEMORY_SYMBOL_LIMIT, MEMORY_SYMBOL_START_BUDGET_MS, type MemorySymbolContext } from "./memory-symbols.ts";
5
+
6
+ export const MEMORY_STALE_SCAN_LIMIT = 256;
7
+ const PAGE = 64, NODE_SCAN_LIMIT = 256;
8
+ const SHA = /^[a-f0-9]{40}$/, ID = /^[a-f0-9]{32}$/;
9
+ export type MemoryTaskPrivacy = "public" | "private" | "unknown";
10
+ export type MemoryStaleReason = "absent" | "withheld" | "provenance" | "task-private" | "task-privacy-unknown" | "unavailable" | "ambiguous" | "present-path-unverified" | "budget" | "head-changed";
11
+ export interface MemoryStaleAnchorRow { id?: string; anchor?: string; state: "stale" | "unknown"; reason: MemoryStaleReason; label?: "absent from indexed definitions" }
12
+ export interface MemoryStaleAnchorReport {
13
+ state: "known" | "unknown";
14
+ head?: string;
15
+ rows: MemoryStaleAnchorRow[];
16
+ scanned: number;
17
+ queried: number;
18
+ stale: number;
19
+ /** Null means the source inventory itself was unavailable, not zero unresolved anchors. */
20
+ unknown: number | null;
21
+ truncated: boolean;
22
+ limit: number;
23
+ scanLimit: number;
24
+ }
25
+ export interface MemoryStaleAnchorOptions {
26
+ cwd: string;
27
+ project: string;
28
+ store: Pick<MemoryStore, "readPage" | "withDeferredPromotions">;
29
+ context(): MemorySymbolContext;
30
+ publicMetadataText(text: string): boolean;
31
+ head(): string | undefined;
32
+ assertCurrent?(): void;
33
+ taskPrivacy?(key: string): MemoryTaskPrivacy;
34
+ exists(repo: string, head: string, name: string): Promise<CodeFactResult<{ exists: boolean; ambiguous: boolean }>>;
35
+ now?: () => number;
36
+ /** Owned query teardown finishes before the final policy/HEAD projection. */
37
+ finishQueries?(): Promise<void>;
38
+ }
39
+ export function unavailableMemoryStaleAnchors(): MemoryStaleAnchorReport {
40
+ return { state: "unknown", rows: [], scanned: 0, queried: 0, stale: 0, unknown: null, truncated: false, limit: MEMORY_SYMBOL_LIMIT, scanLimit: MEMORY_STALE_SCAN_LIMIT };
41
+ }
42
+ /** Parse only the package's exact reset-safe identity; legacy ids never establish current task privacy. */
43
+ export function memoryAnchorTaskIdentity(key: string, project: string): { id: number; created: number; legacy: string } | undefined {
44
+ if (!key.startsWith(`${project}#`)) return;
45
+ const match = /^([1-9][0-9]*)@([1-9][0-9]*)$/.exec(key.slice(project.length + 1));
46
+ if (!match) return;
47
+ const id = Number(match[1]), created = Number(match[2]);
48
+ if (!Number.isSafeInteger(id) || !Number.isSafeInteger(created) || taskKey(project, { id, created }) !== key) return;
49
+ return { id, created, legacy: `${project}#${id}` };
50
+ }
51
+ // Structural task ids/timestamps are host metadata; arbitrary text and symbol/path digits stay screened unchanged.
52
+ const taskNamespace = (key: string): string => key.replace(/#[0-9]+(?:@[0-9]+)?$/, "");
53
+ const literalNote = (note: View): string => [note.title ?? "", note.text, taskNamespace(note.task ?? ""), note.project,
54
+ ...note.about.map(node => node.startsWith("task:") ? taskNamespace(node) : node)].join("\n");
55
+ function privacy(note: View, options: MemoryStaleAnchorOptions): MemoryStaleReason | undefined {
56
+ try {
57
+ options.assertCurrent?.();
58
+ if (note.about.length > NODE_SCAN_LIMIT) return "unavailable";
59
+ if (note.project !== options.project || !ID.test(note.id) || note.title?.startsWith(ASK_NOTE_TITLE)) return "provenance";
60
+ const raw = literalNote(note);
61
+ if (looksSecret(raw) || !options.publicMetadataText(raw)) return "withheld";
62
+ let ownLegacy: string | undefined;
63
+ if (note.task) {
64
+ const identity = memoryAnchorTaskIdentity(note.task, options.project);
65
+ if (!identity) return "task-privacy-unknown";
66
+ const state = options.taskPrivacy?.(note.task) ?? "unknown";
67
+ if (state !== "public") return state === "private" ? "task-private" : "task-privacy-unknown";
68
+ ownLegacy = identity.legacy;
69
+ }
70
+ for (const task of new Set(note.about.filter(node => node.startsWith("task:")).map(node => node.slice(5)))) {
71
+ if (task === note.task || task === ownLegacy) continue;
72
+ if (!memoryAnchorTaskIdentity(task, options.project)) return "task-privacy-unknown";
73
+ const state = options.taskPrivacy?.(task) ?? "unknown";
74
+ if (state !== "public") return state === "private" ? "task-private" : "task-privacy-unknown";
75
+ }
76
+ } catch { return "unavailable"; }
77
+ }
78
+
79
+ /** A read-only derived report. The deadline bounds query starts; the existing core refresh/CodeFacts preparation bounds still apply. */
80
+ export async function memoryStaleAnchors(options: MemoryStaleAnchorOptions): Promise<MemoryStaleAnchorReport> {
81
+ const report = unavailableMemoryStaleAnchors(), now = options.now ?? performance.now.bind(performance), deadline = now() + MEMORY_SYMBOL_START_BUDGET_MS;
82
+ const candidates: Array<{ note: View; raw: string }> = [];
83
+ const identities = new Map<MemoryStaleAnchorRow, string>();
84
+ let after: { position: number; id: string } | undefined;
85
+ try {
86
+ options.assertCurrent?.();
87
+ const head = options.head(); if (head && SHA.test(head)) report.head = head;
88
+ for (let page = 0; page < MEMORY_STALE_SCAN_LIMIT / PAGE; page++) {
89
+ if (now() >= deadline) { report.truncated = true; break; }
90
+ options.assertCurrent?.();
91
+ const result = options.store.withDeferredPromotions(() => options.store.readPage({ project: options.project, limit: PAGE, ...(after ? { after } : {}) }));
92
+ report.scanned += result.rows.length;
93
+ for (const note of result.rows) {
94
+ if (note.about.length > NODE_SCAN_LIMIT) report.truncated = true;
95
+ for (const raw of note.about.slice(0, NODE_SCAN_LIMIT)) {
96
+ if (!raw.startsWith("symbol:") || candidates.some(candidate => candidate.note.id === note.id && candidate.raw === raw)) continue;
97
+ if (candidates.length >= MEMORY_SYMBOL_LIMIT) { report.truncated = true; break; }
98
+ candidates.push({ note, raw });
99
+ }
100
+ }
101
+ if (!result.next) { after = undefined; break; }
102
+ after = result.next;
103
+ if (page === MEMORY_STALE_SCAN_LIMIT / PAGE - 1) {
104
+ const more = options.store.withDeferredPromotions(() => options.store.readPage({ project: options.project, limit: 1, after }));
105
+ report.truncated ||= more.rows.length > 0 || more.next !== undefined;
106
+ }
107
+ }
108
+ report.unknown = 0;
109
+ for (const candidate of candidates) {
110
+ if (now() >= deadline) { report.rows.push({ state: "unknown", reason: "budget" }); report.truncated = true; continue; }
111
+ const hidden = privacy(candidate.note, options);
112
+ if (hidden) { report.rows.push({ state: "unknown", reason: hidden }); continue; }
113
+ const context = options.context();
114
+ const anchor = context.project === options.project && context.cwd === options.cwd ? canonicalMemorySymbolNode(candidate.raw, context) : undefined;
115
+ if (!anchor) { report.rows.push({ state: "unknown", reason: "withheld" }); continue; }
116
+ const row: MemoryStaleAnchorRow = { id: candidate.note.id, anchor, state: "unknown", reason: "unavailable" };
117
+ report.rows.push(row); identities.set(row, context.repo);
118
+ const name = anchor.slice(7, anchor.lastIndexOf("@"));
119
+ if (unindexedSymbolSyntax(name)) continue;
120
+ if (!report.head) continue;
121
+ if (now() >= deadline) { row.reason = "budget"; report.truncated = true; continue; }
122
+ if (options.head() !== report.head) { row.reason = "head-changed"; continue; }
123
+ try {
124
+ options.assertCurrent?.(); report.queried++;
125
+ const answer = await options.exists(options.cwd, report.head, name);
126
+ const withheld = privacy(candidate.note, options);
127
+ const current = options.context();
128
+ if (withheld || current.project !== options.project || current.cwd !== options.cwd || current.repo !== context.repo || canonicalMemorySymbolNode(candidate.raw, current) !== anchor) { delete row.id; delete row.anchor; row.reason = withheld ?? "withheld"; continue; }
129
+ if (options.head() !== report.head) { row.reason = "head-changed"; continue; }
130
+ if (answer.state !== "known") continue;
131
+ if (answer.value.ambiguous === true) row.reason = "ambiguous";
132
+ else if (answer.value.exists === false && answer.value.ambiguous === false) { row.state = "stale"; row.reason = "absent"; }
133
+ else if (answer.value.exists === true && answer.value.ambiguous === false) row.reason = "present-path-unverified"; // exists() does not attest the defining file, so this anchor is not proven present.
134
+ } catch { row.reason = "unavailable"; }
135
+ }
136
+ await options.finishQueries?.();
137
+ options.assertCurrent?.();
138
+ if (report.head && options.head() !== report.head) for (const row of report.rows) { row.state = "unknown"; row.reason = "head-changed"; }
139
+ // Policy and current-task privacy may change during another anchor's awaited query.
140
+ for (let index = 0; index < candidates.length; index++) {
141
+ const row = report.rows[index], candidate = candidates[index]; if (!row || !candidate) continue;
142
+ const hidden = privacy(candidate.note, options), current = options.context();
143
+ const changed = row.anchor !== undefined && (current.project !== options.project || current.cwd !== options.cwd || current.repo !== identities.get(row) || canonicalMemorySymbolNode(candidate.raw, current) !== row.anchor);
144
+ if (hidden || changed) { delete row.id; delete row.anchor; row.state = "unknown"; row.reason = hidden ?? "withheld"; }
145
+ }
146
+ for (const row of report.rows) if (row.state === "stale" && row.reason === "absent") row.label = "absent from indexed definitions";
147
+ report.stale = report.rows.filter(row => row.state === "stale").length;
148
+ report.unknown = report.rows.length - report.stale;
149
+ report.state = report.unknown || report.truncated || !report.head ? "unknown" : "known";
150
+ return report;
151
+ } catch { return { ...unavailableMemoryStaleAnchors(), scanned: report.scanned, queried: report.queried, truncated: report.truncated }; }
152
+ }
153
+
154
+ export function formatMemoryStaleAnchors(report: MemoryStaleAnchorReport): string {
155
+ return [`stale anchors: ${report.stale}; unknown: ${report.unknown ?? "unknown"}; coverage: ${report.state}${report.truncated ? " (truncated)" : ""}`,
156
+ `scope: ${report.head ?? "HEAD unavailable"}; scanned ${report.scanned}/${report.scanLimit}; queried ${report.queried}/${report.limit}`,
157
+ ...report.rows.map(row => `${row.id ? `mem:${row.id} ` : ""}${row.anchor ? `${row.anchor} ` : ""}${row.state} (${row.reason})${row.label ? `: ${row.label}` : ""}`),
158
+ "review only; no memory entries or candidate records changed"].join("\n");
159
+ }
@@ -0,0 +1,74 @@
1
+ import { isDenied } from "../local/deny.ts";
2
+ import { foldSegment } from "./permission-mode.ts";
3
+ import { matchesPolicyLexically } from "./path-policy.ts";
4
+ import { extractNodes } from "@staix/agent-mem";
5
+ import { normalizePlanSymbol } from "./symbol-normalizer.ts";
6
+ import type { CodeFactResult } from "./code-facts.ts";
7
+
8
+ export const MEMORY_SYMBOL_LIMIT = 16;
9
+ export const MEMORY_SYMBOL_START_BUDGET_MS = 2000;
10
+ export interface MemorySymbolContext {
11
+ cwd: string; project: string; repo: string; deny?: string[];
12
+ allowed: (text: string) => boolean;
13
+ }
14
+ export interface MemorySymbolNodes {
15
+ state: "known" | "unknown";
16
+ nodes: string[];
17
+ unknown: number;
18
+ truncated: boolean;
19
+ }
20
+
21
+ /** Preserve an explicit file suffix while normalizing a plan's function signature. */
22
+ export function normalizeMemorySymbolInput(value: string): string {
23
+ if (typeof value !== "string") return "";
24
+ const at = value.lastIndexOf("@");
25
+ return at < 0 ? normalizePlanSymbol(value) : `${normalizePlanSymbol(value.slice(0, at))}@${value.slice(at + 1)}`;
26
+ }
27
+
28
+ /** Candidate identity only. Callers must obtain positive code facts before storing it as an about node. */
29
+ export function canonicalMemorySymbolNode(node: string, context: MemorySymbolContext): string | undefined {
30
+ if (typeof node !== "string" || !node.startsWith("symbol:") || Buffer.byteLength(node) > 512 || /[\p{Cc}\p{Cf}]/u.test(node)) return;
31
+ const at = node.lastIndexOf("@");
32
+ if (at <= 7 || at === node.length - 1 || node.slice(at + 1).includes("\\")) return;
33
+ const name = normalizePlanSymbol(node.slice(7, at)), path = node.slice(at + 1);
34
+ if (!name || /\s|@|\(|\)/u.test(name) || name.startsWith("section ") || name.startsWith("source ")) return;
35
+ // Use the package's path/deny normalization, without reading a working tree or following symlinks.
36
+ const paths = extractNodes("", { ...context, paths: [path], checkExists: false }).filter(value => value.startsWith("path:"));
37
+ if (paths.length !== 1) return;
38
+ const file = paths[0]!.slice(5), deny = context.deny ?? [];
39
+ // Core extraction covers literal denies; host read-time policy also covers folded names and declared globs.
40
+ if (isDenied(foldSegment(file), deny.map(foldSegment)) || matchesPolicyLexically(context.cwd, file, deny)) return;
41
+ const normalized = `symbol:${name}@${file}`;
42
+ try { if ([node, name, path, normalized].every(context.allowed)) return normalized; } catch { /* Unknown policy withholds. */ }
43
+ }
44
+
45
+ /** A bounded optional check of host-observed identities, never plan guesses or source structure. */
46
+ export async function resolveMemorySymbolNodes(
47
+ candidates: readonly string[], context: MemorySymbolContext,
48
+ exists: (name: string) => Promise<CodeFactResult<{ exists: boolean; ambiguous: boolean }>>,
49
+ now: () => number = performance.now.bind(performance),
50
+ ): Promise<MemorySymbolNodes> {
51
+ const result: MemorySymbolNodes = { state: "known", nodes: [], unknown: 0, truncated: candidates.length > MEMORY_SYMBOL_LIMIT };
52
+ const deadline = now() + MEMORY_SYMBOL_START_BUDGET_MS, seen = new Set<string>();
53
+ const lookups = new Map<string, Promise<CodeFactResult<{ exists: boolean; ambiguous: boolean }>>>();
54
+ for (const candidate of candidates.slice(0, MEMORY_SYMBOL_LIMIT)) {
55
+ const node = canonicalMemorySymbolNode(candidate, context);
56
+ if (!node) { result.unknown++; continue; }
57
+ if (seen.has(node)) continue;
58
+ seen.add(node);
59
+ if (now() >= deadline) { result.unknown++; result.truncated = true; continue; }
60
+ try {
61
+ const name = node.slice(7, node.lastIndexOf("@"));
62
+ let lookup = lookups.get(name);
63
+ if (!lookup) {
64
+ lookup = Promise.resolve().then(() => exists(name)).catch(() => ({ state: "unknown" } as const));
65
+ lookups.set(name, lookup);
66
+ }
67
+ const fact = await lookup;
68
+ if (fact.state === "known" && fact.value.exists === true && fact.value.ambiguous === false) result.nodes.push(node);
69
+ else result.unknown++;
70
+ } catch { result.unknown++; }
71
+ }
72
+ if (result.unknown || result.truncated) result.state = "unknown";
73
+ return result;
74
+ }
package/src/hub/peers.ts CHANGED
@@ -9,8 +9,15 @@ export interface DeliveryReceipt {
9
9
  quota?: true;
10
10
  }
11
11
 
12
+ /** Host-owned print child authority, revoked before stop/turn settlement. No terminal ownership. */
13
+ export interface OwnedToolsLaunch {
14
+ launchId: string; generation: string; sessionId: string; signal: AbortSignal; task?: number; permissionHook: true;
15
+ }
16
+
12
17
  export interface PeerAdapter {
13
18
  readonly id: PeerId;
19
+ ownedTools?(launchId: unknown): OwnedToolsLaunch | undefined;
20
+ keepApprovalAlive?(): void;
14
21
  readonly state: PeerState;
15
22
  /** Host-observed native session identity. An absent port permits owner-token fallback; undefined means unknown. */
16
23
  readonly sessionGeneration?: string | number;
@@ -0,0 +1,15 @@
1
+ import { sanitize } from "./envelope.ts";
2
+
3
+ /** Public approval rendering, never the arguments eventually executed by the native child.
4
+ * Withhold the complete value if any private/secret/path policy matches, before truncation. */
5
+ export function permissionPayload(tool: unknown, input: unknown, publicText: (text: string) => boolean): { tool: string; title: string } {
6
+ const name = typeof tool === "string" && /^[A-Za-z][A-Za-z0-9_.:/-]{0,127}$/.test(tool) && publicText(tool) ? tool : "[withheld]";
7
+ let rendered = "[withheld]";
8
+ try {
9
+ const text = JSON.stringify(input);
10
+ if (typeof text === "string" && Buffer.byteLength(text) <= 64 * 1024 && publicText(text)) rendered = sanitize(text);
11
+ } catch { /* Unknown or unserializable arguments are never partially shown. */ }
12
+ const full = Array.from(`${name} ${rendered}`);
13
+ const marker = ` [cut, ${full.length} chars]`;
14
+ return { tool: name, title: full.length > 600 ? full.slice(0, 600 - marker.length).join("") + marker : full.join("") };
15
+ }
@@ -1,9 +1,10 @@
1
1
  import type { ReleaseTrainData } from "./sdlc-release.ts";
2
+ import { planningIssueIdentity, type PlanningIssueIdentity } from "./sdlc.ts";
2
3
  import type { Task } from "./board.ts";
3
4
 
4
5
  export const BACKLOG_SPLIT_SCREEN_TIMEOUT_MS = 8000;
5
6
 
6
- export const PLANNING_FIELDS = ["rank", "priority", "release", "plannedStart", "due", "pinned", "deps", "reserved"] as const;
7
+ export const PLANNING_FIELDS = ["rank", "priority", "release", "plannedStart", "due", "pinned", "deps", "reserved", "sdlc"] as const;
7
8
  export interface PlanningFields {
8
9
  rank?: number | null;
9
10
  priority?: number | null;
@@ -13,6 +14,7 @@ export interface PlanningFields {
13
14
  pinned?: boolean;
14
15
  deps?: number[];
15
16
  reserved?: string | null;
17
+ sdlc?: PlanningIssueIdentity;
16
18
  }
17
19
  export interface PlanningPatch extends PlanningFields {}
18
20
  /** Machine-local capacity settings; ids never grant roster membership or authority. */
@@ -29,6 +31,8 @@ export function planningWipLimits(input: unknown): Record<string, number> {
29
31
  export const RELEASE_STATES = ["planning", "active", "frozen", "shipped", "dropped"] as const;
30
32
  export type ReleaseState = (typeof RELEASE_STATES)[number];
31
33
  export interface Release {
34
+ /** Null for rows created before task-scoped planners; no inferred backfill. */
35
+ planner?: string | null;
32
36
  train?: ReleaseTrainData;
33
37
  id: number; name: string; goal: string; order: number; state: ReleaseState;
34
38
  plannedStart: number | null; cut: number | null; ship: number | null;
@@ -37,15 +41,17 @@ export interface Release {
37
41
  }
38
42
 
39
43
  /** Validate the entire planning input before a write; actual dates are never caller input. */
40
- export function planningPatch(input: unknown): PlanningPatch {
44
+ export function planningPatch(input: unknown = {}): PlanningPatch {
41
45
  if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("planning fields must be an object");
42
46
  const fields = input as Record<string, unknown>;
43
- if (!Object.keys(fields).length || Object.keys(fields).some(key => !(PLANNING_FIELDS as readonly string[]).includes(key))) throw new Error("unknown or empty planning fields");
47
+ if (Object.keys(fields).some(key => !(PLANNING_FIELDS as readonly string[]).includes(key))) throw new Error("unknown planning fields");
44
48
  const result: PlanningPatch = {};
45
49
  for (const key of PLANNING_FIELDS) {
46
50
  if (!Object.hasOwn(fields, key)) continue;
47
51
  const value = fields[key];
48
- if (key === "deps") {
52
+ if (key === "sdlc") {
53
+ result.sdlc = planningIssueIdentity(value);
54
+ } else if (key === "deps") {
49
55
  if (!Array.isArray(value) || value.length > 64 || value.some(id => !Number.isSafeInteger(id) || id <= 0) || new Set(value).size !== value.length) throw new Error("deps must be at most 64 unique positive task ids");
50
56
  result.deps = [...value];
51
57
  } else if (key === "reserved") {
@@ -1,3 +1,4 @@
1
+ import type { MemoryCodeOverlay } from "./memory-code-overlay.ts";
1
2
  import type { MemoryStore, View, Graph } from "@staix/agent-mem";
2
3
  import { looksSecret } from "@staix/agent-mem";
3
4
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
@@ -29,7 +30,7 @@ export function validateMemoryRead(op: string, a: Record<string, unknown>): void
29
30
  }
30
31
 
31
32
  /** Read-only model projection. Counts and caps are computed only after screening whole records and metadata. */
32
- export function memoryRead(store: MemoryStore | undefined, project: string, screen: MemoryScreen, op: string, a: Record<string, unknown>, stale: (about: readonly string[]) => boolean = () => false): string {
33
+ export function memoryRead(store: MemoryStore | undefined, project: string, screen: MemoryScreen, op: string, a: Record<string, unknown>, stale: (about: readonly string[]) => boolean = () => false, code?: MemoryCodeOverlay): string {
33
34
  validateMemoryRead(op, a);
34
35
  const safe = (v: unknown): boolean => {
35
36
  try {
@@ -93,7 +94,9 @@ export function memoryRead(store: MemoryStore | undefined, project: string, scre
93
94
  if (n.startsWith("goal:") && !goals.has(n)) reject();
94
95
  return n;
95
96
  });
96
- const nodes = new Map<string, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
97
+ type Node = Graph["nodes"][number] & { derived?: true; head?: string; codeNeighbour?: true };
98
+ type Edge = Omit<Graph["edges"][number], "rel"> & { rel: string; derived?: true; head?: string };
99
+ const nodes = new Map<string, Node>(), edges = new Map<string, Edge>();
97
100
  for (const start of starts) {
98
101
  const graph = store.graph(project, start, Number(a.hops ?? 1), Number.MAX_SAFE_INTEGER);
99
102
  for (const n of graph.nodes) {
@@ -105,12 +108,27 @@ export function memoryRead(store: MemoryStore | undefined, project: string, scre
105
108
  }
106
109
  for (const e of graph.edges) if (safe(e)) edges.set(JSON.stringify(e), e);
107
110
  }
111
+ const overlay = code && /^[a-f0-9]{40}$/.test(code.head ?? "") && starts.some(id => id.startsWith("symbol:")) ? code : undefined;
112
+ if (overlay) {
113
+ for (const node of overlay.nodes) if (!nodes.has(node.id) && node.head === overlay.head && node.derived && safe(node)) nodes.set(node.id, node);
114
+ for (const edge of overlay.edges) if (edge.head === overlay.head && edge.derived && edge.rel === "calls" && safe(edge)) edges.set(JSON.stringify(edge), edge);
115
+ for (const note of overlay.notes) {
116
+ const value = visible.get(note.id);
117
+ if (!value || !safe(note)) continue;
118
+ nodes.set(note.id, { id: note.id, type: "memory", label: excerpt(value.title ?? value.text), kind: value.kind, codeNeighbour: true });
119
+ for (const about of value.about) if (overlay.nodes.some(node => node.id === about)) {
120
+ const edge = { from: note.id, to: about, rel: value.kind === "fail" ? "rules_out" : "about" };
121
+ edges.set(JSON.stringify(edge), edge);
122
+ }
123
+ }
124
+ }
108
125
  const screenedEdges = [...edges.values()].filter(e => nodes.has(e.from) && nodes.has(e.to));
109
126
  const connected = new Set(screenedEdges.flatMap(e => [e.from, e.to]));
110
127
  const rank = (n: Graph["nodes"][number]) => n.kind === "fail" ? 0 : starts.includes(n.id) ? 1 : n.type !== "memory" ? 2 : 3;
111
128
  const eligibleNodes = [...nodes.values()].filter(n => connected.has(n.id) || starts.includes(n.id)).sort((x, y) => rank(x) - rank(y) || x.id.localeCompare(y.id));
112
129
  const eligibleEdges = screenedEdges.sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));
113
- const out: Graph & { truncated: number } = { nodes: [], edges: [], truncated: eligibleNodes.length + eligibleEdges.length };
130
+ const out: { nodes: Node[]; edges: Edge[]; truncated: number; code?: { state: "known" | "unknown"; head?: string; truncated: boolean; countsFloor: boolean } } = { nodes: [], edges: [], truncated: eligibleNodes.length + eligibleEdges.length,
131
+ ...(code ? { code: { state: overlay?.state ?? "unknown", ...(overlay?.head ? { head: overlay.head } : {}), truncated: code.truncated, countsFloor: code.countsFloor } } : {}) };
114
132
  for (const n of eligibleNodes) {
115
133
  if (out.nodes.length >= Number(a.limit ?? 60)) break;
116
134
  out.nodes.push(n); out.truncated--;