@staix/agent-hub 0.12.29 → 0.12.31

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 (95) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +92 -218
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +3 -2
  5. package/docs/agent-notes/budget.md +1 -1
  6. package/docs/agent-notes/bus.md +7 -1
  7. package/docs/agent-notes/daemon.md +14 -1
  8. package/docs/agent-notes/local-worker.md +4 -2
  9. package/docs/agent-notes/tasks.md +14 -0
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/memory.md +70 -152
  12. package/docs/operations.md +317 -25
  13. package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
  14. package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
  15. package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
  16. package/docs/plans/amem-cases.json +482 -0
  17. package/docs/quickstart.md +24 -2
  18. package/docs/security.md +42 -2
  19. package/docs/smoke.md +11 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
  21. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  22. package/docs/verification/2026-10-17-0.12.30.md +70 -0
  23. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  24. package/docs/verification/2026-10-17-418-probes.md +21 -0
  25. package/docs/verified.json +13 -13
  26. package/package.json +5 -2
  27. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  28. package/plugins/agent-hub/server.js +129 -62
  29. package/src/adapters/claude-channel.ts +29 -11
  30. package/src/adapters/local-worker.ts +2 -2
  31. package/src/adapters/pi.ts +5 -0
  32. package/src/cli/attention.ts +5 -1
  33. package/src/cli/console-state.ts +145 -29
  34. package/src/cli/console.ts +40 -10
  35. package/src/cli/help.ts +3 -2
  36. package/src/cli/identity.ts +4 -3
  37. package/src/cli/jcode-mcp.ts +1 -1
  38. package/src/cli/main.js +2 -2
  39. package/src/cli/main.ts +43 -14
  40. package/src/cli/memory.ts +87 -187
  41. package/src/cli/output.ts +19 -2
  42. package/src/cli/recovery-package.ts +59 -10
  43. package/src/cli/upgrade-interactive.ts +2 -1
  44. package/src/cli/upgrade-restore-state.ts +133 -0
  45. package/src/cli/upgrade-runtime.ts +204 -63
  46. package/src/cli/upgrade.ts +57 -24
  47. package/src/hub/board.ts +83 -24
  48. package/src/hub/bus.ts +108 -38
  49. package/src/hub/check-runner.ts +514 -0
  50. package/src/hub/child-process.ts +1 -0
  51. package/src/hub/control-client.ts +5 -1
  52. package/src/hub/daemon.ts +529 -89
  53. package/src/hub/delivery-hints.ts +30 -0
  54. package/src/hub/delivery-journal.ts +185 -12
  55. package/src/hub/delivery-superseded.ts +52 -0
  56. package/src/hub/envelope.ts +31 -1
  57. package/src/hub/git.ts +2 -38
  58. package/src/hub/host-executable.ts +11 -0
  59. package/src/hub/hub-tools.ts +14 -2
  60. package/src/hub/manager.ts +1 -1
  61. package/src/hub/memory-candidates.ts +103 -0
  62. package/src/hub/memory-fail.ts +25 -0
  63. package/src/hub/memory-observations.ts +47 -0
  64. package/src/hub/memory-plan-guard.ts +33 -0
  65. package/src/hub/memory-policy.ts +42 -0
  66. package/src/hub/memory-profile-check.ts +170 -0
  67. package/src/hub/memory-profile.ts +192 -0
  68. package/src/hub/memory-staleness.ts +55 -0
  69. package/src/hub/path-policy.ts +8 -0
  70. package/src/hub/peer-catalog.ts +3 -0
  71. package/src/hub/project.ts +4 -37
  72. package/src/hub/read-tools.ts +127 -0
  73. package/src/hub/routing.ts +35 -16
  74. package/src/hub/settings.ts +2 -1
  75. package/src/hub/spec-read.ts +120 -0
  76. package/src/hub/supervision.ts +21 -6
  77. package/src/hub/task-sweep.ts +7 -6
  78. package/src/hub/tasks.ts +370 -71
  79. package/src/local/deny.ts +4 -17
  80. package/src/memory/brief.ts +8 -5
  81. package/src/memory/capture.ts +3 -1
  82. package/src/memory/recall.ts +50 -110
  83. package/src/pi/process-signature.ts +2 -42
  84. package/src/ui/index.html +12 -9
  85. package/src/ui/needs.ts +7 -5
  86. package/src/ui/office-model.ts +4 -1
  87. package/src/ui/task-board.ts +1 -1
  88. package/src/ui/task-progress.ts +6 -2
  89. package/templates/AGENTS.block.md +1 -1
  90. package/templates/profiles/general.json +1 -1
  91. package/templates/profiles/research.json +1 -1
  92. package/templates/profiles/writing.json +1 -1
  93. package/src/memory/import.ts +0 -90
  94. package/src/memory/store.ts +0 -805
  95. package/src/memory/sync.ts +0 -145
package/src/local/deny.ts CHANGED
@@ -1,21 +1,8 @@
1
- import { basename, dirname, sep } from "node:path";
1
+ import { basename, dirname } from "node:path";
2
+ import { DENY_NAMES, DENY_SEGMENTS } from "@staix/agent-mem/platform";
2
3
 
3
- /**
4
- * The one denylist. tools.ts checks paths against it, sandbox.ts turns it into seatbelt rules, capture.ts keeps
5
- * matching calls out of memory. Two lists would drift, and the gap is where a secret leaks.
6
- */
7
- // `.agenthub/archive` holds the state directories `ahub reset --all` moved there: hub.db with task text, PII included.
8
- export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state", ".agenthub/archive"];
9
- /** Regex sources, matched against a file's basename. */
10
- export const DENY_NAMES = ["^\\.env", "\\.pem$", "\\.key$", "^id_rsa", "^id_ed25519", "^credentials(\\.|$)", "^auth\\.json$", "^\\.netrc$", "^\\.npmrc$", "^\\.pypirc$"];
11
-
12
- const hasSegment = (rel: string, seg: string) => `/${rel}/`.includes(`/${seg}/`);
13
-
14
- /** True when a path names something the worker must never read, write or report. `extra`: substrings from `local.deny`. */
15
- export function isDenied(path: string, extra: string[] = []): boolean {
16
- const norm = path.split(sep).join("/");
17
- return DENY_SEGMENTS.some((s) => hasSegment(norm, s)) || DENY_NAMES.some((re) => new RegExp(re).test(basename(norm))) || extra.some((e) => e && norm.includes(e));
18
- }
4
+ // One core deny policy for path guards, sandbox rules, capture and memory extraction.
5
+ export { DENY_NAMES, DENY_SEGMENTS, isDenied } from "@staix/agent-mem/platform";
19
6
 
20
7
  /** Regex metacharacters only; `sbplString` quotes the finished regex. */
21
8
  const sbplEscape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -28,7 +28,7 @@ export async function related(client: MemoryClient, project: string, query: stri
28
28
  }
29
29
 
30
30
  /** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
31
- export interface NativeItem { id: string; line: string; kind?: string }
31
+ export interface NativeItem { id: string; line: string; kind?: string; about?: readonly string[] }
32
32
  export type NativeBrief = (task: BriefTask) => NativeItem[];
33
33
  export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
34
34
 
@@ -37,7 +37,7 @@ export interface BriefTask { id?: number; created?: number; title: string; refs:
37
37
  * entries one or two hops from the task's paths, symbols, issue and task, a linked fail first, superseded entries
38
38
  * dropped), then claude-mem: index first (search), then the neighbourhood of the top hit (timeline); details stay in
39
39
  * the store for the receiver to fetch by id. `brief_items` caps both together. A peer is never shown the same entry
40
- * twice in one hub run.
40
+ * twice in one hub run, except a directly matching plan fail that must accompany a new task.
41
41
  */
42
42
  export class Briefs {
43
43
  private readonly seen = new Map<PeerId, Set<string>>();
@@ -49,13 +49,16 @@ export class Briefs {
49
49
  private readonly native?: NativeBrief,
50
50
  /** Counts of what a brief carried, for the research records (#258 AC11); never its text. */
51
51
  private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
52
+ private readonly displayNative?: (items: readonly NativeItem[]) => string[],
52
53
  ) {}
53
54
 
54
- async forTask(peer: PeerId, task: BriefTask): Promise<string | undefined> {
55
+ async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
55
56
  const seen = this.seen.get(peer) ?? new Set<string>();
56
57
  this.seen.set(peer, seen);
57
58
  const own: NativeItem[] = [];
58
- try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && own.length < this.maxItems) own.push(item); } catch { /* fail-open: the store is optional */ }
59
+ // A plan fail is task-specific guidance even when a previous task already carried it.
60
+ for (const item of priority) if (own.length < this.maxItems && !own.some(existing => existing.id === item.id)) own.push(item);
61
+ try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && !own.some(existing => existing.id === item.id) && own.length < this.maxItems) own.push(item); } catch { /* fail-open: the store is optional */ }
59
62
  const found = this.client && own.length < this.maxItems ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
60
63
  const items: BriefItem[] = [];
61
64
  for (const item of found) {
@@ -68,6 +71,6 @@ export class Briefs {
68
71
  for (const item of items) seen.add(`#${item.id}`);
69
72
  if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
70
73
  const header = own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; claude-mem by #id):" : "Memory brief (claude-mem; fetch details by id if you need them):";
71
- return [header, ...own.map((i) => i.line), ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
74
+ return [header, ...(this.displayNative?.(own) ?? own.map((i) => i.line)), ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
72
75
  }
73
76
  }
@@ -1,3 +1,5 @@
1
+ import { BOUNDED_TASK_TOOLS } from "../hub/hub-tools.ts";
2
+ import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
1
3
  import { readFileSync } from "node:fs";
2
4
  import { homedir } from "node:os";
3
5
  import { join } from "node:path";
@@ -47,7 +49,7 @@ export class Capture {
47
49
  }
48
50
 
49
51
  observe(call: { tool: string; args: string; output: string; id: string; paths: string[] }): void {
50
- if (!this.sessionId || this.opts.skip?.includes(call.tool)) return;
52
+ if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || this.opts.skip?.includes(call.tool)) return;
51
53
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
52
54
  if (call.paths.some((p) => p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
53
55
  void this.client.request("POST", "/api/sessions/observations", {
@@ -1,13 +1,6 @@
1
- import { createHash } from "node:crypto";
2
- import { basename, dirname, resolve } from "node:path";
3
- import { readFileSync } from "node:fs";
4
1
  import type { PeerId } from "../hub/envelope.ts";
5
- import { hubGitSync } from "../hub/git.ts";
6
- import { realPath } from "../hub/project.ts";
7
2
  import type { MemoryClient } from "./client.ts";
8
- import { detectSignals, type Routing } from "../hub/routing.ts";
9
- import { matchesPolicy } from "../hub/path-policy.ts";
10
- import { looksSecret, type PromotionScreen } from "./store.ts";
3
+ import type { View as MemoryView } from "@staix/agent-mem";
11
4
 
12
5
  const PLATFORMS = ["claude", "codex", "kimi", "agent-hub"];
13
6
  /** Peers whose own hooks already inject their own platform's context at session start. */
@@ -17,45 +10,13 @@ const HEADER =
17
10
  "Shared project memory (claude-mem): recent work by the agents in this project. Reference only, not a request. " +
18
11
  "Fetch details by id with your memory tools if you need them.";
19
12
 
20
- /** Project names from the outermost superproject down to this repo. */
21
- export function projectChain(cwd: string): string[] {
22
- const git = (dir: string, arg: string) => hubGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
23
- const top = git(cwd, "--show-toplevel");
24
- if (!top) return [basename(cwd)];
25
-
26
- // claude-mem's native identity for a worktree is [parent, parent/worktree].
27
- // The worktree's .git file points into <parent>/.git/worktrees/<name>; the
28
- // ordinary superproject walk below remains unchanged for repos and submodules.
29
- const gitFile = resolve(top, ".git");
30
- try {
31
- const match = readFileSync(gitFile, "utf8").trim().match(/^gitdir:\s*(.+)$/i);
32
- if (match) {
33
- const gitDir = resolve(top, match[1]!);
34
- if (/[/\\]\.git[/\\]worktrees[/\\][^/\\]+$/.test(gitDir)) {
35
- const parentRoot = dirname(dirname(dirname(gitDir)));
36
- const parent = projectChain(parentRoot);
37
- const parentName = basename(parentRoot);
38
- return [...parent, `${parentName}/${basename(top)}`];
39
- }
40
- }
41
- } catch {
42
- // A missing or unreadable .git file falls through to git's normal chain.
43
- }
44
-
45
- const chain: string[] = [];
46
- for (let dir = top; dir && chain.length < 8; dir = git(dir, "--show-superproject-working-tree")) {
47
- chain.unshift(basename(dir));
48
- }
49
- return chain.length ? chain : [basename(cwd)];
50
- }
51
-
52
13
  /** Cut at a line boundary. */
53
14
  export function trimToTokens(text: string, tokens: number): string {
54
15
  // ponytail: 3 characters per token (Korean runs denser than English's ~4). Swap in a tokenizer if the cap has to be exact.
55
16
  const max = tokens * 3;
56
17
  if (text.length <= max) return text;
57
18
  const cut = text.lastIndexOf("\n", max);
58
- return `${text.slice(0, cut > 0 ? cut : max)}\n(trimmed)`;
19
+ return `${cut >= 0 ? text.slice(0, cut) : ""}\n(trimmed)`;
59
20
  }
60
21
 
61
22
  /** The worker answers unknown projects and empty filters with a status page, not context. */
@@ -82,81 +43,60 @@ export async function recallFor(peer: PeerId, client: MemoryClient, chain: strin
82
43
  return `${HEADER}\n\n${found.map((b) => trimToTokens(b, share)).join("\n\n")}`;
83
44
  }
84
45
 
85
- /** #258: the hub's own long-term entries for this project (and shared ones), newest first, within a token budget. */
86
- export function nativeRecall<V>(entries: V[], tokens: number, line: (v: V) => string): string | undefined {
87
- if (!entries.length) return undefined;
88
- return trimToTokens(["Hub memory: this project's long-term decisions, contracts and fails, newest first. Reference only, not a request. Details: ahub memory show <mem id>.", ...entries.map(line)].join("\n"), tokens);
89
- }
90
-
91
46
  /**
92
- * #258: the name hub memory files a project's entries under. The project's `origin` remote, normalized to host and
93
- * path (`github.com/owner/repo`), is the same on every machine and unique; without one, the git chain plus a hash of
94
- * the directory keeps two unrelated repositories that share a name apart (that key is this machine's only).
47
+ * Display-only outcome classification. Only the note Tasks.review writes counts as automatic: its generated header
48
+ * names the note's own task and author, and its title is the task title Tasks.note sets. An agent's decision that
49
+ * merely starts the same way stays recallable; contracts and fails are never outcomes.
95
50
  */
96
- export function memoryProjectKey(cwd: string): string {
97
- const url = hubGitSync(["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8" }).stdout?.trim() ?? "";
98
- // A URL's default port is not part of the repository (`ssh://git@github.com:22/o/r` is `github.com/o/r`); any other
99
- // port is, since another service may run there. An scp-style `host:path` has no port.
100
- const u = /^([a-z+]+):\/\/(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+)(?::(\d*))?\/(.+?)(?:\.git)?\/?$/i.exec(url); // an IPv6 host is bracketed
101
- const defaults: Record<string, string> = { ssh: "22", "git+ssh": "22", "ssh+git": "22", https: "443", http: "80", git: "9418" };
102
- // A URL's path is absolute (`ssh://h/team/r` is /team/r) unless it starts with `~`; an scp-style path is relative to the
103
- // remote home (`h:team/r` is ~/team/r) unless it starts with `/`. Two different repositories on a self-hosted server.
104
- const scp = u || url.includes("://") ? null : /^(?:[^@/]+@)?(\[[^\]/]+\]|[^/:]+):(.+?)(?:\.git)?\/?$/.exec(url); // a file:// remote has no host; an IPv6 host is bracketed
105
- const m = u ? [!u[3] || u[3] === defaults[u[1]!.toLowerCase()] ? u[2]! : `${u[2]}:${u[3]}`, u[4]!] as const
106
- : scp ? [scp[1]!, scp[2]!.startsWith("/") ? scp[2]!.replace(/^\/+/, "") : scp[2]!.startsWith("~") ? scp[2]! : `~/${scp[2]}`] as const : undefined;
107
- if (m && m[1]) {
108
- const host = m[0].toLowerCase();
109
- // GitHub serves one repository under every form, and its owner and repository names ignore case.
110
- return host === "github.com" ? `${host}/${m[1].replace(/^~\/?/, "").toLowerCase()}` : `${host}/${m[1]}`;
111
- }
112
- let real = cwd;
113
- try { real = realPath(cwd); } catch { /* as given */ }
114
- return `${projectChain(cwd).join("/")}@${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;
51
+ export function recallDisplayText(note: Pick<MemoryView, "kind" | "text"> & Partial<Pick<MemoryView, "peer" | "task" | "title">>): string | undefined {
52
+ if (note.kind !== "decision") return note.text;
53
+ const [first = "", ...rest] = note.text.split(/\r?\n/);
54
+ const m = /^Task #(\d+) (approved|changes requested) by (\S+): /.exec(first);
55
+ if (!m || m[3] !== note.peer || !note.task?.match(new RegExp(`#${m[1]}@\\d+$`)) || !note.title?.startsWith(`agent-hub task #${m[1]}: `)) return note.text;
56
+ return m[2] === "approved" ? undefined : rest.join("\n").trim() || undefined;
115
57
  }
116
58
 
117
- /**
118
- * Every path-like word of a text (`dir/file`, `file.ext`), whether or not it exists or a denylist hides it from the
119
- * graph: what a private-path policy is checked against.
120
- */
121
- export const pathCandidates = (text: string): string[] => [...new Set(text.normalize("NFC").match(/\/?[\p{L}\p{M}\p{N}_.~-]+(?:\/[\p{L}\p{M}\p{N}_.~-]+)+|[\p{L}\p{M}\p{N}_-]+\.\p{L}[\p{L}\p{M}\p{N}_]{0,9}/gu) ?? [])]; // any script, combining marks included; an absolute path keeps its slash
122
-
123
- /**
124
- * Whether a note is about a path the `pii.paths` policy makes private: a stored node's path, a path-like word of its
125
- * text, or, for a policy entry with no glob that a word split cannot find (a space in it), that entry written out.
126
- */
127
- export function mentionsPrivatePath(text: string, about: string[], policy: string[], cwd: string): boolean {
128
- if (!policy.length) return false;
129
- const nfc = (v: string) => v.normalize("NFC"), body = nfc(text), entries = policy.map(nfc);
130
- const paths = [...about.map(nodePath), ...pathCandidates(body)].filter((p): p is string => p !== undefined).map(nfc);
131
- if (paths.some((p) => matchesPolicy(cwd, p, entries, true))) return true;
132
- // The entry's literal part (up to its first glob character) written out: catches a path a word split breaks (a space).
133
- return entries.some((entry) => { const lit = entry.replace(/^\.\//, "").split(/[*?[\]{}]/)[0]!.replace(/\/+$/, ""); return /[/\s]/.test(lit) && body.includes(lit); });
59
+ export const RECALL_LINE_CHARS = 200;
60
+ type RecallNote = Pick<MemoryView, "id" | "kind" | "tier" | "title" | "text"> & Partial<Pick<MemoryView, "peer" | "task" | "about">>;
61
+
62
+ /** A complete-word excerpt with its memory reference intact, capped in Unicode code points. */
63
+ export function recallMemoryLine(note: RecallNote, idChars = 8, stale = false): string | undefined {
64
+ const text = recallDisplayText(note);
65
+ if (text === undefined) return undefined;
66
+ // A rejection's title is another outcome header; only its actionable detail belongs in this view.
67
+ const body = `${stale ? "(stale?) " : ""}${text === note.text && note.title ? `${note.title}: ` : ""}${text}`.replace(/\s+/g, " ").trim();
68
+ const header = `mem:${note.id.slice(0, idChars)} ${note.kind} ${note.tier}: `;
69
+ const chars = Array.from(body), available = RECALL_LINE_CHARS - header.length;
70
+ if (chars.length <= available) return header + body;
71
+ const count = available - 4;
72
+ const prefix = chars.slice(0, count).join("");
73
+ const whole = chars[count] === " " || prefix.endsWith(" ") ? prefix.trimEnd() : prefix.slice(0, Math.max(0, prefix.lastIndexOf(" ")));
74
+ return header + (whole ? `${whole} ...` : "...");
134
75
  }
135
76
 
136
- /** The file a graph node names: `path:<p>` and `symbol:<name>@<p>`; nothing for other nodes. */
137
- export const nodePath = (n: string): string | undefined => n.startsWith("path:") ? n.slice(5) : n.startsWith("symbol:") && n.includes("@") ? n.slice(n.lastIndexOf("@") + 1) : undefined;
138
-
139
- /**
140
- * Notes whose title, text, nodes, task key or project key match the PII patterns of `routing`, or that are about a path
141
- * the project's `pii.paths` (`policy`) makes private, left out: what may go to a model.
142
- */
143
- export function withoutPii<T extends { title: string | null; text: string; about?: string[]; task?: string | null; project?: string }>(notes: T[], routing: Routing, cwd: string, policy: string[] = []): T[] {
144
- // A task key's `#<id>@<created ms>` is the hub's own numbers, not note content: a digit-run pattern must not withhold a
145
- // clean note for it. The project part of the key stays screened.
146
- const keyText = (k: string) => k.replace(/#\d+(?:@\d+)?$/, "");
147
- return notes.filter((v) => !mentionsPrivatePath(`${v.title ?? ""}\n${v.text}`, v.about ?? [], policy, cwd) && !detectSignals({ title: v.title ?? "",
148
- detail: [v.text, ...(v.about ?? []).map((n) => n.startsWith("task:") ? keyText(n) : n), keyText(v.task ?? ""), v.project ?? ""].join("\n"), refs: {} }, routing, cwd).includes("pii"));
77
+ /** Filter before the display cap, so outcome noise cannot displace an older relevant contract or fail. */
78
+ export function recallMemoryLines(notes: readonly RecallNote[], limit = 30, stale: (about: readonly string[]) => boolean = () => false): string[] {
79
+ const prefixes = new Map<string, string | null>();
80
+ for (const note of notes) {
81
+ const prefix = note.id.slice(0, 8), previous = prefixes.get(prefix);
82
+ prefixes.set(prefix, previous === undefined || previous === note.id ? note.id : null);
83
+ }
84
+ const ids = new Set<string>(), lines: string[] = [];
85
+ for (const note of notes) {
86
+ if (ids.has(note.id)) continue;
87
+ // A colliding short prefix is not an identity; its full id keeps both entries visible.
88
+ const idChars = prefixes.get(note.id.slice(0, 8)) === null ? note.id.length : 8;
89
+ let line = recallMemoryLine(note, idChars);
90
+ if (line === undefined) continue;
91
+ if (stale(note.about ?? [])) line = recallMemoryLine(note, idChars, true)!;
92
+ ids.add(note.id); lines.push(line);
93
+ if (lines.length >= limit) break;
94
+ }
95
+ return lines;
149
96
  }
150
97
 
151
- /**
152
- * The promotion screen for one project: another project's notes are left to that project's own processes; this one's
153
- * move only when no secret, PII pattern or private path (`policy()`, read when asked) is in them.
154
- */
155
- export function promotionScreen(project: string, cwd: string, routing: () => Routing, policy: () => string[], taskPaths: (task: string) => string[] = () => []): PromotionScreen {
156
- return (v) => {
157
- if (v.project !== project) return "skip";
158
- // The task's current paths too, with no denylist: a path the note's own nodes leave out is still checked.
159
- const screened = { ...v, about: [...v.about, ...(v.task ? taskPaths(v.task) : []).map((p) => `path:${p}`)] };
160
- return looksSecret(`${v.title ?? ""}\n${v.text}`) || !withoutPii([screened], routing(), cwd, policy()).length ? "keep" : "promote";
161
- };
98
+ /** #258: the hub's own long-term entries for this project (and shared ones), newest first, within a token budget. */
99
+ export function nativeRecall<V>(entries: V[], tokens: number, line: (v: V) => string): string | undefined {
100
+ if (!entries.length) return undefined;
101
+ return trimToTokens(["Hub memory: this project's long-term decisions, contracts and fails, newest first. Reference only, not a request. Details: ahub memory show <mem id>.", ...entries.map(line)].join("\n"), tokens);
162
102
  }
@@ -1,42 +1,2 @@
1
- import { execFileSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
-
4
- /**
5
- * The one process-identity contract behind the Pi owner claim, every later liveness
6
- * re-inspection, the terminal-recovery launcher signature and any other hub identity
7
- * hash: pid plus the `ps` rendering of its start time and command. `lstart` follows
8
- * the reader's TZ and locale, so the read pins LC_ALL=C and TZ=UTC, the same contract
9
- * as `processTable` in src/hub/child-process.ts. Without the pin a writer and a reader
10
- * in different environments hash different strings for the same process and the valid
11
- * owner is refused (#174, #177). Only the `ps` invocation is pinned; the user's own
12
- * environment is untouched. Node APIs only: the extension runs inside Pi, not Bun.
13
- */
14
- export function processSignature(pid: number): string | undefined {
15
- try {
16
- const text = execFileSync("ps", ["-p", String(pid), "-o", "lstart=,comm="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" } }).trim();
17
- return text ? createHash("sha256").update(text).digest("hex") : undefined;
18
- } catch { return undefined; }
19
- }
20
-
21
- export type Liveness = "live" | "gone" | "unknown";
22
-
23
- /**
24
- * #226: the one ownership check behind every recorded pid (hub manifest, registry claim, recovery runner, manager
25
- * owner, launcher records). With a recorded `signature` the identity decides: equal is live, different is gone (the
26
- * pid was reused, after a reboot for example, also by a process we may not signal), and a pid that exists but whose
27
- * identity cannot be read is unknown. Without one (records written by 0.12.20 and older) the pid probe answers as it
28
- * always did: live, gone on ESRCH, unknown otherwise. Unknown is never gone.
29
- */
30
- // ponytail: `ps lstart` is derived from boot time plus the process's age; a wall-clock step on Linux can change it for a
31
- // live process, which then reads as gone (a second hub could take its claim). Upgrade path: a boot-id or start-tick
32
- // (Linux /proc/<pid>/stat field 22) component in the signature.
33
- export function processLiveness(pid: unknown, signature?: string | null, identity: (pid: number) => string | undefined = processSignature): Liveness {
34
- if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return "unknown";
35
- if (signature) {
36
- let current: string | undefined;
37
- try { current = identity(pid); } catch { /* unreadable: the pid probe below decides between gone and unknown */ }
38
- if (current) return current === signature ? "live" : "gone";
39
- }
40
- try { process.kill(pid, 0); return signature ? "unknown" : "live"; }
41
- catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? "gone" : "unknown"; }
42
- }
1
+ // Pi ownership and memory sync locks share the same Node-safe core identity contract.
2
+ export { processSignature, processLiveness, type Liveness } from "@staix/agent-mem/platform";
package/src/ui/index.html CHANGED
@@ -19,7 +19,7 @@ const dashboardTheme = (() => {
19
19
  </script>
20
20
  <style>
21
21
  :root{color-scheme:light;--waiting:#795399;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--panel:#ffffff;--panel-soft:#f4f6f2;--control:#ffffff;--control-border:#bbc4bb;--hover:#f0f4ef;--green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--dot:#98a29b;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;--focus:#245f45;--on-accent:#ffffff}:root[data-theme="dark"]{color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]):not([data-theme="dark"]){color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:var(--panel);border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);gap:12px}.section-head .filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.content{padding:16px 18px}.empty{color:var(--muted);padding:20px 18px}.row{padding:14px 18px;border-bottom:1px solid var(--line)}.row:last-child{border-bottom:0}.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.peername{display:flex;align-items:center;gap:8px;min-width:0}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:2px 7px;background:var(--panel-soft);color:var(--muted);border-radius:4px;white-space:nowrap}.badge.ok{background:var(--green-soft);color:var(--green)}.badge.warn{color:var(--amber);background:var(--amber-soft)}.badge.info{color:var(--blue);background:var(--blue-soft)}.badge.err{color:var(--red);background:var(--red-soft)}.dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--dot)}.dot.ok{background:var(--dot-ok)}.dot.warn{background:var(--dot-warn)}.dot.err{background:var(--dot-err)}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:999px;padding:4px 10px;font-size:12px;cursor:pointer;line-height:1.2}.chip:hover{background:var(--hover)}.chip[aria-pressed="true"]{background:var(--green);color:var(--on-accent);border-color:var(--green);font-weight:600}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--green);color:var(--on-accent);border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--focus);outline-offset:3px}input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:5px;padding:8px;background:var(--control);color:var(--ink)}input[type="checkbox"]{width:auto;margin-right:6px}textarea{resize:vertical;min-height:78px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px}form .field{margin-bottom:12px}.actions select{width:auto;max-width:220px}.section-head .filters select{width:auto;max-width:220px}.body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:7px}.stream{height:360px;overflow:auto}.event{padding:12px 18px;border-bottom:1px solid var(--line)}.event[hidden]{display:none}.event .body{font:13px/1.55 ui-monospace,monospace}.notice{grid-column:1/-1;margin:0;border:1px solid var(--line);border-radius:6px;padding:10px 14px;background:var(--panel);overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:var(--red)}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}progress.warn{accent-color:var(--amber)}progress.warn::-webkit-progress-value{background:var(--amber)}progress.critical{accent-color:var(--red)}progress.critical::-webkit-progress-value{background:var(--red)}progress::-moz-progress-bar{background:var(--green)}progress.warn::-moz-progress-bar{background:var(--amber)}progress.critical::-moz-progress-bar{background:var(--red)}.budget-window+.budget-window{margin-top:14px}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}details summary{cursor:pointer;padding:14px 18px;color:var(--green);font-weight:600}details form{padding:0 18px 18px}.task-list{max-height:460px;overflow:auto}footer{max-width:1440px;margin:auto;padding:0 32px 24px;color:var(--muted);font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}.stack{gap:18px}header,main{padding:18px}.stream{height:300px}footer{padding:0 18px 18px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
22
- .task-progress{padding:12px 18px;border-bottom:1px solid var(--line)}.task-progress svg{display:block;width:100%;height:12px;margin:8px 0}.progress-proposed{fill:var(--dot)}.progress-unknown{fill:var(--dot)}.progress-waiting{fill:var(--waiting)}.progress-in_progress{fill:var(--blue)}.progress-in_review{fill:var(--amber)}.progress-changes_requested{fill:var(--red)}.progress-approved{fill:var(--green)}.task-stage{font:12px ui-monospace,monospace;margin:8px 0;color:var(--muted)}.task-stage .reached{color:var(--green)}.task-stage.changes{color:var(--red)}.theme-choice{width:auto}.actions input[type="text"]{width:auto;flex:1 1 160px;max-width:320px}details.settings-group>summary{padding:12px 18px;cursor:pointer;border-bottom:1px solid var(--line);font-weight:600}details.settings-group:last-child:not([open])>summary{border-bottom:0}.preview{margin-top:8px;padding:8px 10px;background:var(--panel-soft);border-radius:5px;font:12px ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.preview:empty{display:none}.memory-controls{display:flex;gap:8px;flex-wrap:wrap;padding:12px 18px;border-bottom:1px solid var(--line)}.memory-controls input,.memory-controls select{width:auto;flex:1 1 130px}.memory-row{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:10px 18px;background:var(--panel)}.memory-row[aria-pressed="true"]{background:var(--green-soft)}.memory-graph svg{display:block;width:100%;height:320px;background:var(--panel-soft)}.medge{stroke:var(--dot);stroke-width:1.5}.medge.rules_out{stroke:var(--red)}.medge.supersedes{stroke-dasharray:4 3}.medge.suggested{stroke-dasharray:2 4;opacity:.75}.medge.contradicts{stroke:var(--amber)}.mnode{cursor:pointer}.mnode circle{fill:var(--dot);stroke:var(--panel);stroke-width:2}.mnode.memory circle{fill:var(--blue)}.mnode.fail circle{fill:var(--red)}.mnode.decision circle{fill:var(--green)}.mnode.contract circle{fill:var(--amber)}.mnode.selected circle{stroke:var(--focus);stroke-width:3}.mnode:focus-visible circle{stroke:var(--focus);stroke-width:4}.mnode text{font:11px system-ui,sans-serif;fill:var(--ink)}.memory-edges{margin:0;padding:8px 18px 12px 34px;font:12px ui-monospace,monospace;color:var(--muted);max-height:160px;overflow:auto}
22
+ .task-progress{padding:12px 18px;border-bottom:1px solid var(--line)}.task-progress svg{display:block;width:100%;height:12px;margin:8px 0}.progress-proposed{fill:var(--dot)}.progress-unknown{fill:var(--dot)}.progress-cancelled{fill:var(--muted)}.progress-waiting{fill:var(--waiting)}.progress-in_progress{fill:var(--blue)}.progress-in_review{fill:var(--amber)}.progress-changes_requested{fill:var(--red)}.progress-approved{fill:var(--green)}.task-stage{font:12px ui-monospace,monospace;margin:8px 0;color:var(--muted)}.task-stage .reached{color:var(--green)}.task-stage.changes{color:var(--red)}.task-stage.cancelled{color:var(--muted)}.theme-choice{width:auto}.actions input[type="text"]{width:auto;flex:1 1 160px;max-width:320px}details.settings-group>summary{padding:12px 18px;cursor:pointer;border-bottom:1px solid var(--line);font-weight:600}details.settings-group:last-child:not([open])>summary{border-bottom:0}.preview{margin-top:8px;padding:8px 10px;background:var(--panel-soft);border-radius:5px;font:12px ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.preview:empty{display:none}.memory-controls{display:flex;gap:8px;flex-wrap:wrap;padding:12px 18px;border-bottom:1px solid var(--line)}.memory-controls input,.memory-controls select{width:auto;flex:1 1 130px}.memory-row{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:10px 18px;background:var(--panel)}.memory-row[aria-pressed="true"]{background:var(--green-soft)}.memory-graph svg{display:block;width:100%;height:320px;background:var(--panel-soft)}.medge{stroke:var(--dot);stroke-width:1.5}.medge.rules_out{stroke:var(--red)}.medge.supersedes{stroke-dasharray:4 3}.medge.suggested{stroke-dasharray:2 4;opacity:.75}.medge.contradicts{stroke:var(--amber)}.mnode{cursor:pointer}.mnode circle{fill:var(--dot);stroke:var(--panel);stroke-width:2}.mnode.memory circle{fill:var(--blue)}.mnode.fail circle{fill:var(--red)}.mnode.decision circle{fill:var(--green)}.mnode.contract circle{fill:var(--amber)}.mnode.selected circle{stroke:var(--focus);stroke-width:3}.mnode:focus-visible circle{stroke:var(--focus);stroke-width:4}.mnode text{font:11px system-ui,sans-serif;fill:var(--ink)}.memory-edges{margin:0;padding:8px 18px 12px 34px;font:12px ui-monospace,monospace;color:var(--muted);max-height:160px;overflow:auto}
23
23
  /* PEER_PALETTE_CSS */
24
24
  .task-board-section{grid-column:1/-1}.board-columns{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;padding:18px}.board-column{min-width:0;background:var(--panel-soft);border:1px solid var(--line);border-radius:6px;padding:10px}.board-column h3{font-size:14px;margin:0 0 12px}.board-column h4{font-size:12px;color:var(--muted);margin:12px 0 8px}.task-card{background:var(--panel);border:1px solid var(--line);border-radius:5px;padding:12px;margin:0 0 10px;overflow-wrap:anywhere}.task-card .row-head{align-items:flex-start}.task-card .label{font-size:14px}.task-card .actions{display:grid;gap:8px}.task-card .actions select{max-width:none;width:100%}.task-card button,.task-card select,.agent-legend button{min-height:44px}.task-card .body{font-size:13px}.task-card .task-stage{font-size:12px}.board-columns .empty{padding:8px 0}.board-lane{border-top:1px solid var(--line)}.board-lane>h3{padding:14px 18px 0;font-size:15px}.peer-mark{display:inline-flex;align-items:center;gap:6px}.peer-color-dot{width:9px;height:9px;display:inline-block;border-radius:50%;background:var(--peer-color);flex-shrink:0}.peer-color-dot.reviewer{background:transparent;border:2px solid var(--peer-color)}.agent-legend{display:flex;flex-wrap:wrap;gap:8px;padding:14px 18px;border-bottom:1px solid var(--line)}.agent-legend .agent-slot{border:1px solid var(--line);border-radius:5px;padding:10px;flex:1 1 180px;min-width:0}.agent-legend .meta{overflow-wrap:anywhere}.board-drop-active{outline:2px solid var(--green);outline-offset:2px}.board-toolbar{padding:12px 18px;display:flex;gap:14px;flex-wrap:wrap;align-items:center}.board-toolbar label{margin:0}.board-toolbar select{width:auto;max-width:220px}.board-unknown{padding:12px 18px;color:var(--amber)}.task-list{max-height:none;overflow:visible}.progress-backlog{fill:var(--dot)}
25
25
  @media(max-width:1100px){.board-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.split{grid-template-columns:minmax(0,1fr)}.board-columns{grid-template-columns:1fr;padding:12px}.board-toolbar{align-items:stretch}.board-toolbar select{max-width:none;width:100%}.board-toolbar label{width:100%}}
@@ -48,7 +48,7 @@ const dashboardTheme = (() => {
48
48
  </nav>
49
49
  <main>
50
50
  <p id="notice" class="notice" role="status" aria-live="polite"></p>
51
- <section class="task-board-section" aria-labelledby="tasks-title" data-view="board"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="backlog">Backlog</option><option value="proposed">To Do</option><option value="work">Work (including rework)</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="task-progress" class="task-progress" aria-live="polite"></div><div class="board-toolbar"><button id="board-back" type="button" hidden>Back to board</button><span id="board-focus-title" class="meta" hidden></span><label for="task-peer-filter">Agent<select id="task-peer-filter"><option value="">All agents</option></select></label><label for="task-release-filter">Release<select id="task-release-filter"><option value="">All releases</option></select></label><label for="task-lanes">View<select id="task-lanes"><option value="columns">By state</option><option value="agents">By agent</option><option value="releases">By release</option></select></label></div><div id="task-legend" class="agent-legend" role="group" aria-label="Agent legend and assignment targets"></div><div id="tasks" class="task-list"></div><details><summary>Plan a task</summary><form id="planning"><div class="field"><label for="planning-task">Task</label><select id="planning-task"><option value="">Select task</option></select></div><div class="split"><label>Priority<select id="planning-priority"><option value="">Default</option><option value="0">P0</option><option value="1">P1</option><option value="2">P2</option><option value="3">P3</option></select></label><label>Order<input id="planning-rank" type="number" min="0" step="1"></label><label>Release<select id="planning-release"><option value="">No release</option></select></label><label>Planned start<input id="planning-start" type="datetime-local" step="any"></label><label>Due<input id="planning-due" type="datetime-local" step="any"></label></div><label><input id="planning-pinned" type="checkbox">Pin against planner changes</label><label>Reason<input id="planning-reason" maxlength="1000" required></label><div class="actions"><button id="planning-save" type="submit" data-mutation="project">Save plan</button><button id="planning-undo" type="button" data-mutation="project">Undo latest plan</button><button id="planning-reload" type="button">Reload current values</button></div><p id="planning-version" class="meta"></p></form></details><details id="split-panel" hidden><summary>Split the selected backlog task</summary><form id="split-backlog"><label>Part titles, one per line<textarea id="split-titles" maxlength="2408" required></textarea></label><label><input id="split-sequential" type="checkbox">Each part waits for the previous part</label><label>Reason<input id="split-reason" maxlength="1000" required></label><p class="meta">Two to eight parts. The original task retains integration and acceptance. Unused parts can be undone from its current plan.</p><button id="split-save" type="submit" data-mutation="project">Stage parts</button></form></details><details><summary>Releases</summary><div id="releases" class="content"></div><form id="release-new"><div class="split"><label>Name<input id="release-name" maxlength="64" required></label><label>Order<input id="release-order" type="number" min="0" value="0" step="1" required></label></div><label>Goal<input id="release-goal" maxlength="1000" required></label><button type="submit" data-mutation="project">Create release</button></form><form id="release-plan"><label>Release<select id="release-edit"><option value="">Select release</option></select></label><div class="split"><label>Planned start<input id="release-start" type="datetime-local" step="any"></label><label>Cut<input id="release-cut" type="datetime-local" step="any"></label><label>Ship<input id="release-ship" type="datetime-local" step="any"></label></div><button id="release-save" type="submit" data-mutation="project">Save dates</button><button id="release-reload" type="button">Reload current release</button><p id="release-version" class="meta"></p></form></details><details id="delivery"><summary>Delivery evidence (read-only)</summary><div class="field"><label for="delivery-repo">GitHub repository</label><input id="delivery-repo" maxlength="200" placeholder="owner/repository"></div><div class="field"><label for="delivery-live">Live verification issue IDs (optional)</label><input id="delivery-live" maxlength="300" placeholder="Comma-separated issue numbers"></div><div class="actions"><button id="delivery-queue" type="button">Read merge queue</button><button id="delivery-release" type="button">Read release checklist</button></div><pre id="delivery-report" class="body" aria-live="polite"></pre></details><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><label><input id="backlog-proposal" type="checkbox"> Add to Backlog without offering</label><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
51
+ <section class="task-board-section" aria-labelledby="tasks-title" data-view="board"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="backlog">Backlog</option><option value="proposed">To Do</option><option value="work">Work (including rework)</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="cancelled">Cancelled</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="task-progress" class="task-progress" aria-live="polite"></div><div class="board-toolbar"><button id="board-back" type="button" hidden>Back to board</button><span id="board-focus-title" class="meta" hidden></span><label for="task-peer-filter">Agent<select id="task-peer-filter"><option value="">All agents</option></select></label><label for="task-release-filter">Release<select id="task-release-filter"><option value="">All releases</option></select></label><label for="task-lanes">View<select id="task-lanes"><option value="columns">By state</option><option value="agents">By agent</option><option value="releases">By release</option></select></label></div><div id="task-legend" class="agent-legend" role="group" aria-label="Agent legend and assignment targets"></div><div id="tasks" class="task-list"></div><details><summary>Plan a task</summary><form id="planning"><div class="field"><label for="planning-task">Task</label><select id="planning-task"><option value="">Select task</option></select></div><div class="split"><label>Priority<select id="planning-priority"><option value="">Default</option><option value="0">P0</option><option value="1">P1</option><option value="2">P2</option><option value="3">P3</option></select></label><label>Order<input id="planning-rank" type="number" min="0" step="1"></label><label>Release<select id="planning-release"><option value="">No release</option></select></label><label>Planned start<input id="planning-start" type="datetime-local" step="any"></label><label>Due<input id="planning-due" type="datetime-local" step="any"></label></div><label><input id="planning-pinned" type="checkbox">Pin against planner changes</label><label>Reason<input id="planning-reason" maxlength="1000" required></label><div class="actions"><button id="planning-save" type="submit" data-mutation="project">Save plan</button><button id="planning-undo" type="button" data-mutation="project">Undo latest plan</button><button id="planning-reload" type="button">Reload current values</button></div><p id="planning-version" class="meta"></p></form></details><details id="split-panel" hidden><summary>Split the selected backlog task</summary><form id="split-backlog"><label>Part titles, one per line<textarea id="split-titles" maxlength="2408" required></textarea></label><label><input id="split-sequential" type="checkbox">Each part waits for the previous part</label><label>Reason<input id="split-reason" maxlength="1000" required></label><p class="meta">Two to eight parts. The original task retains integration and acceptance. Unused parts can be undone from its current plan.</p><button id="split-save" type="submit" data-mutation="project">Stage parts</button></form></details><details><summary>Releases</summary><div id="releases" class="content"></div><form id="release-new"><div class="split"><label>Name<input id="release-name" maxlength="64" required></label><label>Order<input id="release-order" type="number" min="0" value="0" step="1" required></label></div><label>Goal<input id="release-goal" maxlength="1000" required></label><button type="submit" data-mutation="project">Create release</button></form><form id="release-plan"><label>Release<select id="release-edit"><option value="">Select release</option></select></label><div class="split"><label>Planned start<input id="release-start" type="datetime-local" step="any"></label><label>Cut<input id="release-cut" type="datetime-local" step="any"></label><label>Ship<input id="release-ship" type="datetime-local" step="any"></label></div><button id="release-save" type="submit" data-mutation="project">Save dates</button><button id="release-reload" type="button">Reload current release</button><p id="release-version" class="meta"></p></form></details><details id="delivery"><summary>Delivery evidence (read-only)</summary><div class="field"><label for="delivery-repo">GitHub repository</label><input id="delivery-repo" maxlength="200" placeholder="owner/repository"></div><div class="field"><label for="delivery-live">Live verification issue IDs (optional)</label><input id="delivery-live" maxlength="300" placeholder="Comma-separated issue numbers"></div><div class="actions"><button id="delivery-queue" type="button">Read merge queue</button><button id="delivery-release" type="button">Read release checklist</button></div><pre id="delivery-report" class="body" aria-live="polite"></pre></details><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><label><input id="backlog-proposal" type="checkbox"> Add to Backlog without offering</label><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
52
52
  <div class="stack">
53
53
  <section id="office-section" aria-labelledby="office-title" data-view="office" hidden><div class="section-head"><h2 id="office-title">Office</h2><div class="filters"><label for="office-view">Office mode</label><select id="office-view"><option value="off">Off</option><option value="flat">Flat</option><option value="isometric">Isometric</option></select></div></div><div id="office" class="content"></div></section>
54
54
  <section aria-labelledby="stream-title" data-view="stream" hidden><div class="section-head"><h2 id="stream-title">Live stream</h2><div class="filters"><div id="stream-filters" class="chips" role="group" aria-label="Filter events by kind"></div><label><input id="follow" type="checkbox" checked> Follow latest</label></div></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div><p id="stream-empty" class="empty" hidden>No events match this filter.</p></section>
@@ -128,7 +128,7 @@ const rel = (value) => {
128
128
  };
129
129
  const badge = (text, cls = '') => { const node = el('span', text, 'badge' + (cls ? ' ' + cls : '')); return node; };
130
130
  const dot = (cls) => el('span', undefined, 'dot ' + cls);
131
- const taskBadge = { proposed: '', in_progress: 'warn', in_review: 'info', approved: 'ok', changes_requested: 'err' };
131
+ const taskBadge = { proposed: '', in_progress: 'warn', in_review: 'info', approved: 'ok', cancelled: '', changes_requested: 'err' };
132
132
  const peerDot = { idle: 'ok', busy: 'warn', paused: 'warn', offline: '' };
133
133
  const projectBadge = { running: 'ok', starting: 'warn', stopping: 'warn', stopped: '', unavailable: 'err', incompatible: 'err', missing: 'err' };
134
134
  function refreshRelativeTimes(root) { for (const node of root.querySelectorAll('[data-ts]')) node.textContent = rel(Number(node.dataset.ts)); }
@@ -278,6 +278,7 @@ function renderTaskProgress(progress) {
278
278
  });
279
279
  }
280
280
  function renderTaskStage(stage) {
281
+ if (stage.cancelled === true) return el('div', 'Cancelled (closed)', 'task-stage cancelled');
281
282
  const row = el('div', undefined, 'task-stage' + (stage.changes ? ' changes' : ''));
282
283
  for (const [index, label] of ['Proposed', 'In progress', 'In review', 'Approved'].entries()) row.append(el('span', `${index < stage.stage ? '[x]' : '[ ]'} ${label}${index < 3 ? ' > ' : ''}`, index < stage.stage ? 'reached' : ''));
283
284
  row.append(el('span', ` / Stage ${stage.stage}/4: ${stage.label}`)); return row;
@@ -450,6 +451,7 @@ let boardViewState = null, showAllDone = false;
450
451
  const BOARD_FOCUS_COLUMNS = ['backlog','todo','progress','review','done'];
451
452
  let columnFocus, taskDetailId = null;
452
453
  let planningEdit = null, releaseEdit = null;
454
+ function isClosedTaskState(state) { return state === 'approved' || state === 'cancelled'; }
453
455
  const loadedPlanningDates = new Map();
454
456
  const localDateInput = value => {
455
457
  if(!Number.isSafeInteger(value) || value<=0 || value>8_640_000_000_000_000)return '';
@@ -493,18 +495,18 @@ function renderPlanning(snapshot) {
493
495
  const releases=Array.isArray(snapshot.releases)?snapshot.releases:[], releaseRows=releases.map(release=>({id:release.id,label:'#'+release.id+' '+release.name}));
494
496
  for(const id of ['task-release-filter','planning-release','release-edit'])planningOptions($(id),releaseRows,id==='task-release-filter'?'All releases':'No release',$(id).value);
495
497
  if(releaseEdit && (!sameBoardContext(releaseEdit.context) || releases.find(release=>release.id===releaseEdit.id)?.revision!==releaseEdit.revision)){releaseEdit=null;$('release-version').textContent='Release changed. Reload before saving.';}
496
- const selection=$('planning-task').value;planningOptions($('planning-task'),snapshot.tasks.filter(task=>task.state!=='approved').map(task=>({id:task.id,label:'#'+task.id+' '+task.title})),'Select task',selection);
497
- if(planningEdit && (!sameBoardContext(planningEdit.context) || !snapshot.tasks.some(task=>task.id===planningEdit.id)))planningEdit=null;
498
+ const selection=$('planning-task').value;planningOptions($('planning-task'),snapshot.tasks.filter(task=>!isClosedTaskState(task.state)).map(task=>({id:task.id,label:'#'+task.id+' '+task.title})),'Select task',selection);
499
+ if(planningEdit && (!sameBoardContext(planningEdit.context) || !snapshot.tasks.some(task=>task.id===planningEdit.id && !isClosedTaskState(task.state))))planningEdit=null;
498
500
  if(planningEdit && snapshot.tasks.find(task=>task.id===planningEdit.id)?.planningRevision!==planningEdit.revision)$('planning-version').textContent='Task changed. Reload before saving.';
499
501
  const states=new Map(snapshot.tasks.map(task=>[task.id,task.state]));
500
502
  update('releases',{releases,taskStates:[...states]},target=>{
501
503
  if(!releases.length)return empty(target,'No releases planned.');
502
504
  const auditIds=(ids,count)=>ids.map(id=>'#'+id).join(', ')+(Number.isSafeInteger(count)&&count>ids.length?' (showing '+ids.length+' of '+count+')':'');
503
505
  for(const release of releases){const row=el('div',undefined,'row');row.append(el('strong','#'+release.id+' '+release.name),badge(release.state),el('p',release.goal));
504
- const scope=Array.isArray(release.tasks)?release.tasks:null, remaining=scope?.filter(task=>task.state!=='approved');
506
+ const scope=Array.isArray(release.tasks)?release.tasks:null, remaining=scope?.filter(task=>!isClosedTaskState(task.state));
505
507
  const blocking=remaining?.filter(task=>(task.deps || []).some(id=>states.get(id)!=='approved'));
506
508
  row.append(el('p',scope?remaining.length+' remaining of '+scope.length+' / '+blocking.length+' blocked':'Scope unknown','meta'));
507
- if(scope){const progress=el('progress');progress.max=Math.max(1,scope.length);progress.value=scope.length-remaining.length;progress.setAttribute('aria-label','Approved tasks in '+release.name);row.append(progress)}
509
+ if(scope){const progress=el('progress');progress.max=Math.max(1,scope.length);progress.value=scope.length-remaining.length;progress.setAttribute('aria-label','Closed tasks in '+release.name);row.append(progress)}
508
510
  row.append(el('p','Start '+dateText(release.plannedStart)+' / cut '+dateText(release.cut)+' / ship '+dateText(release.ship),'meta'));
509
511
  row.append(el('p','Actual: active '+dateText(release.activatedAt)+' / frozen '+dateText(release.frozenAt)+' / shipped '+dateText(release.shippedAt),'meta'));
510
512
  if(Array.isArray(release.afterCutUnknown) && release.afterCutUnknown.length)row.append(el('p','Timing relative to release cut unknown: '+auditIds(release.afterCutUnknown,release.afterCutUnknownCount),'meta'));
@@ -634,7 +636,7 @@ function refreshTaskDetail() {
634
636
  if (!task || !card) return closeTaskDetail();
635
637
  const stage = taskProgress(snapshot.tasks).stages.find(stage => stage.id === taskDetailId);
636
638
  const ids = boardViewState.ids.filter(id => ['idle','busy'].includes(boardViewState.peers[id]?.state) && boardViewState.peers[id]?.attached !== false && boardViewState.peers[id]?.rostered !== false);
637
- const overdue = task.due != null && task.due < Date.now() && task.state !== 'approved';
639
+ const overdue = task.due != null && task.due < Date.now() && !isClosedTaskState(task.state);
638
640
  $('task-detail-title').textContent = 'Task #' + task.id;
639
641
  update('task-detail-content', [task, card, stage, ids, overdue, boardContext()], target => target.append(taskDetail(task, card, stage, ids, overdue)));
640
642
  }
@@ -653,7 +655,7 @@ function renderBoard(snapshot, peers, ids) {
653
655
  renderTaskProgress(progress);
654
656
  const stages = new Map(progress.stages.map(stage=>[stage.id,stage])), byId = new Map(snapshot.tasks.map(task=>[task.id,task]));
655
657
  const cards = new Map(model.columns.flatMap(column=>[...column.ready,...column.waiting]).map(card=>[card.id,card]));
656
- const now = Date.now(), overdue = new Set(snapshot.tasks.filter(task=>task.due != null && task.due<now && task.state!=='approved').map(task=>task.id));
658
+ const now = Date.now(), overdue = new Set(snapshot.tasks.filter(task=>task.due != null && task.due<now && !isClosedTaskState(task.state)).map(task=>task.id));
657
659
  boardViewState={snapshot,peers,ids,cards};
658
660
  renderPlanning(snapshot);
659
661
  const staged=$('backlog-proposal');staged.disabled=snapshot.boardCapabilities?.backlog!==true || !sameBoardContext(boardContext());
@@ -756,6 +758,7 @@ function render(snapshot) {
756
758
  const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
757
759
  name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
758
760
  head.append(name, badge(peer.rostered === false ? peer.state + ' off roster' : peer.state, peerDot[peer.state] || ''));
761
+ if (peer.planner) name.append(badge('Planner' + (peer.planner === 'setting' ? '' : ' (' + peer.planner + ')'), ''));
759
762
  row.append(head, el('p', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'));
760
763
  if (peer.flowRoles) row.append(el('p', 'Flow roles: ' + (peer.flowRoles.join(', ') || 'none'), 'meta'));
761
764
  if (peer.catalogRoles) { row.append(el('p', 'Catalog guidance: ' + (Array.isArray(peer.catalogRoles.value) ? peer.catalogRoles.value.join(', ') || 'none' : 'unconfirmed'), 'meta')); if (peer.catalogRoles.pending !== undefined) row.append(el('p', 'Pending at next start: ' + (peer.catalogRoles.pending.join(', ') || 'none'), 'meta')); }
package/src/ui/needs.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type NeedKind = "approval" | "default" | "storage" | "recovery" | "delivery" | "review" | "message" | "budget" | "offline";
1
+ export type NeedKind = "approval" | "default" | "storage" | "recovery" | "delivery" | "review" | "message" | "budget" | "offline" | "stall" | "offer";
2
2
  export interface NeedItem { kind: NeedKind; level: "act" | "watch"; peer?: string; id?: string | number; until?: number; since?: number; }
3
3
  export interface NeedsInput {
4
4
  approvals?: readonly { id: string; peer?: string; expiresAt: number; since?: number }[];
@@ -7,7 +7,7 @@ export interface NeedsInput {
7
7
  storageBlocked?: boolean;
8
8
  recovery?: { id?: string; running?: boolean; since?: number };
9
9
  tasks?: readonly { id: number; state: string; owner?: string | null; reviewer?: string | null }[];
10
- facts?: readonly { kind: "review" | "offline"; id: number; peer?: string }[];
10
+ facts?: readonly { kind: "review" | "offline" | "stall" | "offer"; id: number; peer?: string }[];
11
11
  messages?: readonly { id?: string; peer?: string; important: boolean; since?: number }[];
12
12
  budget?: Readonly<Record<string, { paused?: { since?: number; resetsAt?: number } }>>;
13
13
  }
@@ -38,15 +38,17 @@ export function needs(input: NeedsInput, now: number): Needs {
38
38
  }
39
39
  }
40
40
  for (const fact of input.facts ?? []) if (Number.isSafeInteger(fact.id) && fact.id > 0) {
41
- if (fact.kind === "review") add("review", "act", undefined, fact.id);
41
+ if (fact.kind === "stall") add("stall", "act", undefined, fact.id);
42
+ else if (fact.kind === "offer" && agent(fact.peer)) add("offer", "act", fact.peer, fact.id);
43
+ else if (fact.kind === "review") add("review", "act", undefined, fact.id);
42
44
  else if (fact.kind === "offline" && agent(fact.peer) && !offline.has(fact.peer)) { offline.add(fact.peer); add("offline", "watch", fact.peer, fact.id); }
43
45
  }
44
46
  for (const [peer, budget] of Object.entries(input.budget ?? {})) if (budget.paused) add("budget", "watch", peer, undefined, budget.paused.resetsAt, budget.paused.since);
45
- const order: Record<NeedKind, number> = { approval: 0, default: 1, storage: 2, recovery: 3, delivery: 4, review: 5, message: 6, budget: 7, offline: 8 };
47
+ const order: Record<NeedKind, number> = { approval: 0, default: 1, storage: 2, recovery: 3, delivery: 4, review: 5, stall: 6, offer: 7, message: 8, budget: 9, offline: 10 };
46
48
  items.sort((a,b) => (a.level === "act" ? 0 : 1) - (b.level === "act" ? 0 : 1) || order[a.kind] - order[b.kind] || (a.kind === "approval" ? (a.until ?? Infinity) - (b.until ?? Infinity) : 0) || (a.since ?? 0) - (b.since ?? 0));
47
49
  const act = items.filter(item => item.level === "act").length, watch = items.length - act;
48
50
  const peers = Object.values(input.peers ?? {}), attached = peers.some(peer => peer.attached !== false && ["idle", "busy", "paused"].includes(peer.state ?? ""));
49
- const state: Needs["state"] = items.some(item => item.kind === "approval" || item.kind === "default") ? "waiting" : items.some(item => item.level === "act" && ["storage", "delivery", "recovery"].includes(item.kind)) ? "blocked" : act ? "review" : peers.some(peer => peer.attached !== false && peer.state === "busy") ? "working" : attached ? "idle" : "sleeping";
51
+ const state: Needs["state"] = items.some(item => item.kind === "approval" || item.kind === "default") ? "waiting" : items.some(item => item.level === "act" && ["storage", "delivery", "recovery", "stall"].includes(item.kind)) ? "blocked" : act ? "review" : peers.some(peer => peer.attached !== false && peer.state === "busy") ? "working" : attached ? "idle" : "sleeping";
50
52
  return { items, act, watch, state };
51
53
  }
52
54
 
@@ -68,6 +68,7 @@ export interface OfficeLink {
68
68
  export interface OfficeCounts {
69
69
  proposed: number; waiting: number; in_progress: number; in_review: number; changes_requested: number; approved: number;
70
70
  backlog?: number;
71
+ cancelled?: number;
71
72
  unknown?: number;
72
73
  }
73
74
  /** One task card. Positions are grid units; the renderer maps them into the SVG viewBox. */
@@ -178,7 +179,7 @@ export interface OfficeScene {
178
179
 
179
180
  export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene | null, options: { cols?: number; now?: number }): OfficeScene {
180
181
  const FIXED = ["claude", "codex", "kimi", "pi", "local"];
181
- const KNOWN = ["proposed", "in_progress", "in_review", "changes_requested", "approved"];
182
+ const KNOWN = ["proposed", "in_progress", "in_review", "changes_requested", "approved", "cancelled"];
182
183
  const GLYPHS: Record<string, string> = { idle: "○", busy: "●", offline: "×", paused: "‖", waiting: "⚑", unknown: "?" };
183
184
  const LABELS: Record<string, string> = { idle: "idle", busy: "busy", offline: "offline", paused: "paused", waiting: "waiting on approval" };
184
185
  const DESK_CAP = 12, ROOM_CARDS = 8, DESK_CARDS = 3;
@@ -204,6 +205,7 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
204
205
  for (const task of tasks) {
205
206
  const state = text(task.state);
206
207
  if (state === "backlog") { counts.backlog = (counts.backlog ?? 0) + 1; waitingById.set(task.id, false); continue; }
208
+ if (state === "cancelled") { counts.cancelled = (counts.cancelled ?? 0) + 1; waitingById.set(task.id, false); continue; }
207
209
  if (!KNOWN.includes(state)) { counts.unknown = (counts.unknown ?? 0) + 1; waitingById.set(task.id, false); continue; }
208
210
  const waiting = state === "proposed" && (task.deps ?? []).some((id: number) => states.get(id) !== "approved");
209
211
  waitingById.set(task.id, waiting);
@@ -349,6 +351,7 @@ export function officeModel(snapshot: OfficeSnapshotInput, previous: OfficeScene
349
351
  placed[task.id] = state === "proposed" ? "room:todo"
350
352
  : state === "in_review" ? "room:review"
351
353
  : state === "approved" ? "shelf:done"
354
+ : state === "cancelled" ? "counted:cancelled"
352
355
  : state === "in_progress" || state === "changes_requested" ? (owner && shownSet.has(owner) ? `desk:${owner}` : "room:work")
353
356
  : state === "backlog" ? "counted:backlog" : "counted:unknown";
354
357
  }
@@ -23,7 +23,7 @@ export function boardColumns(tasks: readonly BoardCardInput[], peers: Readonly<R
23
23
  ];
24
24
  const states = new Map(tasks.map(task => [task.id, task.state])), unknown: number[] = [];
25
25
  for (const task of tasks) {
26
- const index = task.state === "backlog" ? 0 : task.state === "proposed" ? 1 : ["in_progress", "changes_requested"].includes(task.state) ? 2 : task.state === "in_review" ? 3 : task.state === "approved" ? 4 : -1;
26
+ const index = task.state === "backlog" ? 0 : task.state === "proposed" ? 1 : ["in_progress", "changes_requested"].includes(task.state) ? 2 : task.state === "in_review" ? 3 : ["approved", "cancelled"].includes(task.state) ? 4 : -1;
27
27
  const column = columns[index];
28
28
  if (!column) { unknown.push(task.id); continue; }
29
29
  const waiting: string[] = [];