@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.
- package/CHANGELOG.md +39 -0
- package/README.md +92 -218
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +3 -2
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +7 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +14 -0
- package/docs/agent-notes/tests.md +6 -3
- package/docs/memory.md +70 -152
- package/docs/operations.md +317 -25
- package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
- package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
- package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
- package/docs/plans/amem-cases.json +482 -0
- package/docs/quickstart.md +24 -2
- package/docs/security.md +42 -2
- package/docs/smoke.md +11 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +142 -7
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
- package/docs/verification/2026-10-17-0.12.30.md +70 -0
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verified.json +13 -13
- package/package.json +5 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +129 -62
- package/src/adapters/claude-channel.ts +29 -11
- package/src/adapters/local-worker.ts +2 -2
- package/src/adapters/pi.ts +5 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +145 -29
- package/src/cli/console.ts +40 -10
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +4 -3
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/main.js +2 -2
- package/src/cli/main.ts +43 -14
- package/src/cli/memory.ts +87 -187
- package/src/cli/output.ts +19 -2
- package/src/cli/recovery-package.ts +59 -10
- package/src/cli/upgrade-interactive.ts +2 -1
- package/src/cli/upgrade-restore-state.ts +133 -0
- package/src/cli/upgrade-runtime.ts +204 -63
- package/src/cli/upgrade.ts +57 -24
- package/src/hub/board.ts +83 -24
- package/src/hub/bus.ts +108 -38
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- package/src/hub/control-client.ts +5 -1
- package/src/hub/daemon.ts +529 -89
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +31 -1
- package/src/hub/git.ts +2 -38
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +14 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/memory-candidates.ts +103 -0
- package/src/hub/memory-fail.ts +25 -0
- package/src/hub/memory-observations.ts +47 -0
- package/src/hub/memory-plan-guard.ts +33 -0
- package/src/hub/memory-policy.ts +42 -0
- package/src/hub/memory-profile-check.ts +170 -0
- package/src/hub/memory-profile.ts +192 -0
- package/src/hub/memory-staleness.ts +55 -0
- package/src/hub/path-policy.ts +8 -0
- package/src/hub/peer-catalog.ts +3 -0
- package/src/hub/project.ts +4 -37
- package/src/hub/read-tools.ts +127 -0
- package/src/hub/routing.ts +35 -16
- package/src/hub/settings.ts +2 -1
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +21 -6
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +370 -71
- package/src/local/deny.ts +4 -17
- package/src/memory/brief.ts +8 -5
- package/src/memory/capture.ts +3 -1
- package/src/memory/recall.ts +50 -110
- package/src/pi/process-signature.ts +2 -42
- package/src/ui/index.html +12 -9
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
- package/src/memory/import.ts +0 -90
- package/src/memory/store.ts +0 -805
- package/src/memory/sync.ts +0 -145
package/src/local/deny.ts
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
1
|
-
import { basename, dirname
|
|
1
|
+
import { basename, dirname } from "node:path";
|
|
2
|
+
import { DENY_NAMES, DENY_SEGMENTS } from "@staix/agent-mem/platform";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
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, "\\$&");
|
package/src/memory/brief.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/memory/capture.ts
CHANGED
|
@@ -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", {
|
package/src/memory/recall.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
*/
|
|
121
|
-
export
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
/**
|
|
137
|
-
export
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
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
|
|
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','
|
|
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
|
|
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
|
|
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 === "
|
|
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,
|
|
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
|
|
package/src/ui/office-model.ts
CHANGED
|
@@ -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
|
}
|
package/src/ui/task-board.ts
CHANGED
|
@@ -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
|
|
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[] = [];
|