@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
|
@@ -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
|
+
}
|
package/src/hub/path-policy.ts
CHANGED
|
@@ -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;
|
package/src/hub/peer-catalog.ts
CHANGED
|
@@ -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;
|
package/src/hub/project.ts
CHANGED
|
@@ -1,43 +1,10 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
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
|
+
}
|
package/src/hub/routing.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/hub/settings.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/hub/supervision.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/src/hub/task-sweep.ts
CHANGED
|
@@ -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;
|