@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
@@ -0,0 +1,55 @@
1
+ import { lstatSync, type Stats } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { nodePath } from "@staix/agent-mem";
4
+ import { isDenied } from "../local/deny.ts";
5
+ import { foldSegment } from "./permission-mode.ts";
6
+ import { matchesPolicyLexically } from "./path-policy.ts";
7
+
8
+ export const STALENESS_MAX_PROBES = 32;
9
+ export interface StalenessOptions {
10
+ cwd: string;
11
+ privatePaths?: string[];
12
+ deny?: string[];
13
+ /** A metadata-only seam for privacy and budget regressions. */
14
+ probe?: (path: string) => Pick<Stats, "isSymbolicLink" | "isDirectory">;
15
+ }
16
+
17
+ /** One call-scoped budget/cache, shared by every displayed note. Unknown never produces a marker. */
18
+ export function createStalenessProbe(options: StalenessOptions): (about: readonly string[]) => boolean {
19
+ const root = resolve(options.cwd), cache = new Map<string, boolean>();
20
+ let remaining = STALENESS_MAX_PROBES;
21
+ const allowed = (path: string) => {
22
+ const name = relative(root, resolve(root, path));
23
+ return !isDenied(name, options.deny) && !isDenied(foldSegment(name), options.deny?.map(foldSegment))
24
+ && !matchesPolicyLexically(root, name, [...(options.privatePaths ?? []), ...(options.deny ?? [])]);
25
+ };
26
+ const missing = (path: string): boolean => {
27
+ if (cache.has(path)) return cache.get(path)!;
28
+ cache.set(path, false);
29
+ if (!path || path.length > 4096 || isAbsolute(path) || /[\0\r\n]/.test(path) || path.split("/").includes("..") || !allowed(path)) return false;
30
+ const target = resolve(root, path), rel = relative(root, target);
31
+ if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return false;
32
+ let current = root;
33
+ // ponytail: private/deny exclusion assumes a tree not modified during this call. A concurrent ancestor swap
34
+ // by a process running as you can expose one existence bit and is outside docs/security.md's threat model.
35
+ // Descriptor-anchored no-follow traversal is needed before supporting adversarial concurrent tree mutation.
36
+ for (const component of rel.split("/")) {
37
+ current = join(current, component);
38
+ if (!remaining || !allowed(current)) return false;
39
+ remaining--;
40
+ try {
41
+ const stat = (options.probe ?? lstatSync)(current);
42
+ // Never follow even a publicly named alias: its target could be private.
43
+ if (stat.isSymbolicLink()) return false;
44
+ if (current !== target && !stat.isDirectory()) return false;
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") { cache.set(path, true); return true; }
47
+ return false;
48
+ }
49
+ }
50
+ return false;
51
+ };
52
+ // ponytail: file-level only, so a symbol removed from an existing file is unknown. Add a policy-gated symbol
53
+ // resolver with its own C3 privacy/budget tests before inspecting source contents.
54
+ return about => about.some(node => { const path = nodePath(node); return path !== undefined && missing(path); });
55
+ }
@@ -147,6 +147,14 @@ function globScope(root: string, lexical: string, actual: string, path: string,
147
147
  };
148
148
  return visit(root, start, 0, new Set());
149
149
  }
150
+ /** Policy preflight without filesystem access. Callers must reject symlink traversal separately. */
151
+ export function matchesPolicyLexically(cwd: string, path: string, paths: string[]): boolean {
152
+ const root = resolve(cwd), lexical = resolve(root, path);
153
+ return paths.some(entry => /[?*]/.test(entry)
154
+ ? patternScope(entry.split("/").filter(p => p && p !== "."), new Set([0]), relative(root, lexical), false)
155
+ : beneath(lexical, resolve(root, entry)));
156
+ }
157
+
150
158
  /** Compare declared spelling, real targets and bounded aliases; legacy discovery errors fail closed. */
151
159
  export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false): boolean {
152
160
  if (!paths.length) return false;
@@ -26,3 +26,6 @@ const PEER_ROLES: Record<KnownPeer, readonly string[]> = {
26
26
  export function defaultPeerRoles(includeOptional = false): Record<string, string[]> {
27
27
  return Object.fromEntries(Object.entries(PEER_ROLES).filter(([peer]) => includeOptional || !(OPTIONAL_PEERS as readonly string[]).includes(peer)).map(([peer, roles]) => [peer, [...roles]]));
28
28
  }
29
+
30
+ /** #455: who may hold the planner role, in the order automatic assignment tries them; the hub-sandboxed pi and local never plan. */
31
+ export const PLANNER_PEERS = ["claude", "codex", "kimi", "qwen", "jcode"] as const;
@@ -1,43 +1,10 @@
1
- import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
- import { spawnSync } from "node:child_process";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
4
  import { hubGitSync } from "./git.ts";
6
5
 
7
- const canonical = (path: string): string => {
8
- const absolute = resolve(path);
9
- try {
10
- return realpathSync(absolute);
11
- } catch {
12
- const parent = dirname(absolute);
13
- return parent === absolute ? absolute : join(canonical(parent), basename(absolute));
14
- }
15
- };
16
-
17
- export function canonicalPath(path: string): string {
18
- return canonical(path);
19
- }
20
-
21
- /**
22
- * realpathSync, except that Bun (1.3.14, and 1.4.2 still: #121) throws ENOENT for an existing path containing a
23
- * backslash (issue #26). Such a path goes to the system realpath, which handles it and returns every component as
24
- * stored on disk: guardPath checks names, and on a case-insensitive disk `.GIT/config` or `id_rſa` would otherwise
25
- * pass for another file. A missing path or a dangling symlink still throws, which guardPath relies on. Other paths take
26
- * realpathSync's answer unchanged.
27
- */
28
- export function realPath(path: string): string {
29
- try {
30
- return realpathSync(path);
31
- } catch (error) {
32
- if (!resolve(path).includes("\\")) throw error; // a relative path can sit under a backslash directory too
33
- // The path as given: resolve() would fold a `..` after a symlink by text. GNU realpath accepts a dangling last
34
- // component unless told -e; the BSD one on macOS refuses it by default.
35
- const argv = process.platform === "darwin" ? ["/bin/realpath", "--", path] : ["realpath", "-e", "--", path];
36
- const r = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8" });
37
- if (r.status !== 0 || !r.stdout.endsWith("\n")) throw error;
38
- return r.stdout.slice(0, -1);
39
- }
40
- }
6
+ import { canonicalPath as canonical } from "@staix/agent-mem/platform";
7
+ export { canonicalPath, realPath } from "@staix/agent-mem/platform";
41
8
 
42
9
  function gitRoot(dir: string): string | undefined {
43
10
  const result = hubGitSync(["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
@@ -0,0 +1,127 @@
1
+ import type { MemoryStore, View, Graph } from "@staix/agent-mem";
2
+ import { looksSecret } from "@staix/agent-mem";
3
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
4
+
5
+ export const MEMORY_READ_TOOLS = new Set(["hub_memory_search", "hub_memory_show", "hub_memory_graph"]);
6
+ const REFUSAL = "memory read unavailable";
7
+ const bytes = (v: string) => Buffer.byteLength(v, "utf8");
8
+ const excerpt = (text: string, max = 200): string => {
9
+ if (bytes(text) <= max) return text;
10
+ const points = Array.from(text), marker = " [incomplete]";
11
+ let length = 0, out = "";
12
+ for (const point of points) { if (length + bytes(point) + bytes(marker) > max) break; out += point; length += bytes(point); }
13
+ return out + marker;
14
+ };
15
+ function reject(): never { throw new PreEffectToolRefusal(REFUSAL); }
16
+ export type MemoryScreen = <T extends { title: string | null; text: string }>(rows: T[]) => T[];
17
+
18
+ /** Validate before capability logging, storage, or privacy work. Schemas alone do not enforce tool input. */
19
+ export function validateMemoryRead(op: string, a: Record<string, unknown>): void {
20
+ if (!a || typeof a !== "object" || Array.isArray(a)) reject();
21
+ const string = (v: unknown, min: number, max: number): v is string => typeof v === "string" && bytes(v) >= min && bytes(v) <= max;
22
+ const array = (v: unknown, min: number, max: number): v is string[] => Array.isArray(v) && v.length > 0 && v.length <= 8 && v.every(s => string(s, min, max));
23
+ const limit = (max: number) => a.limit === undefined || (Number.isInteger(a.limit) && Number(a.limit) >= 1 && Number(a.limit) <= max);
24
+ const keys = op === "hub_memory_search" ? ["query", "limit"] : op === "hub_memory_show" ? ["ids"] : ["starts", "hops", "limit"];
25
+ if (Object.keys(a).some(k => !keys.includes(k))) reject();
26
+ if (op === "hub_memory_search" ? !string(a.query, 0, 200) || !limit(20)
27
+ : op === "hub_memory_show" ? !array(a.ids, 6, 64)
28
+ : !array(a.starts, 1, 200) || (a.hops !== undefined && a.hops !== 1 && a.hops !== 2) || !limit(120)) reject();
29
+ }
30
+
31
+ /** Read-only model projection. Counts and caps are computed only after screening whole records and metadata. */
32
+ export function memoryRead(store: MemoryStore | undefined, project: string, screen: MemoryScreen, op: string, a: Record<string, unknown>, stale: (about: readonly string[]) => boolean = () => false): string {
33
+ validateMemoryRead(op, a);
34
+ const safe = (v: unknown): boolean => {
35
+ try {
36
+ const text = typeof v === "string" ? v : JSON.stringify(v);
37
+ return !looksSecret(text) && screen([{ title: null, text }]).length === 1;
38
+ } catch { return false; }
39
+ };
40
+ if (!safe(a)) reject();
41
+ if (!store) return JSON.stringify({ error: REFUSAL });
42
+ try {
43
+ const all = screen(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })).filter(safe);
44
+ const resolve = (input: string): View => {
45
+ const id = input.replace(/^mem:/, "");
46
+ const aliases = [...new Set(all.flatMap(v => [v.id, ...(v.promotedFrom ? [v.promotedFrom] : [])]))];
47
+ const matched = aliases.includes(id) ? [id] : aliases.filter(v => v.startsWith(id));
48
+ if (id.length < 6) reject();
49
+ if (!matched.length) {
50
+ // Older promotion chains are resolved by the store, but only a fully screened visible terminal is exposed.
51
+ const redirected = store.get(id, project);
52
+ const visible = redirected && all.find(v => v.id === redirected.id);
53
+ if (visible) return visible;
54
+ reject();
55
+ }
56
+ if (matched.length !== 1) reject();
57
+ const found = all.find(v => v.id === matched[0]) ?? all.find(v => v.promotedFrom === matched[0]);
58
+ if (!found) reject();
59
+ return found;
60
+ };
61
+ const finish = (v: unknown) => { if (!safe(v)) reject(); return JSON.stringify(v); };
62
+ const order = (x: View, y: View) => Number(y.kind === "fail") - Number(x.kind === "fail") || y.at.localeCompare(x.at) || x.id.localeCompare(y.id);
63
+ if (op !== "hub_memory_graph") {
64
+ const show = op === "hub_memory_show", cap = show ? 8192 : 4096;
65
+ const query = String(a.query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
66
+ const rows = show ? [...new Map((a.ids as string[]).map(id => { const v = resolve(id); return [v.id, v]; })).values()]
67
+ : all.filter(v => !v.supersededBy && query.every(q => `${v.title ?? ""} ${v.text} ${v.about.join(" ")}`.toLowerCase().includes(q))).sort(order);
68
+ const lines: string[] = [];
69
+ const render = () => ({ lines, truncated: rows.length - lines.length });
70
+ for (const v of rows) {
71
+ if (lines.length >= (show ? 8 : Number(a.limit ?? 8))) break;
72
+ const header = `mem:${v.id} ${v.kind} ${v.tier} source:${v.source} peer:${v.peer} project:${v.project} at:${v.at}: `.replace(/\s+/g, " ");
73
+ const body = `${show && stale(v.about) ? "(stale?) " : ""}${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").trim();
74
+ lines.push(header + body);
75
+ if (bytes(JSON.stringify(render())) > cap) {
76
+ const points = Array.from(body);
77
+ let low = 0, high = points.length;
78
+ while (low < high) {
79
+ const mid = Math.ceil((low + high) / 2);
80
+ lines[lines.length - 1] = header + points.slice(0, mid).join("") + " [incomplete]";
81
+ if (bytes(JSON.stringify(render())) <= cap) low = mid; else high = mid - 1;
82
+ }
83
+ lines[lines.length - 1] = header + points.slice(0, low).join("") + " [incomplete]";
84
+ if (bytes(JSON.stringify(render())) > cap) lines.pop();
85
+ }
86
+ }
87
+ return finish(render());
88
+ }
89
+ const visible = new Map(all.map(v => [`mem:${v.id}`, v]));
90
+ const goals = new Map(store.goals(project).filter(safe).map(v => [`goal:${v.id}`, v]));
91
+ const starts = (a.starts as string[]).map(n => {
92
+ if (n.startsWith("mem:")) return `mem:${resolve(n).id}`;
93
+ if (n.startsWith("goal:") && !goals.has(n)) reject();
94
+ return n;
95
+ });
96
+ const nodes = new Map<string, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
97
+ for (const start of starts) {
98
+ const graph = store.graph(project, start, Number(a.hops ?? 1), Number.MAX_SAFE_INTEGER);
99
+ for (const n of graph.nodes) {
100
+ if ((n.id.startsWith("mem:") && !visible.has(n.id)) || (n.id.startsWith("goal:") && !goals.has(n.id))) continue;
101
+ const memory = visible.get(n.id), goal = goals.get(n.id);
102
+ // The dashboard graph already slices labels; model excerpts must retain a visible incompleteness marker.
103
+ const shown = { ...n, label: excerpt(memory ? memory.title ?? memory.text : goal ? goal.title : n.label) };
104
+ if (safe(shown)) nodes.set(n.id, shown);
105
+ }
106
+ for (const e of graph.edges) if (safe(e)) edges.set(JSON.stringify(e), e);
107
+ }
108
+ const screenedEdges = [...edges.values()].filter(e => nodes.has(e.from) && nodes.has(e.to));
109
+ const connected = new Set(screenedEdges.flatMap(e => [e.from, e.to]));
110
+ const rank = (n: Graph["nodes"][number]) => n.kind === "fail" ? 0 : starts.includes(n.id) ? 1 : n.type !== "memory" ? 2 : 3;
111
+ const eligibleNodes = [...nodes.values()].filter(n => connected.has(n.id) || starts.includes(n.id)).sort((x, y) => rank(x) - rank(y) || x.id.localeCompare(y.id));
112
+ const eligibleEdges = screenedEdges.sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));
113
+ const out: Graph & { truncated: number } = { nodes: [], edges: [], truncated: eligibleNodes.length + eligibleEdges.length };
114
+ for (const n of eligibleNodes) {
115
+ if (out.nodes.length >= Number(a.limit ?? 60)) break;
116
+ out.nodes.push(n); out.truncated--;
117
+ if (bytes(JSON.stringify(out)) > (eligibleEdges.length ? 5120 : 6144)) { out.nodes.pop(); out.truncated++; }
118
+ }
119
+ const kept = new Set(out.nodes.map(n => n.id));
120
+ for (const e of eligibleEdges) {
121
+ if (!kept.has(e.from) || !kept.has(e.to) || out.edges.length >= 240) continue;
122
+ out.edges.push(e); out.truncated--;
123
+ if (bytes(JSON.stringify(out)) > 6144) { out.edges.pop(); out.truncated++; }
124
+ }
125
+ return finish(out);
126
+ } catch { return JSON.stringify({ error: REFUSAL }); }
127
+ }
@@ -1,6 +1,9 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { CLASSES, type Task, type TaskClass } from "./board.ts";
4
+ import { isGitHubRepository, specRefs } from "./spec-read.ts";
5
+
6
+ export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; }
4
7
  import type { PeerId, PeerState } from "./envelope.ts";
5
8
 
6
9
  import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
@@ -231,7 +234,10 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
231
234
  /** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
232
235
  export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
233
236
 
237
+ export type RoutingRefusalCode = "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
238
+ export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
234
239
  export interface Assignment {
240
+ refusals?: RoutingRefusal[];
235
241
  owner?: PeerId;
236
242
  reviewer?: PeerId;
237
243
  /** what `local` should ask for on this task: a Switchyard route id and the model to fall back to */
@@ -248,10 +254,12 @@ export interface Assignment {
248
254
  * prints its trace. `states`: effective bus states of attached peers (a peer that is not in the map is not attached).
249
255
  */
250
256
  export function assign(
251
- task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state">>,
257
+ task: Pick<Task, "class" | "signals" | "reserved"> & Partial<Pick<Task, "state" | "title" | "detail" | "refs" | "external">>,
252
258
  states: Record<PeerId, PeerState>,
253
259
  routing: Routing,
254
260
  opts: {
261
+ capabilities?: Record<PeerId, PeerCapabilities>;
262
+ repository?: string;
255
263
  exclude?: PeerId[];
256
264
  /** Owner admission limits do not remove otherwise eligible reviewers. */
257
265
  excludeOwners?: PeerId[];
@@ -280,6 +288,7 @@ export function assign(
280
288
  roles?: Record<string, string[]>;
281
289
  } = {},
282
290
  ): Assignment {
291
+ if (task.state === "cancelled" || task.state === "approved") return { trace: [`${task.state}: closed; not routed`] };
283
292
  if (task.state === "backlog") return { trace: ["backlog: not routed; pull into To Do first"] };
284
293
  const policy = routing.classes[task.class];
285
294
  const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
@@ -292,29 +301,39 @@ export function assign(
292
301
  if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`, ...(reserved ? [`reserved owner ${reserved}: offered first once it is ready`] : [])] };
293
302
  const pii = task.signals.includes("pii_path") || (task.signals.includes("pii") && routing.constraints.pii === "local_only");
294
303
 
304
+ let needsSpec = false;
305
+ if (!pii) try { needsSpec = specRefs({ title: task.title ?? "", detail: task.detail ?? "", refs: task.refs ?? {}, external: task.external }, opts.repository ?? "", { bareTitle: isGitHubRepository(opts.repository) }).length > 0; }
306
+ catch { needsSpec = true; } // an invalid/foreign reference never grants a sandboxed peer spec access
307
+ const needsTests = ["implement", "test", "bulk_edit"].includes(task.class);
308
+ const refusals = new Map<string, RoutingRefusal>();
295
309
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
296
- if (opts.offRoster?.includes(peer)) return "off roster";
310
+ const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
311
+ if (role === "owner" && opts.capabilities && !pii) {
312
+ const caps = peerEntry(opts.capabilities, peer);
313
+ if (needsTests && !caps?.tests) return deny("tests", `missing tests: ${caps?.testsReason ?? "capability unavailable"}`);
314
+ if (needsSpec && !caps?.spec) return deny("spec", `missing spec: ${caps?.specReason ?? "capability unavailable"}`);
315
+ }
316
+ if (opts.offRoster?.includes(peer)) return deny("roster", "off roster");
297
317
  const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
298
- if (opts.pullOnly?.includes(peer) && named !== peer) return "pull-only: takes work when named or when it claims it";
299
- if (!Object.hasOwn(states, peer)) return "not attached";
300
- if (opts.exclude?.includes(peer)) return "excluded (declined or replaced)";
301
- if (role === "owner" && opts.excludeOwners?.includes(peer)) return "owner admission: WIP or delivery hold";
302
- if (states[peer] === "offline") return "offline";
303
- if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return "paused";
318
+ if (opts.pullOnly?.includes(peer) && named !== peer) return deny("pull-only", "pull-only: takes work when named or when it claims it");
319
+ if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
320
+ if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
321
+ if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
322
+ if (states[peer] === "offline") return deny("offline", "offline");
323
+ if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
304
324
  const failing = peerEntry(opts.failing, peer);
305
- if (failing) return `failing: ${failing}`;
306
- if (pii && peer !== LOCAL) return "pii: on-prem peers only";
307
- if (peer === LOCAL && policy?.local_allowed === false) return "local_allowed = false for this class";
308
- if (peer === PI && policy?.local_allowed === false) return "local_allowed = false also excludes pi for this class";
325
+ if (failing) return deny("failing", `failing: ${failing}`);
326
+ if (pii && peer !== LOCAL) return deny("pii", "pii: on-prem peers only");
327
+ if (peer === LOCAL && policy?.local_allowed === false) return deny("policy", "local_allowed = false for this class");
328
+ if (peer === PI && policy?.local_allowed === false) return deny("policy", "local_allowed = false also excludes pi for this class");
309
329
  if (peer === PI && task.signals.includes("long_context")) {
310
330
  const backend = policy?.pi_backend ?? "dgx";
311
331
  const limit = backend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens;
312
- if (routing.signals.long_context_tokens > limit) return `pi ${backend} capability limit ${limit} tokens`;
332
+ if (routing.signals.long_context_tokens > limit) return deny("policy", `pi ${backend} capability limit ${limit} tokens`);
313
333
  }
314
- if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return "long_context: skip local";
334
+ if (peer === LOCAL && role === "owner" && task.signals.includes("long_context") && routing.constraints.long_context === "skip_local") return deny("policy", "long_context: skip local");
315
335
  return undefined;
316
336
  };
317
-
318
337
  /** Quota that resets soonest gets used first: headroom per hour left in the window, at least 15 min (a task assigned that close to a reset mostly runs after it). */
319
338
  const drain = (p: PeerId): number | undefined => {
320
339
  const q = peerEntry(opts.quota, p);
@@ -396,7 +415,7 @@ export function assign(
396
415
  const piBackend = policy?.pi_backend;
397
416
  if (owner === LOCAL) trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
398
417
  if (owner === PI) trace.push(`pi decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
399
- return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), trace };
418
+ return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
400
419
  }
401
420
 
402
421
  /**
@@ -1,4 +1,4 @@
1
- import { SUPPORTED_PEERS } from "./peer-catalog.ts";
1
+ import { PLANNER_PEERS, SUPPORTED_PEERS } from "./peer-catalog.ts";
2
2
  import { createHash } from "node:crypto";
3
3
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
@@ -46,6 +46,7 @@ const perClass = (name: TaskClass): SettingDef[] => [
46
46
  ];
47
47
  export const SETTINGS: readonly SettingDef[] = [
48
48
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `tasks.wip_limits.${peer}`, group: "Routing", label: `${peer}: planner WIP limit`, type: "integer", min: 0, max: 32, store: "config", applies: "hub start", risk: "raises", floor: 0, path: ["tasks", "wip_limits", peer] })),
49
+ { key: "planner", group: "Roles", label: "Planner (the one peer that plans)", type: "enum", values: PLANNER_PEERS, store: "config", applies: "live", risk: "raises", path: ["planner"] },
49
50
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `catalog_roles.${peer}`, group: "Roles", label: `${peer}: catalog guidance`, type: "roles", store: "config", applies: "peer start", risk: "safe", path: ["catalog_roles", peer] })),
50
51
  { key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
51
52
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
@@ -0,0 +1,120 @@
1
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
2
+ import { spawn } from "node:child_process";
3
+ import { hostExecutable } from "./host-executable.ts";
4
+ import type { Task } from "./board.ts";
5
+ import { childEnv, stopOwnedProcess, trackGroup } from "./child-process.ts";
6
+
7
+ const BODY_LIMIT = 64 * 1024, RESULT_LIMIT = 80 * 1024, STDOUT_LIMIT = 512 * 1024;
8
+ const REPO = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
9
+ const bytes = (text: string) => Buffer.byteLength(text, "utf8");
10
+ export interface SpecResult { number: number; title: string; state: string; updated_at: string; html_url: string; body: string; }
11
+ /** PATH/shorter timeout are host-owned fixture seams, never tool inputs. */
12
+ export interface SpecOptions { path?: string; timeoutMs?: number; projectDir?: string; }
13
+ export interface FetchSpecOptions extends SpecOptions { screen: (whole: string) => boolean | Promise<boolean>; }
14
+ /** Canonical owner/repo identity accepted by the fixed github.com fetch boundary. */
15
+ export const isGitHubRepository = (repo: unknown): repo is string => typeof repo === "string" && REPO.test(repo);
16
+ function validRepo(repo: string): void { if (!isGitHubRepository(repo)) throw new PreEffectToolRefusal("other repository"); }
17
+
18
+ /** Resolve only task-bound issue references; foreign references never fall through to local #N. */
19
+ export function specRefs(task: Pick<Task, "title" | "detail" | "refs"> & Partial<Pick<Task, "external">>, repo: string, options: { bareTitle?: boolean } = {}): number[] {
20
+ const references: { repo?: string; number: number }[] = [];
21
+ // Bare detail numbers name hub tasks. File paths are not issue references.
22
+ for (const section of [{ text: task.title, bare: true }, { text: task.detail, bare: false }]) {
23
+ for (const match of section.text.matchAll(/(?<![A-Za-z0-9_./-])(?:https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/([1-9][0-9]*)\b|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)\b|#([1-9][0-9]*)\b)/g)) {
24
+ const qualified = match[1] ?? match[3];
25
+ if (!qualified && (!section.bare || options.bareTitle === false)) continue;
26
+ references.push({ repo: qualified, number: Number(match[2] ?? match[4] ?? match[5]) });
27
+ }
28
+ }
29
+ if (!references.length && task.external === undefined && !task.refs.repo) return [];
30
+ validRepo(repo);
31
+ if (task.refs.repo && task.refs.repo.toLowerCase() !== repo.toLowerCase()) throw new PreEffectToolRefusal("other repository");
32
+ const issues = new Set<number>();
33
+ if (task.external !== undefined) {
34
+ const source = task.external;
35
+ if (!source || source.provider !== "github" || typeof source.repo !== "string") throw new PreEffectToolRefusal("not found");
36
+ validRepo(source.repo);
37
+ if (source.repo.toLowerCase() !== repo.toLowerCase()) throw new PreEffectToolRefusal("other repository");
38
+ if (!Number.isSafeInteger(source.issue) || source.issue < 1 || typeof source.url !== "string"
39
+ || source.url.toLowerCase() !== `https://github.com/${repo}/issues/${source.issue}`.toLowerCase()) throw new PreEffectToolRefusal("not found");
40
+ issues.add(source.issue);
41
+ }
42
+ for (const reference of references) {
43
+ if (reference.repo && reference.repo.toLowerCase() !== repo.toLowerCase()) throw new PreEffectToolRefusal("other repository");
44
+ if (!Number.isSafeInteger(reference.number)) throw new PreEffectToolRefusal("not found");
45
+ issues.add(reference.number);
46
+ if (issues.size > 3) throw new PreEffectToolRefusal("too many spec references");
47
+ }
48
+ return [...issues];
49
+ }
50
+
51
+ interface GhResult { code: number; stdout: string; }
52
+ /** One fixed-argv authenticated boundary, no shell, no retries and no retained stderr. */
53
+ function gh(args: string[], opts: SpecOptions): Promise<GhResult> {
54
+ return new Promise((resolve, reject) => {
55
+ const env: NodeJS.ProcessEnv = { ...childEnv(), GH_HOST: "github.com", GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", ...(opts.path === undefined ? {} : { PATH: opts.path }) };
56
+ if (opts.path !== undefined) {
57
+ for (const key of ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]) delete env[key];
58
+ env.GH_CONFIG_DIR = opts.path;
59
+ }
60
+ let child: ReturnType<typeof spawn>;
61
+ try {
62
+ child = spawn(hostExecutable("gh", opts.projectDir ?? process.cwd(), opts.path ?? process.env.PATH), args, { env, detached: true, stdio: ["ignore", "pipe", "ignore"] });
63
+ trackGroup(child);
64
+ }
65
+ catch { reject(new Error("gh unavailable")); return; }
66
+ let output: Buffer[] = [], size = 0, failure: string | undefined;
67
+ const stop = (reason: string) => {
68
+ if (failure) return;
69
+ failure = reason; output = [];
70
+ void stopOwnedProcess(child, { group: true, termMs: 100, killMs: 500 }).catch(() => {});
71
+ };
72
+ const timeout = opts.timeoutMs === undefined || !Number.isFinite(opts.timeoutMs) ? 15_000 : Math.max(1, Math.min(15_000, opts.timeoutMs));
73
+ const timer = setTimeout(() => stop("timeout"), timeout);
74
+ child.stdout?.on("data", (chunk: Buffer) => {
75
+ if (failure) return;
76
+ size += chunk.length;
77
+ if (size > STDOUT_LIMIT) stop("too large"); else output.push(chunk);
78
+ });
79
+ child.once("error", () => { clearTimeout(timer); reject(new Error("gh unavailable")); });
80
+ child.once("close", code => {
81
+ clearTimeout(timer);
82
+ if (failure) reject(new Error(failure));
83
+ else resolve({ code: code ?? 1, stdout: Buffer.concat(output).toString("utf8") });
84
+ });
85
+ });
86
+ }
87
+
88
+ /** Startup-only availability evidence; daemon caches it until restart. */
89
+ export async function specAvailable(opts: SpecOptions = {}): Promise<{ available: boolean; reason?: string }> {
90
+ try {
91
+ const result = await gh(["auth", "status", "--hostname", "github.com"], opts);
92
+ return result.code === 0 ? { available: true } : { available: false, reason: "gh unauthenticated" };
93
+ } catch (error) { return { available: false, reason: error instanceof Error ? error.message : "gh unavailable" }; }
94
+ }
95
+
96
+ export async function fetchSpec(repo: string, n: number, opts: FetchSpecOptions): Promise<SpecResult> {
97
+ validRepo(repo);
98
+ if (!Number.isSafeInteger(n) || n < 1) throw new PreEffectToolRefusal("not found");
99
+ const raw = await gh(["api", "-X", "GET", `repos/${repo}/issues/${n}`], opts);
100
+ let value: unknown;
101
+ try { value = JSON.parse(raw.stdout); } catch { throw new Error(raw.code === 4 ? "unauthenticated" : "not found"); }
102
+ if (raw.code !== 0) {
103
+ const status = (value as { status?: unknown })?.status;
104
+ throw new Error(raw.code === 4 || status === 401 ? "unauthenticated" : "not found");
105
+ }
106
+ if (!value || typeof value !== "object") throw new Error("not found");
107
+ const row = value as Record<string, unknown>;
108
+ if (typeof row.repository_url !== "string" || row.repository_url.toLowerCase() !== `https://api.github.com/repos/${repo}`.toLowerCase()
109
+ || typeof row.html_url !== "string" || ![`https://github.com/${repo}/issues/${n}`, `https://github.com/${repo}/pull/${n}`].some(url => row.html_url!.toString().toLowerCase() === url.toLowerCase())) throw new PreEffectToolRefusal("other repository");
110
+ if (row.number !== n || typeof row.title !== "string" || typeof row.state !== "string" || typeof row.updated_at !== "string" || (row.body !== null && typeof row.body !== "string")) throw new Error("not found");
111
+ const result: SpecResult = { number: n, title: row.title, state: row.state, updated_at: row.updated_at, html_url: row.html_url, body: row.body ?? "" };
112
+ // GET completed without task/check mutations; these explicit complete-contract refusals withhold only its result.
113
+ const length = bytes(result.body);
114
+ if (length > BODY_LIMIT) throw new PreEffectToolRefusal(`spec too large (${length} > 65536); ask the reviewer to put the contract in the repo`);
115
+ if (bytes(JSON.stringify(result)) > RESULT_LIMIT) throw new PreEffectToolRefusal("too large");
116
+ let allowed = false;
117
+ try { allowed = await opts.screen(`${result.title}\n${result.body}`); } catch { /* policy unavailable withholds the entire spec */ }
118
+ if (allowed !== true) throw new PreEffectToolRefusal("spec withheld by privacy policy");
119
+ return result;
120
+ }
@@ -1,4 +1,4 @@
1
- import type { HistoryEntry, Task, TaskMoveReason } from "./board.ts";
1
+ import { isClosedTaskState, type HistoryEntry, type Task, type TaskMoveReason, type TaskStall } from "./board.ts";
2
2
  import { sanitize, type Kind, type Priority } from "./envelope.ts";
3
3
 
4
4
  export type SupervisionScope = "own" | "all" | "off";
@@ -11,7 +11,7 @@ export interface SupervisionFields {
11
11
  check?: "passed" | "failed" | "unknown";
12
12
  verdict?: "approved" | "changes_requested";
13
13
  reason?: SupervisionReason;
14
- sweep?: "unaccepted-assignment" | "idle-owner" | "review-pending";
14
+ sweep?: "unaccepted-assignment" | "idle-owner" | "review-pending" | "ready-unassigned";
15
15
  }
16
16
  export interface SupervisionNotice {
17
17
  peer: string;
@@ -102,13 +102,25 @@ export class SupervisionFeed {
102
102
  this.checkRound();
103
103
  }
104
104
 
105
+ /** Tasks owns durable cause/replan deduplication; the feed keeps its configured recipient and scope. */
106
+ taskStalled(stall: TaskStall): boolean {
107
+ const task = this.d.tasks().find(task => task.id === stall.task);
108
+ if (!task || isClosedTaskState(task.state) || !this.includes(task)) return false;
109
+ const cause = stall.cause;
110
+ const bits = [`#${stall.task}: stalled`, `cause ${cause.kind}`, `reason ${cause.code}`];
111
+ if (cause.peer) bits.push(`peer ${cause.peer}`);
112
+ if (cause.dependency !== undefined) bits.push(`dependency #${cause.dependency}`);
113
+ if (cause.refusals?.length) bits.push(`refusals ${cause.refusals.map(refusal => `${refusal.peer}:${refusal.code}`).join(", ")}`);
114
+ return this.send(`task:${stall.task}:stall`, bits.join("; "), "status", stall.task);
115
+ }
116
+
105
117
  peerOffline(peer: string): boolean {
106
- if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
118
+ if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer))) return false;
107
119
  return this.send(`peer:${peer}:offline`, `${peer}: offline`);
108
120
  }
109
121
 
110
122
  budgetPaused(peer: string, resetsAt?: number): boolean {
111
- if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
123
+ if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer))) return false;
112
124
  const resetDate = new Date(resetsAt ?? NaN);
113
125
  const reset = Number.isFinite(resetDate.getTime()) ? resetDate.toISOString() : "unknown";
114
126
  return this.send(`peer:${peer}:budget`, `${peer}: budget paused; reset ${reset}`, "status", undefined, "budget");
@@ -137,13 +149,16 @@ export class SupervisionFeed {
137
149
  const peer = this.recipient();
138
150
  if (!peer) return false;
139
151
  const tasks = this.d.tasks().filter((t) => this.includes(t));
140
- if (!tasks.length || tasks.some((t) => t.state !== "approved")) return false;
152
+ if (!tasks.length || tasks.some((t) => !isClosedTaskState(t.state))) return false;
141
153
  const signature = tasks.map((t) => t.id).sort((a, b) => a - b).join(",");
142
154
  const previous = this.d.readRound?.(peer) ?? this.rounds.get(peer);
143
155
  const completed = new Set(previous?.split(",") ?? []);
144
156
  const newlyJoined = tasks.filter((t) => !completed.has(String(t.id)));
145
157
  if (!newlyJoined.length) return false;
146
- const sent = this.send("round", `Supervision round complete: ${newlyJoined.length} task${newlyJoined.length === 1 ? "" : "s"} approved; report the results and open decisions to the person.`);
158
+ const count = `${newlyJoined.length} task${newlyJoined.length === 1 ? "" : "s"}`;
159
+ const cancelled = newlyJoined.filter(t => t.state === "cancelled").length;
160
+ const outcome = cancelled ? `${count} closed (${newlyJoined.length - cancelled} approved, ${cancelled} cancelled)` : `${count} approved`;
161
+ const sent = this.send("round", `Supervision round complete: ${outcome}; report the results and open decisions to the person.`);
147
162
  if (sent) {
148
163
  this.rounds.set(peer, signature);
149
164
  this.d.writeRound?.(peer, signature);
@@ -1,6 +1,6 @@
1
1
  import type { Task } from "./board.ts";
2
2
 
3
- export type SweepKind = "unaccepted-assignment" | "idle-owner" | "review-pending";
3
+ export type SweepKind = "unaccepted-assignment" | "idle-owner" | "review-pending" | "ready-unassigned";
4
4
  export interface SweepRecord {
5
5
  kind: SweepKind;
6
6
  /** Index of the last real activity, distinguishing even events at the same time. */
@@ -14,11 +14,12 @@ export interface TaskSweepConfig {
14
14
  unaccepted_min: number;
15
15
  idle_min: number;
16
16
  review_min: number;
17
+ ready_min?: number;
17
18
  ladder_min: number;
18
19
  auto_reassign: boolean;
19
20
  }
20
21
  export const DEFAULT_TASK_SWEEP: TaskSweepConfig = {
21
- enabled: false, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
22
+ enabled: false, ready_min: 60, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
22
23
  };
23
24
 
24
25
  /** Config cannot silently enable a sweep or reassignment through a truthy string. */
@@ -32,7 +33,7 @@ export function taskSweepConfig(input: unknown): TaskSweepConfig {
32
33
  if (typeof values[key] !== "boolean") throw new Error(`task_sweep.${key} must be a boolean`);
33
34
  out[key] = values[key];
34
35
  }
35
- for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ladder_min"] as const) {
36
+ for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"] as const) {
36
37
  if (values[key] === undefined) continue;
37
38
  const value = values[key];
38
39
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value * (key === "interval_s" ? 1000 : 60_000) > 2_147_483_647) throw new Error(`task_sweep.${key} must be a bounded number of at least 1`);
@@ -42,16 +43,16 @@ export function taskSweepConfig(input: unknown): TaskSweepConfig {
42
43
  }
43
44
 
44
45
  /** Pure time/kind ladder; Tasks decides whether the responsible peer is available. */
45
- export function nextSweep(task: Task, config: TaskSweepConfig, now: number): SweepRecord | undefined {
46
+ export function nextSweep(task: Task, config: TaskSweepConfig, now: number, ready = true): SweepRecord | undefined {
46
47
  if (!config.enabled || !Number.isFinite(now)) return undefined;
47
48
  const kind = task.state === "proposed" && task.owner ? "unaccepted-assignment"
48
49
  : task.state === "in_progress" && task.owner ? "idle-owner"
49
- : task.state === "in_review" && task.reviewer ? "review-pending" : undefined;
50
+ : task.state === "in_review" && task.reviewer ? "review-pending" : task.state === "proposed" && !task.owner && !task.reviewer && ready ? "ready-unassigned" : undefined;
50
51
  if (!kind) return undefined;
51
52
  let activity = task.history.length - 1;
52
53
  while (activity >= 0 && (task.history[activity]?.sweep || task.history[activity]?.event === "memory reference started")) activity--;
53
54
  const at = task.history[activity]?.at ?? task.created;
54
- const threshold = kind === "unaccepted-assignment" ? config.unaccepted_min : kind === "idle-owner" ? config.idle_min : config.review_min;
55
+ const threshold = kind === "ready-unassigned" ? config.ready_min ?? 60 : kind === "unaccepted-assignment" ? config.unaccepted_min : kind === "idle-owner" ? config.idle_min : config.review_min;
55
56
  if (now - at < threshold * 60_000) return undefined;
56
57
  const previous = task.history.flatMap((h) => h.sweep?.kind === kind && h.sweep.activity === activity ? [h.sweep] : []).at(-1);
57
58
  if (previous && (previous.step === 3 || now - previous.at < config.ladder_min * 60_000)) return undefined;