@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.
Files changed (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. package/templates/roles/research/statistician.md +26 -0
@@ -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.get(e.id)?.createdAt) { earlier.push(tasks.get(e.id)!); tasks.delete(e.id); }
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
- a.class = e.class; if (e.pii) a.pii = true;
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 (e.event === "proposed") a.createdAt ??= e.at;
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") { a.turns++; a.activeMs += e.ms; a.filesChanged += e.files ?? 0; }
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 proposed = events.filter((e) => e.type === "task" && e.id === task && e.event === "proposed").at(-1);
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 latest = new Map<string, TaskRecord>(), labels = new Map<string, Label>();
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",
@@ -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
+ }