@staix/agent-hub 0.15.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 (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +6 -3
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +13 -1
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/dogfood.md +13 -4
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +116 -30
  14. package/docs/quickstart.md +8 -4
  15. package/docs/security.md +80 -18
  16. package/docs/smoke.md +7 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
  18. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  19. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  20. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  21. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  22. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  23. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  24. package/docs/verified.json +26 -17
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +2085 -1702
  28. package/src/adapters/acp.ts +5 -2
  29. package/src/adapters/codex-appserver.ts +69 -1
  30. package/src/adapters/compat.ts +2 -2
  31. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  32. package/src/adapters/local-worker.ts +5 -4
  33. package/src/adapters/registry-types.ts +1 -1
  34. package/src/cli/console-state.ts +20 -2
  35. package/src/cli/console-symbols.ts +3 -2
  36. package/src/cli/console-usage.ts +5 -2
  37. package/src/cli/console.ts +5 -1
  38. package/src/cli/help.ts +7 -5
  39. package/src/cli/identity.ts +3 -1
  40. package/src/cli/main.ts +44 -10
  41. package/src/cli/memory.ts +119 -7
  42. package/src/cli/output.ts +12 -4
  43. package/src/hub/action-class.ts +211 -17
  44. package/src/hub/action-enforce.ts +13 -5
  45. package/src/hub/action-policy.ts +19 -8
  46. package/src/hub/ask.ts +16 -4
  47. package/src/hub/board.ts +86 -31
  48. package/src/hub/budget-notes.ts +65 -0
  49. package/src/hub/budget.ts +42 -10
  50. package/src/hub/bus.ts +1 -1
  51. package/src/hub/code-facts.ts +56 -16
  52. package/src/hub/conductor.ts +5 -2
  53. package/src/hub/config-apply.ts +7 -0
  54. package/src/hub/control-client.ts +1 -1
  55. package/src/hub/daemon.ts +250 -49
  56. package/src/hub/envelope.ts +1 -1
  57. package/src/hub/events.ts +2 -2
  58. package/src/hub/hub-tools.ts +3 -3
  59. package/src/hub/kimi-usage.ts +12 -0
  60. package/src/hub/lifecycle.ts +10 -5
  61. package/src/hub/memory-code-head.ts +44 -0
  62. package/src/hub/memory-code-overlay.ts +119 -0
  63. package/src/hub/memory-plan-guard.ts +10 -1
  64. package/src/hub/memory-stale-anchors.ts +159 -0
  65. package/src/hub/memory-symbols.ts +74 -0
  66. package/src/hub/planning.ts +10 -4
  67. package/src/hub/read-tools.ts +21 -3
  68. package/src/hub/sdlc.ts +34 -0
  69. package/src/hub/settings.ts +17 -2
  70. package/src/hub/settled-roles.ts +15 -3
  71. package/src/hub/task-sweep.ts +25 -3
  72. package/src/hub/tasks.ts +353 -64
  73. package/src/hub/token-window.ts +15 -2
  74. package/src/hub/window-kind.ts +13 -0
  75. package/src/local/tools.ts +5 -5
  76. package/src/memory/brief.ts +22 -12
  77. package/src/memory/capture.ts +26 -40
  78. package/src/memory/client.ts +137 -65
  79. package/src/memory/recall.ts +13 -22
  80. package/src/ui/index.html +100 -18
@@ -14,7 +14,7 @@ export interface Envelope {
14
14
  kind: Kind;
15
15
  priority: Priority;
16
16
  body: string; // agent conclusions only, never tool noise
17
- refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
17
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[] };
18
18
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
19
19
  private?: boolean;
20
20
  ts: number;
package/src/hub/events.ts CHANGED
@@ -37,8 +37,8 @@ export type HubEvent =
37
37
  | { type: "hub_start"; instance: string }
38
38
  /** #248: the add-ons a peer works with, names and versions from a closed list, at each attach. */
39
39
  | { type: "tooling"; peer: string; addons: import("./tooling.ts").Addon[] }
40
- /** #258: a task brief handed to its owner: how many hub-memory items (and fails among them) and claude-mem items, never their text. */
41
- | { type: "memory_brief"; peer: string; task: number; native: number; fails: number; claudeMem: number }
40
+ /** #258: a task brief handed to its owner: how many hub-memory items (and fails among them) and supplemental items (legacy claudeMem field), never their text. New local-library records identify source amem. */
41
+ | { type: "memory_brief"; source?: "amem"; peer: string; task: number; native: number; fails: number; claudeMem: number }
42
42
  | { type: "split"; task: number; where?: "routing" | "cohort"; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
43
43
  | { type: "state"; peer: string; state: string }
44
44
  | { type: "turn_start"; peer: string; turn: string }
@@ -90,7 +90,7 @@ export const TASK_TOOLS: HubTool[] = [
90
90
  tool("hub_check_cancel", "Cancel your task-bound isolated check; only its owner or the person may cancel.", { run: str }, ["run"]),
91
91
  tool("hub_delivery_retry", "Request one redelivery of your own needs_review delivery. An earlier attempt may have effects: inspect those effects before acting. Refuses another peer's row or any lineage already explicitly retried; a repeated uncertain row needs the person. Never completes/discards a delivery or changes task state.", { delivery_id: { type: "string", minLength: 1, maxLength: 256 } }, ["delivery_id"]),
92
92
  tool("hub_backlog_list", "Read public backlog cards with planning revisions. Configured planners only; PII remains a stub.", {}),
93
- tool("hub_backlog_plan", "Plan an unpinned backlog item outside frozen releases with the revision you read and a bounded reason. A person's rank is retained. Actual dates cannot be set.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", maxLength: 1000 }, fields: { type: "object", properties: { rank: { type: ["integer", "null"], minimum: 0 }, priority: { type: ["integer", "null"], minimum: 0, maximum: 3 }, release: { type: ["integer", "null"], minimum: 1 }, plannedStart: { type: ["integer", "null"], minimum: 1 }, due: { type: ["integer", "null"], minimum: 1 }, deps: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason", "fields"]),
93
+ tool("hub_backlog_plan", "Plan an unpinned backlog item outside frozen releases with the revision you read and a bounded reason. Omit fields or give {} for a note-only revision. fields.sdlc accepts only repo/issue identity on unstarted backlog work; relinking clears old spec acceptance. A person's rank is retained. Actual dates cannot be set.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", maxLength: 1000 }, fields: { type: "object", properties: { sdlc: { type: "object", minProperties: 1, properties: { repo: { type: ["string", "null"], maxLength: 400 }, issue: { type: ["integer", "null"], minimum: 1 } }, additionalProperties: false }, rank: { type: ["integer", "null"], minimum: 0 }, priority: { type: ["integer", "null"], minimum: 0, maximum: 3 }, release: { type: ["integer", "null"], minimum: 1 }, plannedStart: { type: ["integer", "null"], minimum: 1 }, due: { type: ["integer", "null"], minimum: 1 }, deps: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason"]),
94
94
  tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
95
95
  tool("hub_backlog_split", "Split unstarted backlog work into two to eight staged parts. The original card retains integration/acceptance and depends on the children. Use after for existing prerequisite ids and afterParts for earlier zero-based part indexes. Latest unused unchanged children can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 }, parts: { type: "array", minItems: 2, maxItems: 8, items: { type: "object", properties: { title: { type: "string", minLength: 1, maxLength: 300 }, detail: { type: "string", maxLength: 8000 }, class: str, refs, plan, after: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, afterParts: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "integer", minimum: 0 } } }, required: ["title"], additionalProperties: false } } }, ["id", "revision", "reason", "parts"]),
96
96
  tool("hub_backlog_undo", "Undo your latest planning change at its current revision. Refuses intervening changes and another actor's edit.", { id, revision: { type: "integer", minimum: 1 } }, ["id", "revision"]),
@@ -112,9 +112,9 @@ export const TASK_TOOLS: HubTool[] = [
112
112
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
113
113
  tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
114
114
  tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
115
- tool("hub_memory_graph", "Read screened memory neighbourhoods, fails first. Up to 8 starts (<=200 UTF-8 bytes each), 1 or 2 hops; default 60/max 120 nodes, max 240 edges, whole output <=6 KB. No project argument.", { starts: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 200, description: "At most 200 UTF-8 bytes" } }, hops: { type: "integer", enum: [1, 2] }, limit: { type: "integer", minimum: 1, maximum: 120 } }, ["starts"]),
115
+ tool("hub_memory_graph", "Read screened memory neighbourhoods, fails first. Symbol starts also include optional current-head one-hop code neighbours as derived SHA-stamped edges; unknown coverage is explicit. Up to 8 starts (<=200 UTF-8 bytes each), 1 or 2 hops; default 60/max 120 nodes, max 240 edges, whole output <=6 KB. No project argument.", { starts: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 200, description: "At most 200 UTF-8 bytes" } }, hops: { type: "integer", enum: [1, 2] }, limit: { type: "integer", minimum: 1, maximum: 120 } }, ["starts"]),
116
116
  tool("hub_memory_correct", "Propose a correction to a visible memory entry. This creates a machine-local candidate only; a person accepts or rejects it with amem review. It never edits a note. id is an exact id or unique visible prefix; text and reason must each fit 4000 UTF-8 bytes.", { id: str, text: str, reason: str }, ["id", "text", "reason"]),
117
- tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. For kind fail, optional approach, symptom, cause, evidence and retry_when strings compose fixed labeled text; text may be omitted when structured fields are supplied. The complete text must fit 4000 UTF-8 bytes and is refused, never truncated, when oversized. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, approach: str, symptom: str, cause: str, evidence: str, retry_when: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }),
117
+ tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's amem curated store); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. For kind fail, optional approach, symptom, cause, evidence and retry_when strings compose fixed labeled text; text may be omitted when structured fields are supplied. The complete text must fit 4000 UTF-8 bytes and is refused, never truncated, when oversized. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, approach: str, symptom: str, cause: str, evidence: str, retry_when: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }),
118
118
  ];
119
119
 
120
120
  export const PLANNER_REVIEW_TOOLS: HubTool[] = [
@@ -52,6 +52,18 @@ export class KimiUsage {
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * What the count can say now, for `ahub doctor`, the notes under `ahub budget` and the dashboard. Carries a reason code at most (never a path, id or text from the log).
57
+ * `off`: no ceiling, so no window. `unreadable`: a log read unknown or was tainted. `counting`: a clean log is bound. `waiting`: no Kimi session is bound yet.
58
+ */
59
+ status(): { state: "off" | "waiting" | "counting" } | { state: "unreadable"; reason: string } {
60
+ if (!this.ports.ceiling()) return { state: "off" };
61
+ for (const state of this.bound.values()) if (state.unknown) return { state: "unreadable", reason: state.unknown };
62
+ const now = this.ports.now();
63
+ for (const until of this.taintedUntil.values()) if (now < until) return { state: "unreadable", reason: "log changed since it was counted" };
64
+ return { state: this.bound.size ? "counting" : "waiting" };
65
+ }
66
+
55
67
  /** The peer went offline: its log is no longer read, so an unreadable state of it must not hold the other peers' figure unknown. */
56
68
  drop(peer: string): void { this.bound.delete(peer); }
57
69
 
@@ -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();
@@ -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
+ }
@@ -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") {