@staix/agent-hub 0.12.23 → 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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +8 -3
  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 +8 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +4 -0
  15. package/docs/agent-notes/tests.md +2 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +651 -14
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +1 -1
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +282 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verified.json +22 -13
  43. package/package.json +2 -1
  44. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  45. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  46. package/plugins/agent-hub/server.js +1175 -339
  47. package/scripts/check-docs.mjs +129 -0
  48. package/src/adapters/acp.ts +112 -13
  49. package/src/adapters/claude-channel.ts +48 -11
  50. package/src/adapters/codex-appserver.ts +11 -0
  51. package/src/adapters/jcode.ts +110 -0
  52. package/src/adapters/local-worker.ts +10 -7
  53. package/src/adapters/pi.ts +94 -20
  54. package/src/cli/attention.ts +60 -0
  55. package/src/cli/bench.ts +24 -5
  56. package/src/cli/board-read.ts +12 -0
  57. package/src/cli/console-state.ts +103 -17
  58. package/src/cli/console.ts +54 -8
  59. package/src/cli/facts-hook.ts +12 -4
  60. package/src/cli/help.ts +35 -9
  61. package/src/cli/identity.ts +18 -7
  62. package/src/cli/init.ts +48 -10
  63. package/src/cli/jcode-mcp.ts +58 -0
  64. package/src/cli/kimi-mcp.ts +53 -0
  65. package/src/cli/launch.ts +16 -1
  66. package/src/cli/main.ts +392 -57
  67. package/src/cli/memory.ts +193 -0
  68. package/src/cli/output.ts +45 -4
  69. package/src/cli/pet-config.ts +1 -0
  70. package/src/cli/pet.ts +83 -0
  71. package/src/cli/preview.ts +38 -5
  72. package/src/cli/status-lines.ts +1 -1
  73. package/src/cli/tail-render.ts +1 -0
  74. package/src/cli/upgrade-runtime.ts +32 -3
  75. package/src/cli/watch.ts +31 -0
  76. package/src/hub/activity.ts +53 -0
  77. package/src/hub/ask.ts +10 -5
  78. package/src/hub/bench.ts +10 -3
  79. package/src/hub/board.ts +222 -16
  80. package/src/hub/bus.ts +48 -9
  81. package/src/hub/citations.ts +7 -0
  82. package/src/hub/conductor.ts +12 -7
  83. package/src/hub/config-trust.ts +4 -2
  84. package/src/hub/crash.ts +9 -0
  85. package/src/hub/daemon.ts +910 -120
  86. package/src/hub/delivery-journal.ts +5 -0
  87. package/src/hub/envelope.ts +1 -1
  88. package/src/hub/events.ts +12 -2
  89. package/src/hub/facts.ts +26 -7
  90. package/src/hub/git.ts +8 -2
  91. package/src/hub/github-import.ts +28 -0
  92. package/src/hub/hub-tools.ts +49 -10
  93. package/src/hub/inference.ts +1 -1
  94. package/src/hub/lifecycle-schema.ts +17 -0
  95. package/src/hub/listen.ts +15 -0
  96. package/src/hub/manager.ts +12 -5
  97. package/src/hub/path-policy.ts +96 -0
  98. package/src/hub/peer-catalog.ts +28 -0
  99. package/src/hub/permission-mode.ts +19 -5
  100. package/src/hub/planning.ts +70 -0
  101. package/src/hub/profiles.ts +74 -0
  102. package/src/hub/projected-diff.ts +73 -0
  103. package/src/hub/projection-exec.ts +24 -0
  104. package/src/hub/research.ts +144 -21
  105. package/src/hub/roles.ts +123 -0
  106. package/src/hub/roster.ts +23 -0
  107. package/src/hub/routing.ts +18 -5
  108. package/src/hub/sdlc-queue.ts +112 -0
  109. package/src/hub/sdlc-release.ts +125 -0
  110. package/src/hub/sdlc.ts +250 -0
  111. package/src/hub/settings.ts +34 -15
  112. package/src/hub/shipped-roles.ts +25 -0
  113. package/src/hub/snapshots.ts +89 -15
  114. package/src/hub/start-mode.ts +7 -5
  115. package/src/hub/supervision.ts +1 -0
  116. package/src/hub/task-sweep.ts +1 -1
  117. package/src/hub/tasks.ts +892 -87
  118. package/src/hub/text-projection.ts +126 -0
  119. package/src/hub/tooling.ts +159 -0
  120. package/src/hub/ui.ts +16 -2
  121. package/src/hub/workflow.ts +71 -0
  122. package/src/local/sandbox.ts +6 -4
  123. package/src/local/tools.ts +11 -6
  124. package/src/memory/brief.ts +28 -14
  125. package/src/memory/import.ts +55 -0
  126. package/src/memory/recall.ts +84 -0
  127. package/src/memory/store.ts +750 -0
  128. package/src/memory/sync.ts +145 -0
  129. package/src/pet/NOTICE.md +10 -0
  130. package/src/pet/characters.ts +1438 -0
  131. package/src/pet/config.ts +31 -0
  132. package/src/pet/frame.ts +17 -0
  133. package/src/pet/pixels.ts +28 -0
  134. package/src/role-text.d.ts +1 -0
  135. package/src/ui/index.html +842 -46
  136. package/src/ui/needs.ts +58 -0
  137. package/src/ui/office-model.ts +440 -0
  138. package/src/ui/peer-palette.ts +16 -0
  139. package/src/ui/task-board.ts +49 -0
  140. package/src/ui/task-progress.ts +5 -1
  141. package/templates/AGENTS.block.md +3 -2
  142. package/templates/config.json +5 -1
  143. package/templates/profiles/dev.json +22 -0
  144. package/templates/profiles/general.json +26 -0
  145. package/templates/profiles/research.json +57 -0
  146. package/templates/profiles/writing.json +28 -0
  147. package/templates/roles/NOTICE.md +26 -0
  148. package/templates/roles/dev/backend-engineer.md +26 -0
  149. package/templates/roles/dev/code-reviewer.md +26 -0
  150. package/templates/roles/dev/frontend-engineer.md +26 -0
  151. package/templates/roles/dev/security-reviewer.md +26 -0
  152. package/templates/roles/dev/software-architect.md +26 -0
  153. package/templates/roles/dev/test-engineer.md +26 -0
  154. package/templates/roles/research/analyst.md +26 -0
  155. package/templates/roles/research/fact-checker.md +26 -0
  156. package/templates/roles/research/research-lead.md +26 -0
  157. package/templates/roles/research/researcher.md +26 -0
  158. package/templates/roles/research/statistician.md +26 -0
@@ -1,17 +1,20 @@
1
+ import { isSupportedPeer } from "./peer-catalog.ts";
1
2
  import { lstatSync } from "node:fs";
2
- import { isAbsolute, relative, sep } from "node:path";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
4
 
4
5
  /** Operator-selected approval frequency; agent sandboxes remain separate. */
5
6
  export const PERMISSION_MODES = ["ask", "ask-when-needed", "never-ask"] as const;
6
7
  export type PermissionMode = typeof PERMISSION_MODES[number];
7
8
  export const KIMI_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "yolo", "never-ask": "auto" };
9
+ export const QWEN_MODE_IDS: Readonly<Record<PermissionMode, string>> = { ask: "default", "ask-when-needed": "auto-edit", "never-ask": "yolo" };
8
10
  export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
9
11
  export function permissionDefaults(value: unknown): Record<string, PermissionMode> {
10
12
  if (value === undefined) return {};
11
13
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("permission_modes must be an object with ask, ask-when-needed or never-ask values");
12
14
  const result: Record<string, PermissionMode> = {};
13
15
  for (const [peer, mode] of Object.entries(value)) {
14
- if (!["claude", "codex", "kimi", "pi", "local"].includes(peer)) throw new Error(`permission_modes.${peer}: unsupported peer`);
16
+ if (!isSupportedPeer(peer)) throw new Error(`permission_modes.${peer}: unsupported peer`);
17
+ if (peer === "jcode") throw new Error("Jcode native policy is unmanaged; no hub permission_modes.jcode mapping exists");
15
18
  if (!isPermissionMode(mode)) throw new Error(`permission_modes.${peer} must be ask, ask-when-needed or never-ask`);
16
19
  result[peer] = mode;
17
20
  }
@@ -19,13 +22,16 @@ export function permissionDefaults(value: unknown): Record<string, PermissionMod
19
22
  }
20
23
  /** Approval frequency does not create a common sandbox across native peers. */
21
24
  export function permissionBoundary(peer: string): string {
25
+ if (peer === "jcode") return "jcode: native policy unmanaged; NO hub sandbox; approvals belong to its native tools";
22
26
  if (peer === "pi" || peer === "local") return `${peer}: inside hub sandbox, path guard and denylist`;
23
27
  if (peer === "claude" || peer === "codex") return `${peer}: native vendor bounds; no hub sandbox`;
24
28
  return `${peer}: runs its own tools; NO hub sandbox`;
25
29
  }
26
30
  /** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
27
31
  export function permissionGrant(peer: string, mode: PermissionMode): string {
32
+ if (peer === "jcode") return "no verified hub mode mapping; native policy only";
28
33
  if (mode === "ask") return peer === "codex" ? "the session's own approval policy applies again" : peer === "kimi" ? "Kimi asks as it does on its own" : peer === "claude" ? "Claude's own permission rules decide" : "every write, edit and shell command asks";
34
+ if (peer === "qwen") return mode === "never-ask" ? "Qwen yolo approves every tool; NO hub sandbox" : "Qwen auto-edit approves file edits; shell commands ask; NO hub sandbox";
29
35
  if (peer === "kimi") return mode === "never-ask" ? "Kimi's auto mode: nothing asks" : "Kimi's yolo mode: it asks only where it judges it must";
30
36
  if (peer === "codex") return mode === "never-ask" ? "approval policy never on every turn: nothing asks" : "approval policy on-request on every turn";
31
37
  if (peer === "claude") return mode === "never-ask" ? "every tool runs without asking" : "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules";
@@ -33,7 +39,14 @@ export function permissionGrant(peer: string, mode: PermissionMode): string {
33
39
  }
34
40
  export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
35
41
  /** Native agent policy files are never included in scoped automatic file grants. */
36
- export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".pi", ".mcp.json"]);
42
+ export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".kimi-code", ".jcode", ".pi", ".mcp.json", ".agents", ".claude.json"]);
43
+ export const INSTRUCTION_FILES: ReadonlySet<string> = new Set(["agents.md", "claude.md", "qwen.md"]);
44
+ export const isInstructionPath = (path: string): boolean => path.split(/[\\/]/).some(segment => INSTRUCTION_FILES.has(foldSegment(segment)));
45
+ export function instructionProtection(value: unknown): boolean {
46
+ if (value === undefined) return false;
47
+ if (typeof value !== "boolean") throw new Error("protect_instruction_files must be a boolean");
48
+ return value;
49
+ }
37
50
  /**
38
51
  * One spelling per name, as a case-insensitive disk sees it: it opens `.mcp.j\u017Fon` (long s) as `.mcp.json`, and
39
52
  * HFS+ also ignores the default-ignorable code points (a zero-width joiner inside `.codex` is still `.codex`).
@@ -52,7 +65,8 @@ export function hardLinked(path: string): boolean {
52
65
  * inside the project, outside every agent's configuration, judged by the path within the project (a project that
53
66
  * itself lives under `.claude/worktrees/` is still a project), and not a hard link.
54
67
  */
55
- export function grantablePath(root: string, path: string): boolean {
68
+ export function grantablePath(root: string, path: string, originalPath?: string, protectInstructions = false): boolean {
56
69
  const inside = relative(root, path);
57
- return !!inside && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside) && !isAgentConfigPath(inside) && !isHubWritePath(inside) && !hardLinked(path);
70
+ const original = originalPath === undefined ? inside : relative(root, resolve(root, originalPath));
71
+ return !!inside && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside) && !isAgentConfigPath(inside) && !isHubWritePath(inside) && !isAgentConfigPath(original) && !isHubWritePath(original) && !(protectInstructions && (isInstructionPath(inside) || isInstructionPath(original))) && !hardLinked(path);
58
72
  }
@@ -0,0 +1,70 @@
1
+ import type { Task } from "./board.ts";
2
+
3
+ export const BACKLOG_SPLIT_SCREEN_TIMEOUT_MS = 8000;
4
+
5
+ export const PLANNING_FIELDS = ["rank", "priority", "release", "plannedStart", "due", "pinned", "deps", "reserved"] as const;
6
+ export interface PlanningFields {
7
+ rank?: number | null;
8
+ priority?: number | null;
9
+ release?: number | null;
10
+ plannedStart?: number | null;
11
+ due?: number | null;
12
+ pinned?: boolean;
13
+ deps?: number[];
14
+ reserved?: string | null;
15
+ }
16
+ export interface PlanningPatch extends PlanningFields {}
17
+ /** Machine-local capacity settings; ids never grant roster membership or authority. */
18
+ export function planningWipLimits(input: unknown): Record<string, number> {
19
+ if (input === undefined) return {};
20
+ if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).length > 64) throw new Error("tasks.wip_limits must be a bounded peer map");
21
+ const limits: Record<string, number> = Object.create(null);
22
+ for (const [peer, value] of Object.entries(input)) {
23
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 32) throw new Error("tasks.wip_limits needs peer ids and integer capacities 0..32");
24
+ limits[peer] = value;
25
+ }
26
+ return limits;
27
+ }
28
+ export const RELEASE_STATES = ["planning", "active", "frozen", "shipped", "dropped"] as const;
29
+ export type ReleaseState = (typeof RELEASE_STATES)[number];
30
+ export interface Release {
31
+ id: number; name: string; goal: string; order: number; state: ReleaseState;
32
+ plannedStart: number | null; cut: number | null; ship: number | null;
33
+ activatedAt: number | null; frozenAt: number | null; shippedAt: number | null;
34
+ created: number; updated: number; revision: number;
35
+ }
36
+
37
+ /** Validate the entire planning input before a write; actual dates are never caller input. */
38
+ export function planningPatch(input: unknown): PlanningPatch {
39
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("planning fields must be an object");
40
+ const fields = input as Record<string, unknown>;
41
+ if (!Object.keys(fields).length || Object.keys(fields).some(key => !(PLANNING_FIELDS as readonly string[]).includes(key))) throw new Error("unknown or empty planning fields");
42
+ const result: PlanningPatch = {};
43
+ for (const key of PLANNING_FIELDS) {
44
+ if (!Object.hasOwn(fields, key)) continue;
45
+ const value = fields[key];
46
+ if (key === "deps") {
47
+ if (!Array.isArray(value) || value.length > 64 || value.some(id => !Number.isSafeInteger(id) || id <= 0) || new Set(value).size !== value.length) throw new Error("deps must be at most 64 unique positive task ids");
48
+ result.deps = [...value];
49
+ } else if (key === "reserved") {
50
+ const peer = typeof value === "string" ? value.trim().toLowerCase() : null;
51
+ if (value !== null && (!peer || !/^[a-z][a-z0-9-]{0,31}$/.test(peer) || ["user", "hub", "digest"].includes(peer))) throw new Error("reserved must be a valid agent peer id or null");
52
+ result.reserved = peer;
53
+ } else if (key === "pinned") {
54
+ if (typeof value !== "boolean") throw new Error("pinned must be a boolean");
55
+ result.pinned = value;
56
+ } else {
57
+ if (value !== null && (typeof value !== "number" || !Number.isSafeInteger(value) || value < (key === "release" || key === "plannedStart" || key === "due" ? 1 : 0) || (key === "priority" && value > 3) || (["plannedStart", "due"].includes(key) && value > 8_640_000_000_000_000))) throw new Error(`invalid planning field ${key}`);
58
+ result[key] = value as number | null;
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /** Structured lifecycle events only, including reviewerless approval via done. */
65
+ export function actualTaskDates(task: Pick<Task, "history" | "state" | "reviewer">) {
66
+ const first = (events: readonly string[]) => task.history.find(entry => events.includes(entry.event))?.at ?? null;
67
+ const approved = first(["approved"]), reviewed = task.reviewer !== null || task.history.some(entry => entry.event === "approved" || entry.event === "changes_requested");
68
+ const reviewerlessDone = task.state === "approved" && !task.reviewer ? [...task.history].reverse().find(entry => entry.event === "done")?.at ?? null : null;
69
+ return { startedAt: first(["accepted"]), reviewAt: reviewed ? first(["done"]) : null, doneAt: approved ?? reviewerlessDone };
70
+ }
@@ -0,0 +1,74 @@
1
+ import dev from "../../templates/profiles/dev.json";
2
+ import research from "../../templates/profiles/research.json";
3
+ import writing from "../../templates/profiles/writing.json";
4
+ import general from "../../templates/profiles/general.json";
5
+ import { ALL_PLAN_KEYS, CLASSES, type TaskClass } from "./board.ts";
6
+ import { catalogAssignments } from "./roles.ts";
7
+ import { sanitize } from "./envelope.ts";
8
+ export const PROFILE_NAMES = ["dev", "research", "writing", "general"] as const;
9
+ export type ProfileName = (typeof PROFILE_NAMES)[number];
10
+ export interface ProjectProfile {
11
+ name: string; classes: Record<string, string>; plan_keys: string[]; role_text: Record<string, string>;
12
+ catalog_roles: Record<string, string[]>; agents: string; example_checks: string[];
13
+ }
14
+ const PROFILES: Record<ProfileName, ProjectProfile> = { dev, research, writing, general };
15
+ export function profileName(raw: unknown): ProfileName {
16
+ if (raw === undefined) return "dev";
17
+ if (typeof raw !== "string" || !PROFILE_NAMES.includes(raw as ProfileName)) throw new Error(`profile must be one of ${PROFILE_NAMES.join(", ")}`);
18
+ return raw as ProfileName;
19
+ }
20
+ export const projectProfile = (raw?: unknown): ProjectProfile => PROFILES[profileName(raw)];
21
+ export type ProfileSelection = ProfileName | ProjectProfile;
22
+ export const profileData = (profile: ProfileSelection = "dev"): ProjectProfile => typeof profile === "string" ? projectProfile(profile) : profile;
23
+ export function profileClass(raw: string, profile: ProfileSelection = "dev"): TaskClass {
24
+ const data = profileData(profile);
25
+ const base = typeof raw === "string" ? (Object.hasOwn(data.classes, raw) ? data.classes[raw] : Object.hasOwn(dev.classes, raw) ? (dev.classes as Record<string, string>)[raw] : undefined) : undefined;
26
+ if (!base) throw new Error(`class must be one of ${Object.keys(data.classes).join(", ")} (profile ${data.name}; existing base classes also accepted)`);
27
+ return base as TaskClass;
28
+ }
29
+ export const profileTaskLabel = (task: { class: string; profile?: string; profileClass?: string }) => task.profileClass ? `${task.profileClass} (base ${task.class}, ${task.profile})` : task.class;
30
+ export const PROFILE_CLASS_NAMES = [...new Set(Object.values(PROFILES).flatMap(p => Object.keys(p.classes)))];
31
+
32
+ /** Pure project data resolution. No mutation of shipped profiles or executable check defaults. */
33
+ export function resolveProfile(raw?: unknown, overrides?: unknown): ProjectProfile {
34
+ const original = projectProfile(raw);
35
+ if (overrides === undefined) return original;
36
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) throw new Error("profile_overrides must be an object");
37
+ const data = overrides as Record<string, unknown>, allowed = new Set(["classes", "plan_keys", "role_text", "catalog_roles", "agents", "example_checks"]);
38
+ if (Object.keys(data).some(key => !allowed.has(key))) throw new Error("unknown profile override field");
39
+ if (!Object.keys(data).length) return original;
40
+ const result: ProjectProfile = { ...original, classes: { ...original.classes }, plan_keys: [...original.plan_keys], role_text: { ...original.role_text }, catalog_roles: Object.fromEntries(Object.entries(original.catalog_roles).map(([peer, ids]) => [peer, [...ids]])), example_checks: [...original.example_checks] };
41
+ const text = (value: unknown, cap: number): string => { if (typeof value !== "string" || value.length > cap) throw new Error("profile text is missing or oversized"); return sanitize(value); };
42
+ if (data.classes !== undefined) {
43
+ if (!data.classes || typeof data.classes !== "object" || Array.isArray(data.classes) || Object.keys(data.classes).length > 32) throw new Error("profile classes must be a bounded base-class map");
44
+ for (const [name, base] of Object.entries(data.classes)) {
45
+ if (!/^[a-z][a-z0-9_]{0,47}$/.test(name) || typeof base !== "string" || !CLASSES.includes(base as TaskClass) || CLASSES.includes(name as TaskClass) && name !== base) throw new Error("profile class must retain an existing base-class meaning");
46
+ result.classes[name] = base;
47
+ }
48
+ }
49
+ if (data.plan_keys !== undefined) {
50
+ if (!Array.isArray(data.plan_keys) || !data.plan_keys.length || data.plan_keys.length > ALL_PLAN_KEYS.length || new Set(data.plan_keys).size !== data.plan_keys.length || data.plan_keys.some(key => !ALL_PLAN_KEYS.includes(key))) throw new Error("profile plan_keys must select distinct supported fields");
51
+ result.plan_keys = [...data.plan_keys];
52
+ }
53
+ if (data.role_text !== undefined) {
54
+ if (!data.role_text || typeof data.role_text !== "object" || Array.isArray(data.role_text) || Object.keys(data.role_text).length > 8) throw new Error("profile role_text must be bounded guidance");
55
+ for (const [role, value] of Object.entries(data.role_text)) {
56
+ if (!["planner", "implementer", "verifier", "reviewer", "conductor"].includes(role)) throw new Error("profile role_text must name an existing flow role");
57
+ result.role_text[role] = text(value, 2000);
58
+ }
59
+ }
60
+ if (data.catalog_roles !== undefined) result.catalog_roles = { ...result.catalog_roles, ...catalogAssignments(data.catalog_roles) };
61
+ if (data.agents !== undefined) result.agents = text(data.agents, 16000);
62
+ if (data.example_checks !== undefined) {
63
+ if (!Array.isArray(data.example_checks) || data.example_checks.length > 16) throw new Error("profile example_checks must be a bounded display list");
64
+ result.example_checks = data.example_checks.map(value => text(value, 2000));
65
+ }
66
+ return result;
67
+ }
68
+
69
+ /** Maps merge across shared/local layers; selections and display text replace as a unit. */
70
+ export function mergeProfileOverrides(a: unknown, b: unknown): Record<string, unknown> | undefined {
71
+ if (a === undefined && b === undefined) return undefined;
72
+ const left = (a ?? {}) as Record<string, unknown>, right = (b ?? {}) as Record<string, unknown>;
73
+ return { ...left, ...right, ...Object.fromEntries(["classes", "role_text", "catalog_roles"].filter(key => left[key] !== undefined && right[key] !== undefined).map(key => [key, { ...(left[key] as object), ...(right[key] as object) }])) };
74
+ }
@@ -0,0 +1,73 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { hubGitSync } from "./git.ts";
5
+ import { sanitize } from "./envelope.ts";
6
+ import { foldSegment } from "./permission-mode.ts";
7
+ import { isDenied } from "../local/deny.ts";
8
+ import { MAX_PROJECTION_INPUT, type TextProjections } from "./text-projection.ts";
9
+ import { repoGitArgs, repoGitEnv, type Repo } from "./snapshots.ts";
10
+
11
+ export const PROJECTED_DIFF_BYTES = 12000;
12
+ const MAX_FILES = 8, DIFF_MS = 8000;
13
+ const HEADER = "Projected text only (authorship and raw restore safety are independent):";
14
+ const treeId = (tree: string) => /^[a-f0-9]{40}$|^[a-f0-9]{64}$/.test(tree);
15
+ export function projectionPath(repo: Repo, path: string, deny: string[] = []): boolean {
16
+ const parts = path.split("/"), folded = parts.map(foldSegment).join("/");
17
+ return !!path && !path.startsWith("/") && path.startsWith(repo.prefix) && parts.every(part => !!part && part !== "." && part !== ".." && ![".git", ".agenthub"].includes(foldSegment(part))) && !isDenied(path, deny) && !isDenied(folded, deny.map(foldSegment));
18
+ }
19
+ /** null is an absent file; undefined is unavailable evidence or a non-regular object. */
20
+ export function snapshotBytes(repo: Repo, tree: string, path: string, deadline = performance.now() + DIFF_MS): Buffer | null | undefined {
21
+ if (!treeId(tree) || !projectionPath(repo, path) || performance.now() >= deadline) return undefined;
22
+ const run = (args: string[], encoding: "utf8" | "latin1" = "utf8", maxBuffer = 8192) => hubGitSync(repoGitArgs(repo, args), { encoding, maxBuffer, timeout: Math.ceil(Math.max(1, Math.min(DIFF_MS, deadline - performance.now()))), env: repoGitEnv(repo) });
23
+ const entry = run(["ls-tree", "-z", tree, "--", `:(literal)${path}`]);
24
+ if (entry.status !== 0) return undefined;
25
+ if (!entry.stdout) return null;
26
+ const match = /^(100644|100755) blob ([a-f0-9]+)\t/.exec(entry.stdout);
27
+ if (!match || entry.stdout.slice(match[0].length) !== path + "\0" || !treeId(match[2]!)) return undefined;
28
+ if (performance.now() >= deadline) return undefined;
29
+ const size = run(["cat-file", "-s", match[2]!]);
30
+ const length = Number(size.stdout.trim());
31
+ if (size.status !== 0 || !Number.isSafeInteger(length) || length < 0 || length > MAX_PROJECTION_INPUT || performance.now() >= deadline) return undefined;
32
+ // Latin1 preserves all byte values through the existing bounded git wrapper.
33
+ const blob = run(["cat-file", "blob", match[2]!], "latin1", MAX_PROJECTION_INPUT + 1);
34
+ if (blob.status !== 0 || blob.stdout.length !== length) return undefined;
35
+ return Buffer.from(blob.stdout, "latin1");
36
+ }
37
+ function hunks(repo: Repo, before: string, after: string, deadline: number): string | undefined {
38
+ if (performance.now() >= deadline) return undefined;
39
+ const scratch = mkdtempSync(join(tmpdir(), "ahub-projected-diff-"));
40
+ try {
41
+ const a = join(scratch, "before"), b = join(scratch, "after"); writeFileSync(a, before, { mode: 0o600 }); writeFileSync(b, after, { mode: 0o600 });
42
+ const diff = hubGitSync(repoGitArgs(repo, ["diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--unified=2", "--", a, b]), { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: Math.ceil(Math.max(1, Math.min(DIFF_MS, deadline - performance.now()))), env: repoGitEnv(repo) });
43
+ if (diff.status !== 0 && diff.status !== 1) return undefined;
44
+ const start = diff.stdout.indexOf("@@");
45
+ if (start < 0) return diff.status === 0 ? "(projected text unchanged)" : undefined;
46
+ return diff.stdout.slice(start).replace(/\n$/, "");
47
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
48
+ }
49
+ /** Presentation of checked snapshot bytes, never evidence of authorship or restore safety. */
50
+ export function projectedDiff(options: { repo: Repo; from: string; to: string; paths: string[]; projections: TextProjections; deny?: string[]; privatePath?: (path: string) => boolean; nameable?: (text: string) => boolean }): string | undefined {
51
+ const { repo, projections } = options;
52
+ if (!projections.enabled) return undefined;
53
+ const deadline = performance.now() + DIFF_MS, rows: string[] = []; let bytes = Buffer.byteLength(HEADER) + 1, files = 0;
54
+ const add = (text: string) => { const safe = sanitize(text); if (bytes + Buffer.byteLength(safe) + 1 > PROJECTED_DIFF_BYTES) return false; rows.push(safe); bytes += Buffer.byteLength(safe) + 1; return true; };
55
+ for (const path of [...new Set(options.paths)]) {
56
+ if (!projections.handles(path) || !projectionPath(repo, path, options.deny) || options.nameable?.(path) === false) continue;
57
+ if (files++ >= MAX_FILES || performance.now() >= deadline) { add("(remaining projection previews omitted: count or time limit)"); break; }
58
+ if (options.privatePath?.(path)) { if (!add(`${path}: (projection withheld: private path)`)) break; continue; }
59
+ const before = snapshotBytes(repo, options.from, path, deadline), after = snapshotBytes(repo, options.to, path, deadline);
60
+ if (before === undefined || after === undefined) { if (!add(`${path}: (projection unavailable: snapshot file is unknown)`)) break; continue; }
61
+ if (before === null && after === null) { if (!add(`${path}: (projection unavailable: file is absent from both snapshots)`)) break; continue; }
62
+ const a = before === null ? null : projections.projectBytes(join(repo.top, path), before, deadline);
63
+ if (a === undefined) continue;
64
+ const b = after === null ? null : projections.projectBytes(join(repo.top, path), after, deadline);
65
+ if (a === undefined || b === undefined) continue; // converter failure keeps the ordinary file-only view
66
+ if (a && b && a.configHash !== b.configHash) { if (!add(`${path}: (projection unavailable: converter changed during comparison)`)) break; continue; }
67
+ const diff = hunks(repo, a?.text ?? "", b?.text ?? "", deadline);
68
+ if (diff === undefined) { if (!add(`${path}: (projection unavailable: diff could not be read)`)) break; continue; }
69
+ const text = options.nameable?.(diff) === false ? `${path}: (projection withheld: private-data pattern)` : `${path}:\n${diff}`;
70
+ if (!add(text)) { add("(remaining projection previews omitted: output limit)"); break; }
71
+ }
72
+ return rows.length ? `${HEADER}\n${rows.join("\n")}` : undefined;
73
+ }
@@ -0,0 +1,24 @@
1
+ // Trusted synchronous-boundary helper: the parent waits, this process owns and stops the converter group.
2
+ import { spawn } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ let ownedGroup: number | undefined;
5
+ const stop = () => { if (ownedGroup) try { process.kill(-ownedGroup, "SIGKILL"); } catch { /* owned group gone */ } };
6
+ // The aggregate preview deadline can expire before the converter's own timer.
7
+ process.on("SIGTERM", () => { stop(); process.exit(143); });
8
+ process.on("SIGINT", () => { stop(); process.exit(130); });
9
+ const request = JSON.parse(await new Response(Bun.stdin.stream()).text()) as { cwd: string; sandbox: string; argv: string[]; timeoutMs?: number };
10
+ const limit = 256 * 1024;
11
+ const child = spawn("/usr/bin/sandbox-exec", ["-p", request.sandbox, ...request.argv], {
12
+ cwd: request.cwd, detached: true, stdio: ["ignore", "pipe", "pipe"],
13
+ env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: "en_US.UTF-8", TERM: "dumb" },
14
+ });
15
+ ownedGroup = child.pid;
16
+ const decoder = new TextDecoder("utf-8", { fatal: true });
17
+ let stdout = "", stderrBytes = 0, outputBytes = 0, invalid = false;
18
+ const timeout = typeof request.timeoutMs === "number" && Number.isFinite(request.timeoutMs) ? Math.ceil(Math.max(1, Math.min(5000, request.timeoutMs))) : 5000;
19
+ const timer = setTimeout(() => { invalid = true; stop(); }, timeout);
20
+ child.stdout.on("data", (bytes: Buffer) => { outputBytes += bytes.length; if (outputBytes > limit) { invalid = true; stop(); } else try { stdout += decoder.decode(bytes, { stream: true }); } catch { invalid = true; stop(); } });
21
+ child.stderr.on("data", (bytes: Buffer) => { stderrBytes += bytes.length; if (stderrBytes > limit) { invalid = true; stop(); } });
22
+ child.on("exit", () => stop()); // descendants may retain the output pipe after the leader exits
23
+ child.on("error", () => { clearTimeout(timer); invalid = true; process.stdout.write(JSON.stringify({ ok: false })); });
24
+ child.on("close", code => { clearTimeout(timer); ownedGroup = undefined; try { stdout += decoder.decode(); } catch { invalid = true; } process.stdout.write(JSON.stringify({ ok: !invalid && code === 0, ...(invalid || code !== 0 ? {} : { text: stdout }) })); });
@@ -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",