@staix/agent-hub 0.34.0 → 0.35.0

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 (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +35 -6
  3. package/docs/adding-an-ai-tool.md +2 -2
  4. package/docs/addons/claude-mem.md +4 -4
  5. package/docs/addons/open-code-review.md +1 -1
  6. package/docs/agent-notes/README.md +6 -0
  7. package/docs/agent-notes/adapters.md +9 -5
  8. package/docs/agent-notes/budget.md +1 -1
  9. package/docs/agent-notes/bus.md +1 -1
  10. package/docs/agent-notes/daemon.md +4 -3
  11. package/docs/agent-notes/local-worker.md +1 -1
  12. package/docs/agent-notes/models.md +2 -0
  13. package/docs/agent-notes/tasks.md +1 -1
  14. package/docs/agent-notes/tests.md +5 -0
  15. package/docs/design/adapters.md +110 -2
  16. package/docs/design/agent-hub-architecture.md +63 -1
  17. package/docs/design/agent-hub.md +6 -0
  18. package/docs/design/bus.md +18 -0
  19. package/docs/design/execution-reference.md +146 -0
  20. package/docs/design/task-board.md +10 -0
  21. package/docs/dogfood.md +33 -12
  22. package/docs/events.md +38 -20
  23. package/docs/memory.md +21 -7
  24. package/docs/operations.md +539 -24
  25. package/docs/quickstart.md +18 -4
  26. package/docs/research.md +71 -13
  27. package/docs/roles.md +1 -1
  28. package/docs/sdlc.md +9 -7
  29. package/docs/security.md +87 -11
  30. package/docs/smoke.md +14 -0
  31. package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
  32. package/docs/verification/2026-11-07-0.34.1.md +54 -0
  33. package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
  34. package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
  35. package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
  36. package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
  37. package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
  38. package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
  39. package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
  40. package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
  41. package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
  42. package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
  43. package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
  44. package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
  45. package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
  46. package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
  47. package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
  48. package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
  49. package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
  50. package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
  51. package/docs/verification/2026-11-09-0.35.0.md +129 -0
  52. package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
  53. package/docs/verification/claude-permission-transport-native.py +10 -3
  54. package/docs/verification/claude-shell-wrapper-native.py +8 -8
  55. package/docs/verified.json +245 -15
  56. package/package.json +3 -3
  57. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  58. package/plugins/agent-hub/kimi-hook.js +20397 -0
  59. package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
  60. package/plugins/agent-hub/server.js +2701 -664
  61. package/scripts/check-docs.mjs +12 -1
  62. package/src/adapters/acp-model-policy.ts +56 -0
  63. package/src/adapters/acp.ts +175 -5
  64. package/src/adapters/agy/entry.ts +8 -5
  65. package/src/adapters/agy/hooks.ts +65 -15
  66. package/src/adapters/channel-peer.ts +33 -1
  67. package/src/adapters/claude-implementer.ts +16 -3
  68. package/src/adapters/codex-appserver.ts +118 -8
  69. package/src/adapters/codex-review-isolation.ts +3 -1
  70. package/src/adapters/compat.ts +11 -6
  71. package/src/adapters/definitions/builtin-metadata.ts +8 -1
  72. package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
  73. package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
  74. package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
  75. package/src/adapters/definitions/reviewer-variants.ts +3 -1
  76. package/src/adapters/kimi-review-policy-worker.js +2 -0
  77. package/src/adapters/kimi-review-policy-worker.ts +85 -0
  78. package/src/adapters/kimi-review-policy.ts +68 -0
  79. package/src/adapters/kimi-review.ts +41 -41
  80. package/src/adapters/kimi-tui-facts.ts +153 -0
  81. package/src/adapters/kimi-wire-usage.ts +33 -8
  82. package/src/adapters/local-worker.ts +59 -20
  83. package/src/adapters/model-token.ts +3 -0
  84. package/src/adapters/pi.ts +27 -5
  85. package/src/adapters/registry-types.ts +34 -3
  86. package/src/adapters/registry.ts +1 -0
  87. package/src/adapters/reviewer-policy-watch.ts +86 -0
  88. package/src/adapters/reviewer-print.ts +80 -18
  89. package/src/cli/agents.ts +1 -1
  90. package/src/cli/facts-hook.ts +4 -2
  91. package/src/cli/help.ts +3 -2
  92. package/src/cli/identity.ts +3 -0
  93. package/src/cli/kimi-facts-hook.ts +11 -0
  94. package/src/cli/kimi-plugin.ts +157 -0
  95. package/src/cli/main.ts +48 -9
  96. package/src/cli/model.ts +52 -0
  97. package/src/cli/output.ts +23 -1
  98. package/src/cli/recovery-package.ts +15 -7
  99. package/src/cli/reset.ts +1 -1
  100. package/src/cli/status-lines.ts +8 -4
  101. package/src/hub/budget.ts +1 -1
  102. package/src/hub/bus.ts +40 -2
  103. package/src/hub/code-facts.ts +10 -3
  104. package/src/hub/completions.ts +3 -1
  105. package/src/hub/config-apply.ts +2 -1
  106. package/src/hub/control-client.ts +1 -1
  107. package/src/hub/daemon.ts +1050 -106
  108. package/src/hub/deliverable-files.ts +38 -5
  109. package/src/hub/dogfood-profile.ts +1 -1
  110. package/src/hub/envelope.ts +1 -1
  111. package/src/hub/events.ts +28 -18
  112. package/src/hub/execution-lineage.ts +120 -0
  113. package/src/hub/execution-reference.ts +36 -0
  114. package/src/hub/hub-tools.ts +21 -2
  115. package/src/hub/instances.ts +4 -3
  116. package/src/hub/kimi-events.ts +46 -0
  117. package/src/hub/kimi-usage.ts +132 -29
  118. package/src/hub/lifecycle.ts +31 -15
  119. package/src/hub/limits.ts +13 -4
  120. package/src/hub/manager.ts +15 -2
  121. package/src/hub/memory-policy.ts +2 -1
  122. package/src/hub/memory-sync-attestation.ts +54 -0
  123. package/src/hub/memory-sync-state.ts +20 -5
  124. package/src/hub/model-overrides.ts +115 -0
  125. package/src/hub/native-hook-launch.ts +12 -3
  126. package/src/hub/operational-view.ts +2 -2
  127. package/src/hub/peers.ts +27 -0
  128. package/src/hub/permission-mode.ts +1 -1
  129. package/src/hub/person-figures.ts +88 -0
  130. package/src/hub/report.ts +38 -4
  131. package/src/hub/research-dataset.ts +158 -0
  132. package/src/hub/research-memory.ts +68 -0
  133. package/src/hub/research-quality.ts +118 -0
  134. package/src/hub/research-record.ts +279 -0
  135. package/src/hub/research.ts +126 -27
  136. package/src/hub/restart.ts +14 -0
  137. package/src/hub/settings.ts +3 -0
  138. package/src/hub/startup-event-seeds.ts +51 -0
  139. package/src/hub/startup-readiness.ts +65 -0
  140. package/src/hub/startup-timing.ts +38 -0
  141. package/src/hub/tasks.ts +29 -9
  142. package/src/hub/ui.ts +9 -5
  143. package/src/hub/visual-assets.ts +50 -0
  144. package/src/hub/visual.ts +257 -0
  145. package/src/memory/brief.ts +169 -14
  146. package/src/memory/capture.ts +2 -1
  147. package/src/memory/recall.ts +46 -5
  148. package/src/memory/record-provenance.ts +82 -0
  149. package/src/memory/records.ts +116 -0
  150. package/src/models/route/adec.ts +190 -0
  151. package/src/models/route/config.ts +37 -3
  152. package/src/models/route/decision-evidence.ts +83 -0
  153. package/src/models/route/decision.ts +143 -0
  154. package/src/models/route/runtime.ts +13 -6
  155. package/src/ui/index.html +134 -6
  156. package/src/ui/plugin.ts +65 -4
  157. package/templates/AGENTS.block.md +1 -1
  158. package/templates/config.json +1 -1
  159. package/templates/profiles/general.json +1 -1
  160. package/templates/profiles/research.json +1 -1
  161. package/templates/profiles/writing.json +1 -1
@@ -0,0 +1,115 @@
1
+ import { createHash } from "node:crypto";
2
+ import { openHubDatabase, type Database } from "./db-schema.ts";
3
+ import { isNativeModelToken } from "../adapters/model-token.ts";
4
+ import type { ModelOverrideSelection } from "../adapters/registry-types.ts";
5
+
6
+ export interface ModelOverrideRow { peer: string; model: string; setAt: number; by: "person" }
7
+ export type ModelSource = "override" | "instance" | "launch" | "native default" | "native thread default (was override)";
8
+ export interface RuntimeModelStatus {
9
+ peer: string;
10
+ effectiveModel: string | null;
11
+ source: ModelSource;
12
+ desiredModel: string | null;
13
+ pendingNextTurn: boolean;
14
+ unsupported?: { reasonCode: "unverified_setter" | "unmanaged_attachment" | "acp_setter_unavailable"; kind?: string; version?: string; reason: string; nextStep: string };
15
+ }
16
+ const MAX_OVERRIDE_ROWS = 128;
17
+ const peerToken = (peer: unknown): peer is string => typeof peer === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(peer);
18
+ const invalid = () => new Error("model override state is invalid; inspect hub.db or clear the override with ahub model clear");
19
+ function row(value: unknown): ModelOverrideRow {
20
+ if (!value || typeof value !== "object") throw invalid();
21
+ const entry = value as Record<string, unknown>;
22
+ if (!peerToken(entry.peer) || !isNativeModelToken(entry.model) || !Number.isSafeInteger(entry.set_at) || Number(entry.set_at) < 0 || entry.by !== "person") throw invalid();
23
+ return { peer: entry.peer, model: entry.model, setAt: Number(entry.set_at), by: "person" };
24
+ }
25
+ const columns = "CASE WHEN typeof(peer)='text' AND length(CAST(peer AS BLOB))<=32 THEN peer END AS peer,CASE WHEN typeof(model)='text' AND length(CAST(model AS BLOB))<=128 THEN model END AS model,CASE WHEN typeof(set_at)='integer' AND set_at>=0 THEN set_at END AS set_at,CASE WHEN typeof(\"by\")='text' AND \"by\"='person' THEN 'person' END AS \"by\"";
26
+ /** Runtime state only: no configuration writes, native calls or authority grants. */
27
+ export class ModelOverrides {
28
+ private readonly db: Database;
29
+ private readonly revisions = new Map<string, number>();
30
+ constructor(dbPath: string) {
31
+ this.db = openHubDatabase(dbPath, { create: true });
32
+ try { this.db.run('CREATE TABLE IF NOT EXISTS model_overrides(peer TEXT PRIMARY KEY,model TEXT NOT NULL,set_at INTEGER NOT NULL,"by" TEXT NOT NULL)'); }
33
+ catch (error) { this.db.close(); throw error; }
34
+ }
35
+ get(peer: string): ModelOverrideRow | undefined {
36
+ if (!peerToken(peer)) throw invalid();
37
+ const found = this.db.query(`SELECT ${columns} FROM model_overrides WHERE peer=?`).get(peer);
38
+ return found ? row(found) : undefined;
39
+ }
40
+ list(): ModelOverrideRow[] {
41
+ const rows = this.db.query(`SELECT ${columns} FROM model_overrides ORDER BY peer LIMIT ?`).all(MAX_OVERRIDE_ROWS + 1);
42
+ if (rows.length > MAX_OVERRIDE_ROWS) throw invalid();
43
+ return rows.map(row);
44
+ }
45
+ revision(peer: string): string { return JSON.stringify([this.revisions.get(peer) ?? 0, this.get(peer) ?? null]); }
46
+ set(peer: string, model: string, setAt = Date.now()): ModelOverrideRow {
47
+ if (!peerToken(peer) || !isNativeModelToken(model) || !Number.isSafeInteger(setAt) || setAt < 0) throw new Error("invalid model selection; use a peer id and a bounded native model token, then retry ahub model set");
48
+ if (!this.get(peer) && this.list().length >= MAX_OVERRIDE_ROWS) throw new Error("model override capacity reached; clear unused peer overrides before adding another");
49
+ this.db.query('INSERT INTO model_overrides(peer,model,set_at,"by") VALUES(?,?,?,?) ON CONFLICT(peer) DO UPDATE SET model=excluded.model,set_at=excluded.set_at,"by"=excluded."by"').run(peer, model, setAt, "person");
50
+ this.revisions.set(peer, (this.revisions.get(peer) ?? 0) + 1);
51
+ return { peer, model, setAt, by: "person" };
52
+ }
53
+ clear(peer: string): boolean {
54
+ if (!peerToken(peer)) throw invalid();
55
+ const changed = this.db.query("DELETE FROM model_overrides WHERE peer=?").run(peer).changes > 0;
56
+ if (changed) this.revisions.set(peer, (this.revisions.get(peer) ?? 0) + 1);
57
+ return changed;
58
+ }
59
+ hasRecords(): boolean { return this.db.query("SELECT 1 FROM model_overrides LIMIT 1").get() !== null; }
60
+ integrity(): { count: number; digest: string } {
61
+ const rows = this.list();
62
+ return { count: rows.length, digest: createHash("sha256").update(JSON.stringify(rows)).digest("hex") };
63
+ }
64
+ close(): void { this.db.close(); }
65
+ }
66
+ interface Captured {
67
+ owner: object;
68
+ turn: object;
69
+ revision: string;
70
+ model: string | undefined;
71
+ selection: ModelOverrideSelection;
72
+ retirementFenced: boolean;
73
+ }
74
+ /** Selected and acknowledged are separate. A later desired write never changes a turn's captured selection. */
75
+ export class ModelOverrideLatches {
76
+ private readonly captured = new Map<string, Captured>();
77
+ private readonly acknowledged = new Map<string, { owner: object; revision: string | undefined; model: string | undefined; source: "override" | "native default" | "native thread default (was override)" }>();
78
+ constructor(private readonly store: ModelOverrides) {}
79
+ capture(peer: string, owner: object, turn: object, current: () => boolean): ModelOverrideSelection {
80
+ const previous = this.captured.get(peer);
81
+ if (previous?.owner === owner && previous.turn === turn) return previous.selection;
82
+ const revision = this.store.revision(peer), model = this.store.get(peer)?.model;
83
+ const capture: Captured = { owner, turn, revision, model, retirementFenced: false, selection: { model, applied: (nativeModel?: string) => {
84
+ if (this.captured.get(peer) !== capture) return;
85
+ try { if (!current()) return; } catch { return; }
86
+ const hint = model === undefined && isNativeModelToken(nativeModel) ? nativeModel : undefined;
87
+ const before = this.acknowledged.get(peer), wasOwned = hint !== undefined && before?.owner === owner && before.model === hint
88
+ && (before.source === "override" || before.source === "native thread default (was override)");
89
+ capture.retirementFenced = true;
90
+ this.acknowledged.set(peer, { owner, revision, model: model ?? hint, source: hint === undefined ? "override" : wasOwned ? "native thread default (was override)" : "native default" });
91
+ } } };
92
+ this.captured.set(peer, capture);
93
+ return capture.selection;
94
+ }
95
+ status(peer: string, owner: object | undefined, fallback: { model?: string; source: Exclude<ModelSource, "override"> }, unsupported?: RuntimeModelStatus["unsupported"]): RuntimeModelStatus {
96
+ const desired = this.store.get(peer), applied = this.acknowledged.get(peer), captured = this.captured.get(peer), revision = this.store.revision(peer);
97
+ const active = !unsupported && applied?.owner === owner ? applied : undefined;
98
+ const effective = active?.model;
99
+ return { peer, effectiveModel: effective ?? fallback.model ?? null, source: effective === undefined ? fallback.source : active!.source, desiredModel: desired?.model ?? null,
100
+ pendingNextTurn: !unsupported && ((desired !== undefined || active !== undefined) && active?.revision !== revision
101
+ || !!captured && captured.owner === owner && captured.revision !== revision),
102
+ ...(unsupported ? { unsupported } : {}) };
103
+ }
104
+ forget(peer: string): void { this.captured.delete(peer); this.acknowledged.delete(peer); }
105
+ end(peer: string, expectedTurn?: object, onlyUnacknowledged = false): boolean {
106
+ const capture = this.captured.get(peer);
107
+ if (!capture || expectedTurn !== undefined && capture.turn !== expectedTurn || onlyUnacknowledged && capture.retirementFenced) return false;
108
+ this.captured.delete(peer); return true;
109
+ }
110
+ observeNative(peer: string, owner: object, model: string): void {
111
+ if (!isNativeModelToken(model)) return;
112
+ const capture = this.captured.get(peer); if (capture && capture.owner === owner) capture.retirementFenced = true;
113
+ this.acknowledged.set(peer, { owner, model, source: "native default", revision: this.store.get(peer) ? undefined : this.store.revision(peer) });
114
+ }
115
+ }
@@ -1,23 +1,28 @@
1
1
  /** Managed launch authority is ephemeral and scoped to one peer, daemon and native session. */
2
2
  export interface NativeHookLaunch {
3
3
  id: string;
4
+ home?: string;
4
5
  session?: string;
6
+ transcript?: string;
5
7
  generation?: string;
6
8
  baseline?: number;
7
9
  turn?: string;
8
10
  active: boolean;
9
11
  completed?: string;
12
+ transcriptPath?: string;
13
+ transcriptOffset?: number;
10
14
  }
11
15
  export class NativeHookLaunches {
12
16
  private readonly launches = new Map<string, NativeHookLaunch>();
13
17
  private readonly owners = new Map<string, object>();
14
- begin(peer: string, owner: object): NativeHookLaunch {
18
+ begin(peer: string, owner: object, home?: string): NativeHookLaunch {
15
19
  if (this.launches.has(peer)) throw new Error("a managed native launch is already active; end it in its terminal first");
16
- const launch: NativeHookLaunch = { id: crypto.randomUUID(), active: false };
20
+ const launch: NativeHookLaunch = { id: crypto.randomUUID(), active: false, ...(home ? { home } : {}) };
17
21
  this.launches.set(peer, launch);
18
22
  this.owners.set(peer, owner);
19
23
  return launch;
20
24
  }
25
+ current(peer: string): NativeHookLaunch | undefined { return this.launches.get(peer); }
21
26
  get(peer: string, id: unknown): NativeHookLaunch | undefined {
22
27
  const launch = this.launches.get(peer);
23
28
  return launch && typeof id === "string" && launch.id === id ? launch : undefined;
@@ -27,11 +32,15 @@ export class NativeHookLaunches {
27
32
  this.owners.delete(peer);
28
33
  return this.launches.delete(peer);
29
34
  }
30
- endOwner(owner: object): void {
35
+ endOwner(owner: object): { peer: string; launch: NativeHookLaunch }[] {
36
+ const ended: { peer: string; launch: NativeHookLaunch }[] = [];
31
37
  for (const [peer, current] of this.owners) {
32
38
  if (current !== owner) continue;
39
+ const launch = this.launches.get(peer);
40
+ if (launch) ended.push({ peer, launch });
33
41
  this.launches.delete(peer);
34
42
  this.owners.delete(peer);
35
43
  }
44
+ return ended;
36
45
  }
37
46
  }
@@ -12,7 +12,7 @@ export interface OperationalViewPorts {
12
12
  }
13
13
  /** Closed metadata receipts only. Unsupported nested records are withheld until given their own projection. */
14
14
  const EVENT_FIELDS: Record<string,readonly string[]>={
15
- state:["peer","state"],hub_start:["instance"],native_thread:["peer","thread","fresh"],turn_start:["peer","turn"],native_turn_end:["peer","id"],
15
+ state:["peer","state"],hub_start:["instance"],native_thread:["peer","thread","fresh"],turn_start:["peer","turn"],native_turn_end:["peer","id","reason"],
16
16
  task:["id","event","by","state","owner","reviewer","class","profile","profileClass","role","pii","reason","release","priority","plannedStart","due"],
17
17
  tokens:["peer","n","task","attribution","pii"],
18
18
  usage:["peer","source","id","task","attribution","pii","reasoningTokens","measuredAt","inputTokens","outputTokens","cacheReadTokens","cacheWriteTokens","totalTokens"],
@@ -67,7 +67,7 @@ function metadataValue(type:string,key:string,value:unknown):boolean {
67
67
  if(key==="item")return oneOf(value,["task","summary","review note","handoff","note"]);
68
68
  if(key==="category")return oneOf(value,["name","student_id","phone","address","grade","health","other"]);
69
69
  if(key==="miss")return oneOf(value,["too long","off campus","timeout","unreadable","failed"]);
70
- if(key==="source")return type==="pii_screen"?oneOf(value,["regex","screen","unknown","inherited"]):type==="usage"?oneOf(value,["omniroute","claude_transcript","claude_result","codex_appserver","kimi_wire","jcode_acp"]):identifier(value);
70
+ if(key==="source")return type==="pii_screen"?oneOf(value,["regex","screen","unknown","inherited"]):type==="usage"?oneOf(value,["omniroute","claude_transcript","claude_result","codex_appserver","kimi_wire","jcode_acp","agy_transcript"]):identifier(value);
71
71
  if(key==="measuredAt")return type==="context_pressure" || type==="quota_reset"?typeof value==="number"&&Number.isFinite(value):typeof value==="string"&&Number.isFinite(Date.parse(value));
72
72
  if(key==="window")return value===null || typeof value==="number"&&Number.isFinite(value)&&value>=0 || identifier(value);
73
73
  if(key==="priority")return value===null || typeof value==="number"&&Number.isFinite(value) || oneOf(value,["status","important","fyi"]);
package/src/hub/peers.ts CHANGED
@@ -23,6 +23,11 @@ export interface OwnedToolsLaunch {
23
23
 
24
24
  /** Display-only identity for a live channel connection waiting for a person's handover. */
25
25
  export interface PeerStandbySession { id: string; pid?: number; since: number; }
26
+ /**
27
+ * Display metadata about the CURRENT owner of an attached channel peer (#1310): who holds it, since when, and which native session the host has already verified. Never authentication,
28
+ * delivery, takeover or permission evidence, and never derived from a standby id, a process name, a title or attach order. Every field is bounded; none is text from the client.
29
+ */
30
+ export interface PeerOwnerAttachment { ownerPid?: number; attachedAt: number; nativeSessionId?: string; }
26
31
 
27
32
  /** Opaque, generation-fenced claim produced by a peer's person-requested takeover port. */
28
33
  export interface PeerTakeoverClaim {
@@ -46,6 +51,8 @@ export interface PeerAdapter {
46
51
  readonly id: PeerId;
47
52
  /** Live waiting sessions, for display only; ids and pids grant no native identity or authority. */
48
53
  readonly standbySessions?: readonly PeerStandbySession[];
54
+ /** The current owner's attachment facts, for display only; absent unless a socket is attached and no newer claim is arriving (#1310). */
55
+ readonly ownerAttachment?: PeerOwnerAttachment;
49
56
  /** Person-authorized takeover port. The host still performs its own admission and claim fences. */
50
57
  takeover?(standbyId: string | undefined, eligible: (socket: object) => boolean): PeerTakeoverClaim | undefined;
51
58
  /** Exact transport ownership check for guarding a selected standby through its asynchronous preface. */
@@ -115,6 +122,10 @@ export interface PeerAdapter {
115
122
  stop(reason?: string): Promise<void>;
116
123
  /** Set by the bus. The peer said something worth sharing. */
117
124
  onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
125
+ /** Host-only Record guidance for the next owned headless turn; never native-client configuration. */
126
+ recordInstruction?: () => string | undefined;
127
+ /** Raw model answer boundary, separate from explicit send tools and diagnostics. */
128
+ onFinalAnswer?: (body: string, opts?: EnvelopeOpts, complete?: boolean) => unknown;
118
129
  /** Set by the bus. */
119
130
  onState?: (state: PeerState) => void;
120
131
  /** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
@@ -135,6 +146,10 @@ export const DEFAULT_WATCHDOG_MS = 300_000;
135
146
  export abstract class BasePeer implements PeerAdapter {
136
147
  /** Returns a reason (a string) when the hub refused the message (issue #38). */
137
148
  onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
149
+ /** Host-only Record guidance for the next owned headless turn; never native-client configuration. */
150
+ recordInstruction?: () => string | undefined;
151
+ /** Raw model answer boundary, separate from explicit send tools and diagnostics. */
152
+ onFinalAnswer?: (body: string, opts?: EnvelopeOpts, complete?: boolean) => unknown;
138
153
  onState?: (state: PeerState) => void;
139
154
  onFailed?: (envs: Envelope[], failure?: { quota?: true; failureCause?: DeliveryFailureCause }) => void;
140
155
  onDelivery?: (receipt: DeliveryReceipt) => void;
@@ -152,6 +167,18 @@ export abstract class BasePeer implements PeerAdapter {
152
167
  return this._state;
153
168
  }
154
169
 
170
+ /** Decorate only a fixed prompt, once at the delivered-turn boundary. */
171
+ protected turnRecordPrompt(base: string): string {
172
+ let instruction: string | undefined;
173
+ try { instruction = this.recordInstruction?.(); } catch { return base; }
174
+ if (!instruction || base.includes(instruction)) return base;
175
+ return base ? `${base}\n\n${instruction}` : instruction;
176
+ }
177
+
178
+ protected finalAnswer(text: string, opts?: EnvelopeOpts, complete = true): unknown {
179
+ return this.onFinalAnswer ? this.onFinalAnswer(text, opts, complete) : this.onMessage?.(text, opts);
180
+ }
181
+
155
182
  /** Keep the active turn's inactivity bound; a changed setting belongs to the next busy turn. */
156
183
  configureWatchdog(ms: number): void {
157
184
  if (!Number.isFinite(ms) || ms <= 0 || ms > 2_147_483_647) throw new Error("invalid watchdog interval");
@@ -41,7 +41,7 @@ export function permissionGrant(peer: string, mode: PermissionMode, instances?:
41
41
  export const PI_EDIT_TOOLS = new Set(["edit", "write"]);
42
42
  /** Native agent policy files are never included in scoped automatic file grants. */
43
43
  export const AGENT_CONFIG_SEGMENTS: ReadonlySet<string> = new Set([".claude", ".codex", ".qwen", ".kimi", ".kimi-code", ".jcode", ".pi", ".mcp.json", ".agents", ".agent", "_agents", "_agent", ".gemini", ".grok", ".claude.json"]);
44
- export const INSTRUCTION_FILES: ReadonlySet<string> = new Set(["agents.md", "claude.md", "qwen.md"]);
44
+ export const INSTRUCTION_FILES: ReadonlySet<string> = new Set(["agents.md", "claude.md", "qwen.md", "gemini.md"]);
45
45
  export const isInstructionPath = (path: string): boolean => path.split(/[\\/]/).some(segment => INSTRUCTION_FILES.has(foldSegment(segment)));
46
46
  export function instructionProtection(value: unknown): boolean {
47
47
  if (value === undefined) return false;
@@ -0,0 +1,88 @@
1
+ import type { StampedEvent } from "./events.ts";
2
+
3
+ /**
4
+ * #1278: what the person was asked and did, per task, from the four events that now carry the token attribution rule (`task`, `attribution`): `permission`, `question`, `completion` and `agent_cli`.
5
+ * One accumulator serves `ahub report --by task` and the research record, so the two cannot show different numbers. Ids, counts and recorded durations only: none of these events holds a
6
+ * prompt, an option, a command or an answer.
7
+ *
8
+ * - An interaction counts for a task only when its event names one (`attribution` of `delivery` or `single_open`). `unattributed` is counted apart and never folded into a task.
9
+ * - Unknown is null, never 0: a log with no attributed person event at all (written before this version, or by a hub that never saw one) says nothing about any task, so every figure is null.
10
+ * - Minutes: `latencyMs.sum` adds only recorded latencies of PERSON-answered permissions and questions (an answer with a `surface`: the console, the dashboard, the terminal, acon). A person
11
+ * answer with no latency reading raises `n` and `unknown` and adds nothing to `sum`. An automatic answer, an expiry and a cancellation are counted but are not the person's minutes.
12
+ * - `consoleConfirmations` stays null: the hub records no event for a console confirmation (the confirm step is the console's own, and its approval arrives as the permission's `answered`).
13
+ * Ceiling: a person interaction in a vendor TUI the hub cannot see is not observed here at all.
14
+ */
15
+ export interface PermissionFigures { asked: number; allowed: number; denied: number; expired: number }
16
+ export interface QuestionFigures { asked: number; answered: number; expired: number }
17
+ export interface LatencyFigures { sum: number; n: number; unknown: number }
18
+ export interface PersonFigures {
19
+ permissions: PermissionFigures | null;
20
+ questions: QuestionFigures | null;
21
+ completions: number | null;
22
+ cliRefusals: number | null;
23
+ consoleConfirmations: null;
24
+ latencyMs: LatencyFigures | null;
25
+ }
26
+ export const UNKNOWN_PERSON_FIGURES: Readonly<PersonFigures> = Object.freeze({ permissions: null, questions: null, completions: null, cliRefusals: null, consoleConfirmations: null, latencyMs: null });
27
+
28
+ export interface PersonCounts {
29
+ permissions: PermissionFigures; questions: QuestionFigures; completions: number; cliRefusals: number; latencyMs: LatencyFigures;
30
+ }
31
+ export const emptyPersonCounts = (): PersonCounts => ({ permissions: { asked: 0, allowed: 0, denied: 0, expired: 0 }, questions: { asked: 0, answered: 0, expired: 0 }, completions: 0, cliRefusals: 0, latencyMs: { sum: 0, n: 0, unknown: 0 } });
32
+
33
+ /** The four event types this module reads. */
34
+ export const PERSON_EVENT_TYPES = ["permission", "question", "completion", "agent_cli"] as const;
35
+ export type PersonEvent = Extract<StampedEvent, { type: (typeof PERSON_EVENT_TYPES)[number] }>;
36
+ export const isPersonEvent = (e: StampedEvent): e is PersonEvent => (PERSON_EVENT_TYPES as readonly string[]).includes(e.type);
37
+
38
+ const ALLOW = new Set(["allow_once", "allow_always"]), DENY = new Set(["reject_once", "reject_always"]);
39
+ const recorded = (ms: unknown): ms is number => typeof ms === "number" && Number.isFinite(ms) && ms >= 0;
40
+
41
+ /** Add one person event to a group of counts. */
42
+ export function addPersonEvent(into: PersonCounts, e: PersonEvent): void {
43
+ if (e.type === "permission") {
44
+ if (e.event === "requested") into.permissions.asked++;
45
+ else if (e.event === "expired") into.permissions.expired++;
46
+ else if (e.event === "answered") {
47
+ if (e.option && ALLOW.has(e.option)) into.permissions.allowed++; else if (e.option && DENY.has(e.option)) into.permissions.denied++;
48
+ if (e.surface !== undefined) minutes(into, e.latencyMs);
49
+ }
50
+ } else if (e.type === "question") {
51
+ if (e.event === "asked") into.questions.asked++;
52
+ else if (e.event === "expired") into.questions.expired++;
53
+ else if (e.event === "answered") { into.questions.answered++; if (e.surface !== undefined) minutes(into, e.latencyMs); }
54
+ } else if (e.type === "completion") into.completions++;
55
+ else if (e.type === "agent_cli" && e.refused) into.cliRefusals++;
56
+ }
57
+ function minutes(into: PersonCounts, latencyMs: unknown): void {
58
+ into.latencyMs.n++;
59
+ if (recorded(latencyMs)) into.latencyMs.sum += latencyMs; else into.latencyMs.unknown++;
60
+ }
61
+
62
+ /**
63
+ * The figures of one task, the ONE place that decides unknown against zero, for the research record and `ahub report --by task` alike (#1298 review: the two must not disagree).
64
+ * `covered`: the log is known to attribute person events for this task (see `personCoverage`). A covered task with no event of its own is a known zero, which is the real and best answer for a
65
+ * task nobody was asked about, so `counts` missing reads as empty; an uncovered task is unknown whatever its counts say, because the log cannot say what happened before attribution.
66
+ */
67
+ export const personFigures = (counts: PersonCounts | undefined, covered: boolean): PersonFigures => {
68
+ if (!covered) return { ...UNKNOWN_PERSON_FIGURES };
69
+ const c = counts ?? emptyPersonCounts();
70
+ return { permissions: c.permissions, questions: c.questions, completions: c.completions, cliRefusals: c.cliRefusals, consoleConfirmations: null, latencyMs: c.latencyMs };
71
+ };
72
+
73
+ /**
74
+ * Whether a task is covered: it began (its proposal, else its first start) at or after the first `hub_start` that attributes person events. `since` is that start's time, or null when no
75
+ * such start is in the log; `began` is the task's own first time, or undefined/NaN when the log does not say.
76
+ */
77
+ export const personCoverage = (since: number | null, began: number | undefined): boolean => since !== null && began !== undefined && Number.isFinite(began) && began >= since;
78
+
79
+ /**
80
+ * Whether a `hub_start` says the run attributes person events (#1278). A task is covered by the log when it began at or after the first such start: from then on every person interaction of
81
+ * the task was written with the rule's answer, so no event means none happened, which is a known 0. Before it, the log cannot say, and the task's person block is unknown. The first
82
+ * person event is NOT the marker: a hub that upgraded and saw no interaction for days would otherwise report its first tasks as unknown instead of as the zero they are.
83
+ */
84
+ export const startsAttribution = (e: StampedEvent): boolean => e.type === "hub_start" && e.personAttribution === true;
85
+
86
+ /** The task an attributed event names, or undefined for an unattributed or pre-attribution one. */
87
+ export const personTask = (e: PersonEvent): number | undefined =>
88
+ (e.attribution === "delivery" || e.attribution === "single_open") && typeof e.task === "number" && Number.isSafeInteger(e.task) && e.task > 0 ? e.task : undefined;
package/src/hub/report.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { definitionOf } from "../adapters/registry.ts";
2
2
  import type { StampedEvent } from "./events.ts";
3
+ import { addPersonEvent, emptyPersonCounts, isPersonEvent, personCoverage, personFigures, personTask, startsAttribution, UNKNOWN_PERSON_FIGURES, type PersonCounts, type PersonFigures } from "./person-figures.ts";
3
4
 
4
5
  export interface Report {
5
6
  from?: string;
@@ -256,7 +257,9 @@ const usageCounts = ["inputRecords", "outputRecords", "cacheReadRecords", "cache
256
257
  type TaskUsage = Omit<UsageCoverage, typeof usageCounters[number]> & Record<typeof usageCounters[number], number | null>;
257
258
  export interface TaskPeerReport { tokens: number | null; usage: TaskUsage }
258
259
  export interface TaskTotals { turns: number; peers: Record<string, TaskPeerReport> }
259
- export interface TaskSummary extends TaskTotals { class: string | null; outcome: string | null; wallMs: number | null; pii?: true }
260
+ export interface TaskSummary extends TaskTotals { class: string | null; outcome: string | null; wallMs: number | null; pii?: true;
261
+ /** #1278: the person's interactions with the task (`src/hub/person-figures.ts`), null parts when the log does not cover it. A PII task counts here without any text, as everywhere. */
262
+ person: PersonFigures }
260
263
  export interface TaskReport {
261
264
  from?: string;
262
265
  to?: string;
@@ -265,6 +268,8 @@ export interface TaskReport {
265
268
  totals: { tokens: number; usageRecords: number };
266
269
  unattributed: TaskTotals & { tokens: number; usageRecords: number; tokenShare: number | null; usageShare: number | null };
267
270
  beforeAttribution: TaskTotals & { tokens: number; usageRecords: number };
271
+ /** #1278: person events by peer, and the remainder no task owns. Both are counts over the whole slice; `unattributed` is never folded into a task. */
272
+ person: { byPeer: Record<string, PersonCounts>; unattributed: PersonCounts; beforeAttribution: PersonCounts };
268
273
  }
269
274
 
270
275
  const taskTotals = (): TaskTotals => ({ turns: 0, peers: {} });
@@ -288,19 +293,27 @@ function addTaskUsage(peer: TaskPeerReport, e: Extract<StampedEvent, { type: "us
288
293
  export function summarizeByTask(events: StampedEvent[]): TaskReport {
289
294
  const r: TaskReport = { tasks: {}, classes: {}, totals: { tokens: 0, usageRecords: 0 },
290
295
  unattributed: { ...taskTotals(), tokens: 0, usageRecords: 0, tokenShare: null, usageShare: null },
291
- beforeAttribution: { ...taskTotals(), tokens: 0, usageRecords: 0 } };
292
- const task = (id: number) => r.tasks[String(id)] ??= { ...taskTotals(), class: null, outcome: null, wallMs: null };
296
+ beforeAttribution: { ...taskTotals(), tokens: 0, usageRecords: 0 },
297
+ person: { byPeer: {}, unattributed: emptyPersonCounts(), beforeAttribution: emptyPersonCounts() } };
298
+ const counts = new Map<number, PersonCounts>();
299
+ // #1278: a task is covered when it began at or after the first hub run that attributes person events (the research record's rule too); earlier ones are unknown, never 0.
300
+ let reportingSince: number | null = null;
301
+ const proposedAt = new Map<number, number>();
302
+ const task = (id: number) => r.tasks[String(id)] ??= { ...taskTotals(), class: null, outcome: null, wallMs: null, person: { ...UNKNOWN_PERSON_FIGURES } };
293
303
  const starts = new Map<number, number>(), approvals = new Map<number, number>();
294
304
  const seenUsage = new Set<string>();
295
305
  // Resolve latest class/outcome before rolling usage up: usage may precede the task's history in a slice.
296
306
  for (const e of events) {
297
307
  r.from ??= e.at; r.to = e.at;
308
+ if (startsAttribution(e) && reportingSince === null) reportingSince = Date.parse(e.at);
298
309
  if (e.type !== "task") continue;
299
310
  const t = task(e.id);
300
311
  t.class = e.class; t.outcome = e.state;
301
312
  if (e.pii) t.pii = true;
302
313
  const at = Date.parse(e.at);
303
314
  if (!Number.isFinite(at)) continue;
315
+ // The record's rule for when a task began: its proposal (or the pull from the backlog), else its first start. A backlog card's earlier `backlogged` event does not count.
316
+ if ((e.event === "proposed" || e.event === "backlog pulled") && !proposedAt.has(e.id)) proposedAt.set(e.id, at);
304
317
  if (e.state === "in_progress" && !starts.has(e.id)) starts.set(e.id, at);
305
318
  if (e.state === "approved" && !approvals.has(e.id)) approvals.set(e.id, at);
306
319
  }
@@ -310,6 +323,16 @@ export function summarizeByTask(events: StampedEvent[]): TaskReport {
310
323
  r.classes[t.class ?? "unknown"] ??= taskTotals();
311
324
  }
312
325
  for (const e of events) {
326
+ if (isPersonEvent(e)) {
327
+ // The same accumulator as the research record, so `report --by task` and the record show the same numbers.
328
+ const owner = personTask(e);
329
+ const group = owner !== undefined ? counts.get(owner) ?? counts.set(owner, emptyPersonCounts()).get(owner)! : e.attribution === undefined ? r.person.beforeAttribution : r.person.unattributed;
330
+ addPersonEvent(group, e);
331
+ addPersonEvent(r.person.byPeer[e.peer] ??= emptyPersonCounts(), e);
332
+ if (owner !== undefined && e.pii) task(owner).pii = true;
333
+ if (owner !== undefined) task(owner);
334
+ continue;
335
+ }
313
336
  if (e.type !== "tokens" && e.type !== "usage" && e.type !== "turn_end") continue;
314
337
  if (e.type === "usage") {
315
338
  const key = `${e.peer}\0${e.source}\0${e.id}`;
@@ -338,11 +361,19 @@ export function summarizeByTask(events: StampedEvent[]): TaskReport {
338
361
  else addTaskUsage(p, e);
339
362
  }
340
363
  }
364
+ for (const [id, t] of Object.entries(r.tasks)) t.person = personFigures(counts.get(Number(id)), personCoverage(reportingSince, proposedAt.get(Number(id)) ?? starts.get(Number(id))));
341
365
  r.unattributed.tokenShare = r.totals.tokens ? r.unattributed.tokens / r.totals.tokens : null;
342
366
  r.unattributed.usageShare = r.totals.usageRecords ? r.unattributed.usageRecords / r.totals.usageRecords : null;
343
367
  return r;
344
368
  }
345
369
 
370
+ /** One line of a person's interactions, `unknown` where the log does not cover them; counts and minutes only. */
371
+ export function formatPerson(p: PersonFigures): string {
372
+ if (p.permissions === null || p.questions === null || p.latencyMs === null) return "person unknown";
373
+ const l = p.latencyMs, mins = l.n === 0 ? "none answered" : `${(l.sum / 60_000).toFixed(1)} min over ${l.n - l.unknown}/${l.n} recorded`;
374
+ return `person: permissions ${p.permissions.asked} asked (${p.permissions.allowed} allowed, ${p.permissions.denied} denied, ${p.permissions.expired} expired), questions ${p.questions.asked} asked (${p.questions.answered} answered, ${p.questions.expired} expired), completions ${p.completions ?? "unknown"}, CLI refusals ${p.cliRefusals ?? "unknown"}, ${mins}`;
375
+ }
376
+
346
377
  export function formatTaskReport(r: TaskReport): string[] {
347
378
  const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
348
379
  const peers = (group: TaskTotals) => Object.entries(group.peers).sort(([a], [b]) => a.localeCompare(b)).map(([id, p]) => {
@@ -351,11 +382,14 @@ export function formatTaskReport(r: TaskReport): string[] {
351
382
  return ` peer ${id}: tokens ${p.tokens ?? "unknown"}; ${counts}`;
352
383
  });
353
384
  for (const [id, t] of Object.entries(r.tasks).sort(([a], [b]) => Number(a) - Number(b))) {
354
- lines.push(`task #${id}${t.pii ? " [pii]" : ""}: class ${t.class ?? "unknown"}, outcome ${t.outcome ?? "unknown"}, turns ${t.turns}, wall ${t.wallMs === null ? "unknown" : `${t.wallMs} ms`}`, ...peers(t));
385
+ lines.push(`task #${id}${t.pii ? " [pii]" : ""}: class ${t.class ?? "unknown"}, outcome ${t.outcome ?? "unknown"}, turns ${t.turns}, wall ${t.wallMs === null ? "unknown" : `${t.wallMs} ms`}`, ` ${formatPerson(t.person)}`, ...peers(t));
355
386
  }
356
387
  for (const [name, group] of Object.entries(r.classes).sort(([a], [b]) => a.localeCompare(b))) lines.push(`class ${name}: turns ${group.turns}`, ...peers(group));
357
388
  const share = (value: number | null) => value === null ? "unknown" : `${(value * 100).toFixed(1)}%`;
358
389
  lines.push(`unattributed: tokens ${r.unattributed.tokens}/${r.totals.tokens} (${share(r.unattributed.tokenShare)}), usage records ${r.unattributed.usageRecords}/${r.totals.usageRecords} (${share(r.unattributed.usageShare)}), turns ${r.unattributed.turns}`, ...peers(r.unattributed));
390
+ const total = r.person.unattributed;
391
+ lines.push(`person unattributed: ${total.permissions.asked} permissions asked, ${total.questions.asked} questions asked, ${total.completions} completions, ${total.cliRefusals} CLI refusals, never counted for a task`);
392
+ for (const [id, p] of Object.entries(r.person.byPeer).sort(([a], [b]) => a.localeCompare(b))) lines.push(`person peer ${id}: ${p.permissions.asked} permissions asked, ${p.questions.asked} questions asked, ${p.completions} completions, ${p.cliRefusals} CLI refusals`);
359
393
  lines.push(`before attribution: tokens ${r.beforeAttribution.tokens}, usage records ${r.beforeAttribution.usageRecords}, turns ${r.beforeAttribution.turns}`, ...peers(r.beforeAttribution));
360
394
  return lines;
361
395
  }
@@ -0,0 +1,158 @@
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve, sep } from "node:path";
4
+ import { familyOf as builtinFamilyOf } from "../adapters/registry.ts";
5
+ import { VERSION } from "../version.ts";
6
+ import { readEvents, type StampedEvent } from "./events.ts";
7
+ import { readInstanceConfig } from "./instance-config.ts";
8
+ import { hubHome, realPath } from "./project.ts";
9
+ import { latestTaskRecords, projectKey, closedAtOf, readStore, researchFile, type LabelRecord, type PeerUsage, type ResearchRecord, type TaskRecord } from "./research.ts";
10
+ import { buildResearchQuality, type QualityReport } from "./research-quality.ts";
11
+ import type { Addon } from "./tooling.ts";
12
+
13
+ /**
14
+ * #1277 (#44): the research dataset, a projection of the record store and the event log into three files per project (`tasks.jsonl`, `manifest.json`, `quality.json`). It is built to
15
+ * leave the machine, so it is an allowlist: a peer is only ever a family name (an instance id such as `codex-review` is a name the person chose), and the fields that carry a
16
+ * project-chosen name (profile, role, session roles) are dropped. The records are already text-free, so a task title, a path or an envelope body is not here to leak; the
17
+ * privacy test proves it on a canary fixture instead of trusting that.
18
+ */
19
+ export const MANIFEST_SCHEMA = "agent-hub.research-dataset/v1";
20
+ export const UNKNOWN_FAMILY = "unknown";
21
+
22
+ export interface DatasetProject { id: string; root: string; stateDir: string }
23
+ export interface DatasetInput {
24
+ project: DatasetProject;
25
+ records: readonly ResearchRecord[];
26
+ events: readonly StampedEvent[];
27
+ /** Instance ids resolve to a family only through the project's own config; the registry default knows the shipped adapters. */
28
+ familyOf?: (peer: string) => string | undefined;
29
+ }
30
+ export interface DatasetOptions { since?: number; now?: () => number; hubVersion?: string; /** The directory the target must be inside; the person's home unless a test says otherwise. */ home?: string }
31
+ export interface DatasetManifest {
32
+ schema: typeof MANIFEST_SCHEMA; hubVersion: string; projectKeyHash: string;
33
+ window: { since: string | null; until: string };
34
+ /** Family names only, sorted; `unknown` stands for a peer no family is known for. */
35
+ peers: string[];
36
+ counts: { tasks: number; approved: number; cancelled: number; labels: number };
37
+ recordedAt: string;
38
+ }
39
+ export interface DatasetResult { projectKeyHash: string; dir: string; counts: DatasetManifest["counts"] }
40
+
41
+ /** Why a target directory is refused, or undefined when it is allowed. Both sides are compared as real paths: a symlink out of the home or into the project does not pass. */
42
+ export function refuseTarget(target: string, projects: readonly { root: string; stateDir: string }[], home = homedir()): string | undefined {
43
+ const absolute = resolve(target);
44
+ // The nearest existing ancestor decides: the directory itself may not exist yet.
45
+ let existing = absolute; const rest: string[] = [];
46
+ while (!existsSync(existing)) { const up = dirname(existing); if (up === existing) return `no part of ${target} exists`; rest.unshift(basename(existing)); existing = up; }
47
+ const real = join(realPath(existing), ...rest), realHome = realPath(home);
48
+ if (real !== realHome && !real.startsWith(realHome + sep)) return `the dataset directory must be inside your home directory (${realHome})`;
49
+ if (real === realHome) return "the dataset directory must be a directory of its own, not the home directory itself";
50
+ for (const p of projects) {
51
+ for (const dir of [p.root, p.stateDir]) {
52
+ let inside: string; try { inside = realPath(dir); } catch { inside = resolve(dir); }
53
+ if (real === inside || real.startsWith(inside + sep) || inside.startsWith(real + sep)) return `the dataset directory must be outside the project and its state directory (${inside})`;
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ const family = (peer: string, familyOf: (peer: string) => string | undefined): string => familyOf(peer) ?? builtinFamilyOf(peer) ?? UNKNOWN_FAMILY;
60
+ const sumNullable = (a: number | null, b: number | null): number | null => a === null && b === null ? null : (a ?? 0) + (b ?? 0);
61
+
62
+ /** One task record as it goes into the dataset: every peer id folded into its family, fields that carry project-chosen names dropped. */
63
+ export function datasetTask(r: TaskRecord, familyOf: (peer: string) => string | undefined): TaskRecord {
64
+ const fam = (peer: string) => family(peer, familyOf);
65
+ const { profile: _p, profileClass: _pc, role: _r, roleIdentity: _ri, sessionRoles: _sr, peers, tooling, ...rest } = r;
66
+ const byPeer: Record<string, number> = {};
67
+ for (const [peer, n] of Object.entries(r.tokens.byPeer)) byPeer[fam(peer)] = (byPeer[fam(peer)] ?? 0) + n;
68
+ const usage: Record<string, PeerUsage> = {};
69
+ for (const [peer, u] of Object.entries(r.usage)) {
70
+ const key = fam(peer), held = usage[key];
71
+ usage[key] = held ? { input: sumNullable(held.input, u.input), output: sumNullable(held.output, u.output), cacheRead: sumNullable(held.cacheRead, u.cacheRead), total: sumNullable(held.total, u.total) } : { ...u };
72
+ }
73
+ const out: TaskRecord = { ...rest, owners: r.owners.map(fam), reviewer: r.reviewer === null ? null : fam(r.reviewer), tokens: { ...r.tokens, byPeer: sortKeys(byPeer) }, usage: sortKeys(usage) };
74
+ if (peers) out.peers = [...new Set(peers.map(fam))].sort();
75
+ if (tooling) {
76
+ const byFamily: Record<string, Addon[]> = {};
77
+ // A family with two peers that had different add-ons reads as the union: the dataset only says which add-ons were around.
78
+ for (const [peer, addons] of Object.entries(tooling)) { const key = fam(peer), held = byFamily[key] ??= []; for (const a of addons) if (!held.some(h => h.name === a.name)) held.push(a); }
79
+ out.tooling = sortKeys(byFamily);
80
+ }
81
+ return out;
82
+ }
83
+ const sortKeys = <T>(o: Record<string, T>): Record<string, T> => Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
84
+
85
+ const datasetLabel = (l: LabelRecord, familyOf: (peer: string) => string | undefined): LabelRecord => l.by === undefined ? l : { ...l, by: family(l.by, familyOf) };
86
+
87
+ /** Build one project's three files in memory; nothing is written. */
88
+ export function buildDataset(input: DatasetInput, options: DatasetOptions = {}): { tasks: string; manifest: DatasetManifest; quality: QualityReport } {
89
+ const now = (options.now ?? Date.now)(), since = options.since ?? 0, familyOf = input.familyOf ?? (() => undefined);
90
+ const fam = (peer: string) => family(peer, familyOf);
91
+ const tasks = latestTaskRecords(input.records).filter(r => Date.parse(closedAtOf(r) ?? "") >= since);
92
+ const keep = new Set(tasks.map(r => `${r.task}\n${r.createdAt}`));
93
+ const labels = input.records.filter((r): r is LabelRecord => r.kind === "label" && keep.has(`${r.task}\n${r.createdAt}`));
94
+ const rows = [...tasks.map(r => datasetTask(r, familyOf)), ...labels.map(l => datasetLabel(l, familyOf))];
95
+ const peers = new Set<string>();
96
+ for (const r of tasks) { for (const o of r.owners) peers.add(fam(o)); if (r.reviewer) peers.add(fam(r.reviewer)); for (const p of r.peers ?? []) peers.add(fam(p)); for (const p of Object.keys(r.tokens.byPeer)) peers.add(fam(p)); }
97
+ const quality = buildResearchQuality(input.records, input.events, { ...(since > 0 ? { since } : {}), until: now }, familyOf);
98
+ return {
99
+ tasks: rows.map(r => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : ""),
100
+ manifest: {
101
+ schema: MANIFEST_SCHEMA, hubVersion: options.hubVersion ?? VERSION, projectKeyHash: projectKey(input.project.id),
102
+ window: { since: since > 0 ? new Date(since).toISOString() : null, until: new Date(now).toISOString() }, peers: [...peers].sort(),
103
+ counts: { tasks: tasks.length, approved: tasks.filter(r => r.outcome === "approved").length, cancelled: tasks.filter(r => r.outcome === "cancelled").length, labels: labels.length },
104
+ recordedAt: new Date(now).toISOString(),
105
+ },
106
+ quality,
107
+ };
108
+ }
109
+
110
+ /** A file written whole: a temp file in the same directory, then a rename, so a reader never sees half of it. Owner-only. */
111
+ function writeWhole(file: string, text: string): void {
112
+ const temp = `${file}.${process.pid}.tmp`;
113
+ writeFileSync(temp, text, { mode: 0o600 });
114
+ chmodSync(temp, 0o600);
115
+ renameSync(temp, file);
116
+ }
117
+
118
+ /**
119
+ * Write each project's dataset under `<target>/<projectKeyHash>/`. The target is checked against every given project (the ones that will be exported and the one the command ran in), not
120
+ * only the one being written. A project directory that is a symlink is refused: it could point anywhere the check did not look.
121
+ */
122
+ export function exportResearchDataset(target: string, inputs: readonly DatasetInput[], options: DatasetOptions = {}, guard: readonly { root: string; stateDir: string }[] = inputs.map(i => i.project)): DatasetResult[] {
123
+ const refusal = refuseTarget(target, guard, options.home);
124
+ if (refusal) throw new Error(refusal);
125
+ mkdirSync(target, { recursive: true, mode: 0o700 });
126
+ const results: DatasetResult[] = [];
127
+ for (const input of inputs) {
128
+ const built = buildDataset(input, options), key = built.manifest.projectKeyHash, dir = join(target, key);
129
+ if (existsSync(dir) && lstatSync(dir).isSymbolicLink()) throw new Error(`${dir} is a symbolic link; remove it first`);
130
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ chmodSync(dir, 0o700);
132
+ writeWhole(join(dir, "tasks.jsonl"), built.tasks);
133
+ writeWhole(join(dir, "quality.json"), JSON.stringify(built.quality, null, 2) + "\n");
134
+ // The manifest goes last: a directory with a manifest is a complete one.
135
+ writeWhole(join(dir, "manifest.json"), JSON.stringify(built.manifest, null, 2) + "\n");
136
+ results.push({ projectKeyHash: key, dir, counts: built.manifest.counts });
137
+ }
138
+ return results;
139
+ }
140
+
141
+ /** The family of a peer id through the project's own config (instances resolve there); undefined when the config cannot be read, then only the shipped adapters' families apply. */
142
+ export function projectFamilyOf(root: string): ((peer: string) => string | undefined) | undefined {
143
+ try {
144
+ const config = readInstanceConfig(root);
145
+ return peer => config.catalog.familyOf(peer, config.instances);
146
+ } catch { return undefined; }
147
+ }
148
+
149
+ /**
150
+ * One registered project's records, events and family lookup, read-only. Instance ids resolve through that project's own config (`readInstanceConfig`); a config that cannot be read leaves
151
+ * the shipped adapters' families, and an id neither knows is `unknown`, never guessed from its spelling. Undefined when the project has no record file: it was never researched.
152
+ */
153
+ export function datasetInputFor(project: DatasetProject, home = hubHome()): DatasetInput | undefined {
154
+ const file = researchFile(project.id, home);
155
+ if (!existsSync(file)) return undefined;
156
+ const familyOf = projectFamilyOf(project.root);
157
+ return { project, records: readStore(file), events: readEvents(join(project.stateDir, "events.jsonl")), ...(familyOf ? { familyOf } : {}) };
158
+ }