@staix/agent-hub 0.12.22 → 0.12.24
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 +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/hub/research.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type { StampedEvent } from "./events.ts";
|
|
4
|
+
import type { RoleIdentity, StampedEvent } from "./events.ts";
|
|
5
5
|
import { hubHome } from "./project.ts";
|
|
6
|
+
import { ADDONS, addonState, type Addon, type AddonName } from "./tooling.ts";
|
|
7
|
+
import { MIN_ATTEMPTS, mulberry32 } from "./bench.ts";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* #247: opt-in research records of how tasks went, built only from `events.jsonl` (ids, states, counts, tokens), so the
|
|
@@ -18,6 +20,11 @@ const FAILED: ReadonlySet<string> = new Set(["regressed", "reverted", "incomplet
|
|
|
18
20
|
export interface PeerUsage { input: number | null; output: number | null; cacheRead: number | null; total: number | null }
|
|
19
21
|
export interface TaskRecord {
|
|
20
22
|
schema: typeof RESEARCH_SCHEMA; kind: "task"; project: string; task: number;
|
|
23
|
+
profile?: string; profileClass?: string;
|
|
24
|
+
release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null;
|
|
25
|
+
role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string };
|
|
26
|
+
/** Native preamble preparations frozen at each attributed turn's start, never proof of model consumption. */
|
|
27
|
+
sessionRoles?: { peer: string; turn: string; roles: RoleIdentity[] | null }[];
|
|
21
28
|
class: string | null; pii: boolean; outcome: "approved";
|
|
22
29
|
/** The proposal time also tells a task from an earlier one with the same id after `ahub reset --all`. */
|
|
23
30
|
createdAt: string | null; startedAt: string | null; approvedAt: string;
|
|
@@ -44,6 +51,12 @@ export interface TaskRecord {
|
|
|
44
51
|
/** Model routes and served models seen for the task's attributed requests. */
|
|
45
52
|
models: string[];
|
|
46
53
|
writer: { version: string; source: "live" | "backfill" };
|
|
54
|
+
/** #248: the add-ons each peer that worked on the task had at its latest attach; a peer missing here is unknown. */
|
|
55
|
+
tooling?: Record<string, Addon[]>;
|
|
56
|
+
/** #248, with `tooling`: every peer with an event attributed to the task, fingerprinted or not. */
|
|
57
|
+
peers?: string[];
|
|
58
|
+
/** #258 AC11: task briefs handed over for it, and how many of them carried a hub-memory fail note; absent without briefs. */
|
|
59
|
+
briefs?: { delivered: number; withFail: number };
|
|
47
60
|
}
|
|
48
61
|
/** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. */
|
|
49
62
|
export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string }
|
|
@@ -56,15 +69,15 @@ export const researchFile = (projectId: string, home = hubHome()): string => joi
|
|
|
56
69
|
/** One task across every record about it: a task id is reused after `ahub reset --all`, its proposal time is not. */
|
|
57
70
|
export const recordKey = (r: { project: string; task: number; createdAt: string | null; approvedAt?: string }): string => `${r.project}#${r.task}@${r.createdAt ?? r.approvedAt ?? ""}`;
|
|
58
71
|
|
|
59
|
-
type Acc = Omit<TaskRecord, "schema" | "kind" | "project" | "approvedAt" | "wallMs" | "firstPass" | "outcome" | "writer" | "models"> & {
|
|
60
|
-
models: Set<string>; approvedAt: string | null; lastState: string | null;
|
|
72
|
+
type Acc = Omit<TaskRecord, "schema" | "kind" | "project" | "approvedAt" | "wallMs" | "firstPass" | "outcome" | "writer" | "models" | "tooling" | "peers" | "briefs"> & {
|
|
73
|
+
models: Set<string>; approvedAt: string | null; lastState: string | null; tooling: Record<string, Addon[]>; peers: Map<string, number>; briefs: { delivered: number; withFail: number };
|
|
61
74
|
};
|
|
62
75
|
/** Keyed by peer ids, model names and reasons, any of which may be `constructor` or `__proto__`: no prototype to inherit from. */
|
|
63
76
|
const keyed = <T>(): Record<string, T> => Object.create(null) as Record<string, T>;
|
|
64
77
|
const blank = (task: number): Acc => ({ task, class: null, pii: false, createdAt: null, startedAt: null, activeMs: 0, owners: [], reviewer: null,
|
|
65
78
|
reviewRounds: 0, changesRequested: 0, checkPassed: 0, checkFailed: 0, dones: 0, reassignments: 0, reassignedBy: keyed(), stuck: 0, overlaps: 0, conflicts: 0,
|
|
66
79
|
tests: { pass: 0, fail: 0 }, tokens: { total: 0, byPeer: keyed(), byAttribution: keyed() }, usage: keyed(), usageByModel: keyed(), turns: 0, filesChanged: 0, models: new Set(),
|
|
67
|
-
approvedAt: null, lastState: null });
|
|
80
|
+
approvedAt: null, lastState: null, tooling: keyed(), peers: new Map(), briefs: { delivered: 0, withFail: 0 } });
|
|
68
81
|
const add = (current: number | null, value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? (current ?? 0) + value : current;
|
|
69
82
|
const bump = (counts: Record<string, number>, key: string, n = 1) => { counts[key] = (counts[key] ?? 0) + n; };
|
|
70
83
|
|
|
@@ -77,18 +90,51 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
77
90
|
const tasks = new Map<number, Acc>(), earlier: Acc[] = [];
|
|
78
91
|
const acc = (id: number) => { let a = tasks.get(id); if (!a) tasks.set(id, a = blank(id)); return a; };
|
|
79
92
|
const seenUsage = new Set<string>();
|
|
93
|
+
// A fingerprint belongs to one attachment of its peer: from a state event that brings it online to the next offline.
|
|
94
|
+
// Detection settles in the background, so it can arrive after the peer's first work in that attachment.
|
|
95
|
+
const attach = new Map<string, number>(), online = new Set<string>();
|
|
96
|
+
const tooling = new Map<string, { attach: number; addons: Addon[] }>();
|
|
97
|
+
/** A peer that worked on the task carries the fingerprint of the attachment it worked in. */
|
|
98
|
+
/**
|
|
99
|
+
* `acted`: the peer did something for the task (an attributed turn, tokens, the actor of a board move). A peer only
|
|
100
|
+
* named on a move (the owner or reviewer an approval lists) joins the task once and never moves to a later
|
|
101
|
+
* attachment, where it did no work.
|
|
102
|
+
*/
|
|
103
|
+
const involve = (a: Acc, peer: string | null | undefined, acted = true) => {
|
|
104
|
+
if (!peer || peer === "hub" || peer === "user" || (!acted && a.peers.has(peer))) return;
|
|
105
|
+
const now = attach.get(peer) ?? 0, f = tooling.get(peer);
|
|
106
|
+
a.peers.set(peer, now);
|
|
107
|
+
if (f?.attach === now) a.tooling[peer] = f.addons;
|
|
108
|
+
else delete a.tooling[peer]; // a fingerprint of an earlier attachment does not describe this work: unknown until one comes
|
|
109
|
+
};
|
|
80
110
|
for (const e of events) {
|
|
111
|
+
// A crash writes no offline: a new run is a new attachment, and no fingerprint from before it describes later work.
|
|
112
|
+
if (e.type === "hub_start") { online.clear(); tooling.clear(); continue; }
|
|
113
|
+
if (e.type === "state") {
|
|
114
|
+
if (e.state === "offline") online.delete(e.peer);
|
|
115
|
+
else if (!online.has(e.peer)) { online.add(e.peer); attach.set(e.peer, (attach.get(e.peer) ?? 0) + 1); }
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (e.type === "tooling") {
|
|
119
|
+
const now = attach.get(e.peer) ?? 0;
|
|
120
|
+
tooling.set(e.peer, { attach: now, addons: e.addons });
|
|
121
|
+
for (const a of tasks.values()) if (a.peers.get(e.peer) === now) a.tooling[e.peer] = e.addons;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
81
124
|
if (e.type === "task") {
|
|
125
|
+
const proposed = e.event === "proposed" || e.event === "backlog pulled";
|
|
82
126
|
// Ids restart after `ahub reset --all` archives the events, but a file that kept both starts the task anew.
|
|
83
|
-
if (e.event === "proposed" && tasks.
|
|
127
|
+
if ((e.event === "proposed" && tasks.has(e.id)) || (e.event === "backlogged" && tasks.has(e.id))) { earlier.push(tasks.get(e.id)!); tasks.delete(e.id); }
|
|
84
128
|
const a = acc(e.id);
|
|
85
|
-
|
|
129
|
+
for (const field of ["release", "priority", "plannedStart", "due"] as const) if (Object.hasOwn(e, field)) a[field] = e[field];
|
|
130
|
+
a.class = e.class; if (e.role && a.role === undefined) { a.role = e.role; if (e.event === "proposed" && e.roleIdentity) a.roleIdentity = e.roleIdentity; } if (e.profile) { a.profile = e.profile; a.profileClass = e.profileClass; } if (e.pii) a.pii = true;
|
|
131
|
+
if (proposed && e.role === a.role && e.roleIdentity && a.roleIdentity === undefined) a.roleIdentity = e.roleIdentity;
|
|
86
132
|
if (e.owner && e.owner !== a.owners.at(-1)) {
|
|
87
133
|
if (a.owners.length) { a.reassignments++; bump(a.reassignedBy, e.reason ?? "none"); }
|
|
88
134
|
a.owners.push(e.owner);
|
|
89
135
|
}
|
|
90
136
|
if (e.reviewer) a.reviewer = e.reviewer;
|
|
91
|
-
if (
|
|
137
|
+
if (proposed) a.createdAt ??= e.at;
|
|
92
138
|
if (e.state === "in_progress") a.startedAt ??= e.at;
|
|
93
139
|
if (e.event === "done") a.dones++;
|
|
94
140
|
if (e.event === "check passed") a.checkPassed++;
|
|
@@ -97,14 +143,19 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
97
143
|
if (e.state === "changes_requested" && a.lastState !== "changes_requested") a.changesRequested++;
|
|
98
144
|
if (e.state === "approved") a.approvedAt ??= e.at;
|
|
99
145
|
a.lastState = e.state;
|
|
146
|
+
involve(a, e.by); involve(a, e.owner, false); involve(a, e.reviewer, false);
|
|
100
147
|
continue;
|
|
101
148
|
}
|
|
102
149
|
const task = "task" in e && typeof e.task === "number" ? e.task : undefined;
|
|
103
150
|
if (task === undefined) continue;
|
|
104
151
|
const a = acc(task);
|
|
105
152
|
if ("pii" in e && e.pii === true) a.pii = true;
|
|
153
|
+
if ("peer" in e && typeof e.peer === "string") involve(a, e.peer);
|
|
106
154
|
if (e.type === "tokens") { a.tokens.total += e.n; bump(a.tokens.byPeer, e.peer, e.n); bump(a.tokens.byAttribution, e.attribution ?? "unknown", e.n); }
|
|
107
|
-
else if (e.type === "turn_end") {
|
|
155
|
+
else if (e.type === "turn_end") {
|
|
156
|
+
a.turns++; a.activeMs += e.ms; a.filesChanged += e.files ?? 0;
|
|
157
|
+
if (e.preparedRoles !== undefined) (a.sessionRoles ??= []).push({ peer: e.peer, turn: e.turn, roles: e.preparedRoles?.map(role => ({ id: role.id, layer: role.layer, hash: role.hash })) ?? null });
|
|
158
|
+
}
|
|
108
159
|
else if (e.type === "usage") {
|
|
109
160
|
const key = `${e.peer}\0${e.source}\0${e.id}`;
|
|
110
161
|
if (seenUsage.has(key)) continue;
|
|
@@ -117,6 +168,7 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
117
168
|
}
|
|
118
169
|
else if (e.type === "route") a.models.add(e.route);
|
|
119
170
|
else if (e.type === "route_outcome") { if (e.next?.tests === "pass") a.tests.pass++; if (e.next?.tests === "fail") a.tests.fail++; }
|
|
171
|
+
else if (e.type === "memory_brief") { a.briefs.delivered++; if (e.fails > 0) a.briefs.withFail++; }
|
|
120
172
|
else if (e.type === "stuck") a.stuck++;
|
|
121
173
|
else if (e.type === "overlap") a.overlaps++;
|
|
122
174
|
else if (e.type === "conflict") a.conflicts++;
|
|
@@ -124,11 +176,12 @@ export function taskRecords(events: StampedEvent[], projectId: string, writer: T
|
|
|
124
176
|
const out: TaskRecord[] = [];
|
|
125
177
|
for (const a of [...earlier, ...tasks.values()]) {
|
|
126
178
|
if (!a.approvedAt) continue;
|
|
127
|
-
const { approvedAt, lastState: _state, models, ...rest } = a;
|
|
179
|
+
const { approvedAt, lastState: _state, models, tooling: fingerprints, peers, briefs, ...rest } = a;
|
|
128
180
|
const start = a.startedAt ? Date.parse(a.startedAt) : NaN, end = Date.parse(approvedAt);
|
|
129
181
|
out.push({ schema: RESEARCH_SCHEMA, kind: "task", project: projectKey(projectId), ...rest, outcome: "approved", approvedAt,
|
|
130
182
|
wallMs: Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null,
|
|
131
|
-
firstPass: a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer
|
|
183
|
+
firstPass: a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer,
|
|
184
|
+
...(Object.keys(fingerprints).length ? { tooling: fingerprints, peers: [...peers.keys()].sort() } : {}), ...(briefs.delivered ? { briefs } : {}) });
|
|
132
185
|
}
|
|
133
186
|
return out.sort((x, y) => x.approvedAt.localeCompare(y.approvedAt) || x.task - y.task);
|
|
134
187
|
}
|
|
@@ -175,7 +228,11 @@ export function labelTarget(records: ResearchRecord[], events: StampedEvent[], t
|
|
|
175
228
|
if (created !== undefined) {
|
|
176
229
|
return mine.find((r) => r.createdAt === created) ?? fail(`no research record for task #${task} proposed at ${created}${mine.length ? ` (it has: ${mine.map((r) => r.createdAt).join(", ")})` : ""}`);
|
|
177
230
|
}
|
|
178
|
-
const
|
|
231
|
+
const creation = events.findLast(e => e.type === "task" && e.id === task && ["proposed", "backlogged"].includes(e.event));
|
|
232
|
+
const proposed = creation?.type === "task" && creation.event === "backlogged"
|
|
233
|
+
? events.slice(events.indexOf(creation) + 1).find(e => e.type === "task" && e.id === task && e.event === "backlog pulled")
|
|
234
|
+
: creation;
|
|
235
|
+
if (creation?.type === "task" && creation.event === "backlogged" && !proposed) fail(`task #${task} is backlog and has no execution proposal; name an earlier record with --created`);
|
|
179
236
|
if (proposed) {
|
|
180
237
|
return mine.find((r) => r.createdAt === proposed.at)
|
|
181
238
|
?? fail(`task #${task} has no research record yet: it is not approved, its record still waits for the turns that approved it, or it was approved while research was off (ahub research backfill builds that one)${mine.length ? `; an earlier task #${task} from before a reset is labelled with --created ${mine.map((r) => r.createdAt).join(" or ")}` : ""}`);
|
|
@@ -207,19 +264,15 @@ function measures(records: TaskRecord[], labels: Map<string, Label>): Measures {
|
|
|
207
264
|
tokensPerTask: { median: quantile(records.map((r) => r.tokens.total), 0.5), p90: quantile(records.map((r) => r.tokens.total), 0.9) },
|
|
208
265
|
wallMsPerTask: { median: quantile(records.flatMap((r) => r.wallMs ?? []), 0.5), p90: quantile(records.flatMap((r) => r.wallMs ?? []), 0.9) } };
|
|
209
266
|
}
|
|
210
|
-
export interface ResearchReport { overall: Measures; byClass: Record<string, Measures>; byOwner: Record<string, Measures>; byProject: Record<string, Measures>; byModel: Record<string, Measures
|
|
267
|
+
export interface ResearchReport { overall: Measures; byClass: Record<string, Measures>; byOwner: Record<string, Measures>; byProject: Record<string, Measures>; byModel: Record<string, Measures>;
|
|
268
|
+
/** #258 AC11: tasks whose brief carried a hub-memory fail note, a brief without one, or no brief recorded. */
|
|
269
|
+
byMemoryBrief: Record<string, Measures> }
|
|
211
270
|
/**
|
|
212
271
|
* The derived measures, never stored as truth: each task counts once (its last record), and a person's latest label
|
|
213
272
|
* decides whether an approved task later failed. Records older than `since` (ms epoch, by approval) are left out.
|
|
214
273
|
*/
|
|
215
274
|
export function researchReport(records: ResearchRecord[], since = 0): ResearchReport {
|
|
216
|
-
const
|
|
217
|
-
for (const r of records) {
|
|
218
|
-
const key = recordKey(r);
|
|
219
|
-
if (r.kind === "label") { labels.set(key, r.label); continue; }
|
|
220
|
-
if (Date.parse(r.approvedAt) >= since) latest.set(key, r);
|
|
221
|
-
}
|
|
222
|
-
const all = [...latest.values()];
|
|
275
|
+
const { all, labels } = latestRecords(records, since);
|
|
223
276
|
const group = (keys: (r: TaskRecord) => string[]) => {
|
|
224
277
|
// A Map: a peer or model may be named `constructor`, which a plain object answers with an inherited value.
|
|
225
278
|
const out = new Map<string, TaskRecord[]>();
|
|
@@ -227,7 +280,8 @@ export function researchReport(records: ResearchRecord[], since = 0): ResearchRe
|
|
|
227
280
|
return Object.fromEntries([...out].sort(([a], [b]) => a.localeCompare(b)).map(([k, rs]) => [k, measures(rs, labels)]));
|
|
228
281
|
};
|
|
229
282
|
return { overall: measures(all, labels), byClass: group((r) => [r.class ?? "unknown"]), byOwner: group((r) => r.owners.length ? [...new Set(r.owners)] : ["none"]),
|
|
230
|
-
byProject: group((r) => [r.project]), byModel: group((r) => r.models.length ? r.models : ["unknown"])
|
|
283
|
+
byProject: group((r) => [r.project]), byModel: group((r) => r.models.length ? r.models : ["unknown"]),
|
|
284
|
+
byMemoryBrief: group((r) => [!r.briefs ? "no brief recorded" : r.briefs.withFail ? "a fail note in its brief" : "brief without a fail note"]) };
|
|
231
285
|
}
|
|
232
286
|
|
|
233
287
|
const fmtPct = (v: number | null) => v === null ? "-" : `${Math.round(v * 100)}%`;
|
|
@@ -236,7 +290,76 @@ export function formatResearch(r: ResearchReport): string[] {
|
|
|
236
290
|
const row = (name: string, m: Measures) => `${name.padEnd(24)} ${String(m.tasks).padStart(5)} ${fmtPct(m.successRate).padStart(7)} ${fmtPct(m.firstPassRate).padStart(10)} ${(m.reworkRate === null ? "-" : m.reworkRate.toFixed(2)).padStart(6)} ${fmtPct(m.checkFailureRate).padStart(11)} ${fmtNum(m.tokensPerTask.median).padStart(10)} ${fmtNum(m.tokensPerTask.p90).padStart(10)} ${(m.wallMsPerTask.median === null ? "-" : `${Math.round(m.wallMsPerTask.median / 60_000)}m`).padStart(8)}`;
|
|
237
291
|
const head = `${"".padEnd(24)} ${"tasks".padStart(5)} ${"success".padStart(7)} ${"first pass".padStart(10)} ${"rework".padStart(6)} ${"check fails".padStart(11)} ${"tokens p50".padStart(10)} ${"tokens p90".padStart(10)} ${"wall p50".padStart(8)}`;
|
|
238
292
|
const section = (title: string, groups: Record<string, Measures>) => Object.keys(groups).length ? [``, title, head, ...Object.entries(groups).map(([k, m]) => row(k, m))] : [];
|
|
239
|
-
return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject)];
|
|
293
|
+
return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject), ...(Object.keys(r.byMemoryBrief).some((k) => k !== "no brief recorded") ? section("by memory brief", r.byMemoryBrief) : [])];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Each approved task once, its last record, and every task's latest label: what both reports count. */
|
|
297
|
+
function latestRecords(records: ResearchRecord[], since: number): { all: TaskRecord[]; labels: Map<string, Label> } {
|
|
298
|
+
const latest = new Map<string, TaskRecord>(), labels = new Map<string, Label>();
|
|
299
|
+
for (const r of records) {
|
|
300
|
+
const key = recordKey(r);
|
|
301
|
+
if (r.kind === "label") { labels.set(key, r.label); continue; }
|
|
302
|
+
if (Date.parse(r.approvedAt) >= since) latest.set(key, r);
|
|
303
|
+
}
|
|
304
|
+
return { all: [...latest.values()], labels };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The peers that worked on a task: every peer with an attributed event, else (older records) owners, reviewer and token peers. */
|
|
308
|
+
const involved = (r: TaskRecord): string[] => [...new Set([...(r.peers ?? []), ...r.owners, ...(r.reviewer ? [r.reviewer] : []), ...Object.keys(r.tokens.byPeer)])].filter((p) => p !== "user");
|
|
309
|
+
/** Own properties only: a peer may be named `constructor`, and a parsed record's object would answer with an inherited one. */
|
|
310
|
+
const fingerprintOf = (r: TaskRecord, peer: string): Addon[] | undefined => r.tooling && Object.hasOwn(r.tooling, peer) ? r.tooling[peer] : undefined;
|
|
311
|
+
|
|
312
|
+
type AddonMeasure = "successRate" | "firstPassRate" | "reworkRate" | "checkFailureRate" | "tokensMedian" | "wallMsMedian" | "collisionRate";
|
|
313
|
+
const ADDON_MEASURES: AddonMeasure[] = ["successRate", "firstPassRate", "reworkRate", "checkFailureRate", "tokensMedian", "wallMsMedian", "collisionRate"];
|
|
314
|
+
/** Every add-on measure of one set of tasks, computed once (a bootstrap calls this per resample). */
|
|
315
|
+
function addonValues(rs: TaskRecord[], labels: Map<string, Label>): Record<AddonMeasure, number | null> {
|
|
316
|
+
const x = measures(rs, labels);
|
|
317
|
+
return { successRate: x.successRate, firstPassRate: x.firstPassRate, reworkRate: x.reworkRate, checkFailureRate: x.checkFailureRate,
|
|
318
|
+
tokensMedian: x.tokensPerTask.median, wallMsMedian: x.wallMsPerTask.median, collisionRate: pct(rs.reduce((n, r) => n + r.overlaps + r.conflicts, 0), rs.length) };
|
|
319
|
+
}
|
|
320
|
+
export interface AddonComparison {
|
|
321
|
+
addon: AddonName;
|
|
322
|
+
/** Tasks where a peer that worked on it had the add-on, where every such peer is known not to, and the rest. */
|
|
323
|
+
on: number; off: number; unknown: number;
|
|
324
|
+
measures: { measure: AddonMeasure; on: number | null; off: number | null; diff: number | null; low: number | null; high: number | null; inconclusive: boolean }[];
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* #248, observational: approved tasks with each add-on on against those with it off, by the tooling fingerprint each
|
|
328
|
+
* record carries. The difference is on minus off with a 95% bootstrap interval (seeded, so the same records give the
|
|
329
|
+
* same interval); below MIN_ATTEMPTS tasks on either side it is inconclusive. Field data shows association only: the
|
|
330
|
+
* peers, task mix and time differ between the sides. The causal side is `ahub bench` arms (docs/bench.md).
|
|
331
|
+
*/
|
|
332
|
+
export function addonReport(records: ResearchRecord[], since = 0, resamples = 1000, seed = 248): AddonComparison[] {
|
|
333
|
+
const { all, labels } = latestRecords(records, since);
|
|
334
|
+
return ADDONS.map((addon) => {
|
|
335
|
+
const side = { on: [] as TaskRecord[], off: [] as TaskRecord[], unknown: [] as TaskRecord[] };
|
|
336
|
+
for (const r of all) side[addonState(addon, involved(r).map((p) => fingerprintOf(r, p)))].push(r);
|
|
337
|
+
const random = mulberry32(seed);
|
|
338
|
+
const pick = (xs: TaskRecord[]) => Array.from({ length: xs.length }, () => xs[Math.floor(random() * xs.length)]!);
|
|
339
|
+
const samples = new Map<AddonMeasure, number[]>(ADDON_MEASURES.map((m) => [m, []]));
|
|
340
|
+
if (side.on.length && side.off.length) for (let i = 0; i < resamples; i++) {
|
|
341
|
+
const a = addonValues(pick(side.on), labels), b = addonValues(pick(side.off), labels);
|
|
342
|
+
for (const m of ADDON_MEASURES) { const x = a[m], y = b[m]; if (x !== null && y !== null) samples.get(m)!.push(x - y); }
|
|
343
|
+
}
|
|
344
|
+
const onValues = addonValues(side.on, labels), offValues = addonValues(side.off, labels);
|
|
345
|
+
return { addon, on: side.on.length, off: side.off.length, unknown: side.unknown.length, measures: ADDON_MEASURES.map((measure) => {
|
|
346
|
+
const on = onValues[measure], off = offValues[measure];
|
|
347
|
+
const s = samples.get(measure)!.sort((x, y) => x - y);
|
|
348
|
+
const rank = (q: number) => s[Math.min(s.length - 1, Math.max(0, Math.ceil(q * s.length) - 1))]!;
|
|
349
|
+
const diff = on !== null && off !== null ? on - off : null;
|
|
350
|
+
return { measure, on, off, diff, low: s.length ? rank(0.025) : null, high: s.length ? rank(0.975) : null,
|
|
351
|
+
inconclusive: diff === null || side.on.length < MIN_ATTEMPTS || side.off.length < MIN_ATTEMPTS };
|
|
352
|
+
}) };
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
export function formatAddons(rows: AddonComparison[]): string[] {
|
|
356
|
+
const fmt = (m: AddonMeasure, v: number | null) => v === null ? "-" : m === "tokensMedian" ? String(Math.round(v)) : m === "wallMsMedian" ? `${Math.round(v / 60_000)}m` : m === "reworkRate" || m === "collisionRate" ? v.toFixed(2) : `${Math.round(v * 100)}%`;
|
|
357
|
+
const out = ["add-on tasks with it on / off / unknown; on minus off with a 95% bootstrap interval (association, not cause)"];
|
|
358
|
+
for (const r of rows) {
|
|
359
|
+
out.push("", `${r.addon}: ${r.on} on, ${r.off} off, ${r.unknown} unknown`);
|
|
360
|
+
for (const m of r.measures) out.push(` ${m.measure.padEnd(16)} on ${fmt(m.measure, m.on).padStart(6)} off ${fmt(m.measure, m.off).padStart(6)} diff ${fmt(m.measure, m.diff).padStart(6)} (${fmt(m.measure, m.low)} to ${fmt(m.measure, m.high)})${m.inconclusive ? ` inconclusive (fewer than ${MIN_ATTEMPTS} tasks on a side)` : ""}`);
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
240
363
|
}
|
|
241
364
|
|
|
242
365
|
export const CSV_COLUMNS = ["project", "task", "class", "pii", "outcome", "createdAt", "startedAt", "approvedAt", "wallMs", "activeMs", "owners",
|
package/src/hub/roles.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { sanitize } from "./envelope.ts";
|
|
5
|
+
import { SHIPPED_ROLES } from "./shipped-roles.ts";
|
|
6
|
+
|
|
7
|
+
export const ROLE_SECTIONS = ["Scope", "Not in scope", "Deliverables", "Hub use", "Review duties", "Evidence"] as const;
|
|
8
|
+
const BASES = ["planner", "implementer", "verifier", "reviewer"];
|
|
9
|
+
const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"];
|
|
10
|
+
const ID = /^[a-z][a-z0-9-]{0,47}$/;
|
|
11
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
12
|
+
export interface CatalogRole {
|
|
13
|
+
id: string; name: string; group: string; summary: string; base: string[]; classes: string[]; source: string;
|
|
14
|
+
body: string; review: string; layer: "shipped" | "project"; hash: string;
|
|
15
|
+
}
|
|
16
|
+
export interface RoleCatalog { roles: Map<string, CatalogRole>; errors: { id: string; reason: string }[] }
|
|
17
|
+
/** Flat headers are data, never YAML execution or authority configuration. */
|
|
18
|
+
export function parseRole(text: string, stem: string, layer: CatalogRole["layer"]): CatalogRole {
|
|
19
|
+
if (Buffer.byteLength(text) > 8192 || text.includes("\r") || !ID.test(stem)) throw new Error("invalid role size, encoding or file id");
|
|
20
|
+
const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text);
|
|
21
|
+
if (!match) throw new Error("expected a flat header and body");
|
|
22
|
+
const header: Record<string, string> = Object.create(null);
|
|
23
|
+
const keys = ["id", "name", "group", "summary", "base", "classes", "source"];
|
|
24
|
+
for (const line of match[1]!.split("\n")) {
|
|
25
|
+
const field = /^([a-z]+): (.+)$/.exec(line);
|
|
26
|
+
if (!field || !keys.includes(field[1]!) || header[field[1]!] !== undefined || /[\x00-\x1f\x7f]/.test(field[2]!)) throw new Error("invalid or duplicate header field");
|
|
27
|
+
header[field[1]!] = field[2]!;
|
|
28
|
+
}
|
|
29
|
+
if (keys.some(key => !header[key]) || header.id !== stem || !ID.test(header.group!) || header.name!.length > 80 || header.summary!.length > 160 || header.source!.length > 300) throw new Error("invalid header identity or limits");
|
|
30
|
+
const list = (key: string, allowed: string[]) => {
|
|
31
|
+
const values = header[key]!.split(",").map(v => v.trim());
|
|
32
|
+
if (!values.length || new Set(values).size !== values.length || values.some(v => !allowed.includes(v))) throw new Error(`invalid ${key}`);
|
|
33
|
+
return values;
|
|
34
|
+
};
|
|
35
|
+
const body = match[2]!.trimEnd();
|
|
36
|
+
if (!body || sanitize(body).length > 2000 || /```|~~~|^\t|^ {4}|^(?:\$\s|(?:bun|npm|npx|git|ssh|curl|wget|rm|bash|sh|python[0-9.]*)\s)/m.test(body)) throw new Error("invalid body size or command/code block");
|
|
37
|
+
for (const char of text) if ((char.codePointAt(0)! > 127 && /[\p{P}\p{S}\p{Z}\p{C}]/u.test(char)) || /[\x00-\x08\x0b-\x1f\x7f]/.test(char)) throw new Error("use plain ASCII punctuation and no controls/emoji");
|
|
38
|
+
const headings = [...body.matchAll(/^#{1,6} (.+)$/gm)];
|
|
39
|
+
if (headings.length !== ROLE_SECTIONS.length || headings.some(h => !h[0].startsWith("## "))) throw new Error("unexpected role heading or personality section");
|
|
40
|
+
const sections = [...body.matchAll(/^## (.+)$/gm)];
|
|
41
|
+
if (sections.length !== ROLE_SECTIONS.length || sections.some((s, i) => s[1] !== ROLE_SECTIONS[i])) throw new Error("expected the six ordered role sections");
|
|
42
|
+
for (let i = 0; i < sections.length; i++) {
|
|
43
|
+
const start = sections[i]!.index! + sections[i]![0].length;
|
|
44
|
+
const end = sections[i + 1]?.index ?? body.length;
|
|
45
|
+
if (!body.slice(start, end).trim()) throw new Error("empty role section");
|
|
46
|
+
}
|
|
47
|
+
const reviewStart = sections[4]!.index! + sections[4]![0].length;
|
|
48
|
+
const review = body.slice(reviewStart, sections[5]!.index).trim();
|
|
49
|
+
return { id: stem, name: header.name!, group: header.group!, summary: header.summary!, source: header.source!, base: list("base", BASES), classes: list("classes", CLASSES), body, review, layer, hash: createHash("sha256").update(text).digest("hex") };
|
|
50
|
+
}
|
|
51
|
+
function projectRole(file: string): string {
|
|
52
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
53
|
+
try {
|
|
54
|
+
const before = fstatSync(fd);
|
|
55
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > 8192) throw new Error("role must be a bounded regular file");
|
|
56
|
+
const bytes = Buffer.alloc(8193); let length = 0, n = 0;
|
|
57
|
+
while ((n = readSync(fd, bytes, length, bytes.length - length, null)) > 0) { length += n; if (length > 8192) throw new Error("role exceeds file cap"); }
|
|
58
|
+
const after = fstatSync(fd);
|
|
59
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || length !== after.size) throw new Error("role changed during read");
|
|
60
|
+
return bytes.subarray(0, length).toString("utf8");
|
|
61
|
+
} finally { closeSync(fd); }
|
|
62
|
+
}
|
|
63
|
+
export function loadRoleCatalog(cwd?: string): RoleCatalog {
|
|
64
|
+
const roles = new Map<string, CatalogRole>(), errors: RoleCatalog["errors"] = [];
|
|
65
|
+
for (const [id, text] of Object.entries(SHIPPED_ROLES)) {
|
|
66
|
+
try { roles.set(id, parseRole(text, id, "shipped")); } catch { errors.push({ id, reason: "invalid shipped role" }); }
|
|
67
|
+
}
|
|
68
|
+
if (!cwd) return { roles, errors };
|
|
69
|
+
try {
|
|
70
|
+
const config = join(cwd, ".agenthub"), dir = join(config, "roles");
|
|
71
|
+
for (const path of [config, dir]) { const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe role directory"); }
|
|
72
|
+
// ponytail: 64 flat project files, no recursive scan; explicit catalog index before larger collections.
|
|
73
|
+
const files = readdirSync(dir).filter(name => name.endsWith(".md")).sort();
|
|
74
|
+
if (files.length > 64) throw new Error("too many project roles");
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const id = file.slice(0, -3);
|
|
77
|
+
try { roles.set(id, parseRole(projectRole(join(dir, file)), id, "project")); }
|
|
78
|
+
catch { errors.push({ id: ID.test(id) ? id : "invalid-file-id", reason: "invalid or unsafe project role" }); }
|
|
79
|
+
}
|
|
80
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push({ id: "project", reason: "project role directory unavailable or unsafe" }); }
|
|
81
|
+
return { roles, errors };
|
|
82
|
+
}
|
|
83
|
+
export function catalogAssignments(raw: unknown): Record<string, string[]> {
|
|
84
|
+
if (raw === undefined) return {};
|
|
85
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length > 64) throw new Error("catalog_roles must be a bounded peer map");
|
|
86
|
+
const out: Record<string, string[]> = Object.create(null);
|
|
87
|
+
for (const [peer, ids] of Object.entries(raw)) {
|
|
88
|
+
if (!PEER.test(peer) || ["user", "hub", "digest"].includes(peer) || !Array.isArray(ids) || ids.length > 2 || new Set(ids).size !== ids.length || ids.some(id => typeof id !== "string" || !ID.test(id))) throw new Error("catalog_roles takes at most two distinct role ids per peer");
|
|
89
|
+
out[peer] = [...ids];
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
export function selectedRoles(peer: string, flow: Record<string, string[]>, assignments: Record<string, string[]>, catalog: RoleCatalog): CatalogRole[] {
|
|
94
|
+
return (assignments[peer] ?? []).map(id => {
|
|
95
|
+
const role = catalog.roles.get(id); if (!role) throw new Error(`unknown catalog role ${id}`);
|
|
96
|
+
if (!role.base.some(base => flow[peer]?.includes(base))) throw new Error(`${peer} does not hold a matching base for ${id}`);
|
|
97
|
+
return role;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export function catalogSection(roles: CatalogRole[]): string {
|
|
101
|
+
if (!roles.length) return "";
|
|
102
|
+
if (roles.length > 2) throw new Error("at most two session roles");
|
|
103
|
+
const header = "Catalog roles (guidance; grant no tool, capability or permission): " + roles.map(role => `${role.id} [${role.group}, ${role.layer}, ${role.hash}]`).join(", ");
|
|
104
|
+
return header + "\n" + roles.map(role => sanitize(role.body)).join("\n\n");
|
|
105
|
+
}
|
|
106
|
+
export const roleIdentity = (role: CatalogRole) => ({ id: role.id, layer: role.layer, hash: role.hash });
|
|
107
|
+
|
|
108
|
+
/** Validate before a board write. Routing continues to use only the existing base class. */
|
|
109
|
+
export function taskRole(raw: unknown, cls: string, catalog: RoleCatalog): CatalogRole | undefined {
|
|
110
|
+
if (raw === undefined) return undefined;
|
|
111
|
+
const allowed = [...catalog.roles.values()].filter(role => role.classes.includes(cls)).map(role => role.id).sort();
|
|
112
|
+
if (typeof raw !== "string" || !ID.test(raw)) throw new Error("task role must be a catalog id; allowed: " + allowed.join(", "));
|
|
113
|
+
const role = catalog.roles.get(raw);
|
|
114
|
+
if (!role || !role.classes.includes(cls)) throw new Error("unknown or class-mismatched task role; allowed: " + allowed.join(", "));
|
|
115
|
+
taskRoleText(role); // refuse an oversized complete notice before the board write
|
|
116
|
+
return role;
|
|
117
|
+
}
|
|
118
|
+
/** Role definitions are untrusted presentation, including their metadata. */
|
|
119
|
+
export function taskRoleText(role: CatalogRole, review = false): string {
|
|
120
|
+
const text = `Task role ${role.id} [${role.layer}, ${role.hash}] (guidance; grants no authority):\n` + sanitize(review ? role.review : role.body).replace(/\[agent-hub\b/gi, "[peer-text");
|
|
121
|
+
if (text.length > 2000) throw new Error("task role guidance exceeds 2000 characters including its header");
|
|
122
|
+
return text;
|
|
123
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isSupportedPeer, type KnownPeer } from "./peer-catalog.ts";
|
|
2
|
+
|
|
3
|
+
export type PeerRoster = Partial<Record<KnownPeer, boolean>>;
|
|
4
|
+
// Default participation policy, not another catalog. New catalog members default off.
|
|
5
|
+
const DEFAULT_ON: Partial<Record<KnownPeer, boolean>> = { claude: true, codex: true, kimi: true, local: true };
|
|
6
|
+
|
|
7
|
+
export function peerRosterConfig(value: unknown): PeerRoster {
|
|
8
|
+
if (value === undefined) return {};
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
|
|
10
|
+
const roster: PeerRoster = {};
|
|
11
|
+
for (const [peer, on] of Object.entries(value)) {
|
|
12
|
+
if (!isSupportedPeer(peer)) throw new Error("roster contains an unsupported peer");
|
|
13
|
+
if (typeof on !== "boolean") throw new Error(`roster.${peer} must be a boolean`);
|
|
14
|
+
roster[peer] = on;
|
|
15
|
+
}
|
|
16
|
+
return roster;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
|
|
20
|
+
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean): boolean {
|
|
21
|
+
if (!isSupportedPeer(peer)) return true;
|
|
22
|
+
return roster[peer] ?? (peer === "pi" ? piEnabled : DEFAULT_ON[peer] ?? false);
|
|
23
|
+
}
|