@staix/agent-hub 0.20.0 → 0.22.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 (183) hide show
  1. package/CHANGELOG.md +66 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +17 -8
  9. package/docs/agent-notes/local-worker.md +4 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +17 -9
  12. package/docs/agent-notes/tests.md +9 -2
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +39 -4
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +610 -62
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +54 -10
  23. package/docs/security.md +396 -34
  24. package/docs/smoke.md +133 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  29. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  30. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  31. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  32. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  33. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  34. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  35. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  36. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  37. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  38. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  39. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  40. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  41. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  42. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  43. package/docs/verification/claude-permission-transport-native.py +71 -14
  44. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  45. package/docs/verified.json +15 -15
  46. package/package.json +2 -2
  47. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  48. package/plugins/agent-hub/server.js +1016 -280
  49. package/src/adapters/acp.ts +79 -3
  50. package/src/adapters/channel-peer.ts +4 -0
  51. package/src/adapters/claude-channel.ts +13 -6
  52. package/src/adapters/claude-implementer.ts +60 -7
  53. package/src/adapters/codex-appserver.ts +77 -4
  54. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  55. package/src/adapters/conformance/kit.ts +3 -9
  56. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  57. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  58. package/src/adapters/definitions/native-config-policy.ts +3 -2
  59. package/src/adapters/definitions/native-context.ts +12 -0
  60. package/src/adapters/external-metadata.ts +48 -0
  61. package/src/adapters/external-modules.ts +63 -3
  62. package/src/adapters/jcode.ts +6 -2
  63. package/src/adapters/local-worker.ts +40 -25
  64. package/src/adapters/native-proof-ports.ts +10 -0
  65. package/src/adapters/registry-types.ts +4 -0
  66. package/src/broker/protocol.ts +203 -0
  67. package/src/broker/server.ts +314 -0
  68. package/src/broker/store.ts +548 -0
  69. package/src/cli/bench.ts +3 -2
  70. package/src/cli/broker.ts +58 -0
  71. package/src/cli/conductor-recovery.ts +142 -0
  72. package/src/cli/configured-acp-entry.ts +26 -6
  73. package/src/cli/console-state.ts +59 -17
  74. package/src/cli/console.ts +17 -2
  75. package/src/cli/facts-hook.ts +3 -1
  76. package/src/cli/help.ts +29 -16
  77. package/src/cli/identity.ts +25 -10
  78. package/src/cli/init.ts +34 -7
  79. package/src/cli/intake-output.ts +33 -0
  80. package/src/cli/learn-output.ts +38 -0
  81. package/src/cli/main.ts +334 -73
  82. package/src/cli/output.ts +41 -1
  83. package/src/cli/recovery-package.ts +18 -1
  84. package/src/cli/recovery-waits.ts +26 -0
  85. package/src/cli/role-holders.ts +35 -0
  86. package/src/cli/room.ts +304 -0
  87. package/src/cli/say-input.ts +16 -0
  88. package/src/cli/schedule-output.ts +49 -0
  89. package/src/cli/sdlc-output.ts +44 -6
  90. package/src/cli/smoke-pty-bridge.ts +82 -0
  91. package/src/cli/smoke-pty.ts +153 -0
  92. package/src/cli/smoke-run.ts +139 -0
  93. package/src/cli/smoke-target.ts +130 -0
  94. package/src/cli/source-output.ts +29 -0
  95. package/src/cli/task-output.ts +3 -2
  96. package/src/cli/upgrade-runtime.ts +112 -28
  97. package/src/cli/upgrade.ts +68 -8
  98. package/src/hub/ask.ts +76 -12
  99. package/src/hub/board.ts +132 -22
  100. package/src/hub/bus.ts +416 -65
  101. package/src/hub/child-process.ts +2 -0
  102. package/src/hub/ci-watch.ts +42 -20
  103. package/src/hub/claude-failure-stage.ts +85 -0
  104. package/src/hub/claude-shell-wrapper.ts +11 -5
  105. package/src/hub/conductor.ts +17 -4
  106. package/src/hub/config-apply.ts +34 -6
  107. package/src/hub/config-reload.ts +7 -5
  108. package/src/hub/config-trust.ts +7 -1
  109. package/src/hub/context-window.ts +33 -3
  110. package/src/hub/control-client.ts +14 -2
  111. package/src/hub/corpus-config.ts +34 -0
  112. package/src/hub/corpus-evidence.ts +184 -0
  113. package/src/hub/corpus.ts +373 -0
  114. package/src/hub/cost-governance.ts +26 -0
  115. package/src/hub/daemon.ts +1209 -183
  116. package/src/hub/deliverable-files.ts +8 -2
  117. package/src/hub/delivery-journal.ts +200 -73
  118. package/src/hub/dogfood-profile.ts +32 -5
  119. package/src/hub/dogfood-read.ts +6 -2
  120. package/src/hub/envelope.ts +11 -0
  121. package/src/hub/eval-gate.ts +131 -0
  122. package/src/hub/events.ts +1 -1
  123. package/src/hub/external-instance.ts +69 -0
  124. package/src/hub/facts.ts +15 -5
  125. package/src/hub/folder-trigger.ts +223 -0
  126. package/src/hub/hub-tools.ts +2 -1
  127. package/src/hub/inference.ts +20 -5
  128. package/src/hub/instance-config.ts +69 -3
  129. package/src/hub/instance-execution.ts +6 -2
  130. package/src/hub/instances.ts +32 -4
  131. package/src/hub/intake.ts +125 -0
  132. package/src/hub/learning.ts +117 -0
  133. package/src/hub/lifecycle.ts +7 -3
  134. package/src/hub/limits.ts +12 -5
  135. package/src/hub/native-permission-request.ts +77 -0
  136. package/src/hub/operational-authority.ts +63 -5
  137. package/src/hub/operational-settings.ts +154 -0
  138. package/src/hub/operational-view.ts +1 -1
  139. package/src/hub/packs.ts +132 -0
  140. package/src/hub/path-policy.ts +7 -0
  141. package/src/hub/peer-doing.ts +130 -0
  142. package/src/hub/peers.ts +12 -0
  143. package/src/hub/queue-backpressure.ts +58 -0
  144. package/src/hub/read-tools.ts +2 -0
  145. package/src/hub/recovery-authority.ts +151 -0
  146. package/src/hub/recovery-policy.ts +220 -0
  147. package/src/hub/research.ts +24 -14
  148. package/src/hub/restart.ts +7 -0
  149. package/src/hub/routing.ts +54 -11
  150. package/src/hub/schedules.ts +266 -0
  151. package/src/hub/sdlc-merge.ts +38 -14
  152. package/src/hub/sdlc-metrics.ts +32 -10
  153. package/src/hub/sdlc-queue.ts +7 -1
  154. package/src/hub/sdlc-release.ts +1 -1
  155. package/src/hub/sdlc.ts +37 -7
  156. package/src/hub/security-baseline.ts +38 -0
  157. package/src/hub/settings.ts +27 -2
  158. package/src/hub/shipped-packs.ts +8 -0
  159. package/src/hub/snapshots.ts +72 -6
  160. package/src/hub/source-read.ts +160 -0
  161. package/src/hub/sources.ts +311 -0
  162. package/src/hub/tasks.ts +987 -110
  163. package/src/hub/workflow.ts +1 -1
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/tools.ts +37 -3
  167. package/src/local/web-fetch-config.ts +43 -0
  168. package/src/local/web-fetch.ts +183 -0
  169. package/src/memory/brief.ts +85 -13
  170. package/src/memory/capture.ts +1 -1
  171. package/src/rooms/application.ts +114 -0
  172. package/src/rooms/boundary.ts +103 -0
  173. package/src/rooms/commands.ts +294 -0
  174. package/src/rooms/crypto.ts +371 -0
  175. package/src/rooms/link.ts +584 -0
  176. package/src/rooms/policy.ts +82 -0
  177. package/src/rooms/remote-peer.ts +80 -0
  178. package/src/rooms/service.ts +426 -0
  179. package/src/rooms/status.ts +146 -0
  180. package/src/rooms/store.ts +1210 -0
  181. package/src/rooms/wire.ts +465 -0
  182. package/src/ui/index.html +91 -4
  183. package/templates/packs/example.pack.json +13 -0
@@ -0,0 +1,77 @@
1
+ import { types } from "node:util";
2
+
3
+ export const NATIVE_PERMISSION_PROBE_MAX_BYTES = 4096;
4
+ export const NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH = 4;
5
+ const MAX_DEPTH = 8, MAX_NODES = 256;
6
+
7
+ /** The approval path's pre-existing inline checks, deliberately without stricter shape checks.
8
+ * Keep ordinary property reads and their failure behavior; diagnostics cannot change native admission. */
9
+ export function acceptsNativePermissionRequest(value: unknown): boolean {
10
+ const args = value as Record<string, unknown> | undefined;
11
+ if (typeof args?.tool_name !== "string" || !/^[A-Za-z][A-Za-z0-9_.:/-]{0,127}$/.test(args.tool_name)
12
+ || !args.input || typeof args.input !== "object" || Array.isArray(args.input)
13
+ || typeof args.tool_use_id !== "string" || !args.tool_use_id.length || args.tool_use_id.length > 256) return false;
14
+ return true;
15
+ }
16
+
17
+ /** Strict diagnostic classification only. Never use this to narrow the approval path. */
18
+ export function validateNativePermissionRequest(value: unknown): boolean {
19
+ try {
20
+ if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value)) return false;
21
+ const prototype = Object.getPrototypeOf(value);
22
+ if (prototype !== Object.prototype && prototype !== null) return false;
23
+ const keys = Reflect.ownKeys(value);
24
+ if (keys.length !== 3 || !keys.every(key => key === "tool_name" || key === "input" || key === "tool_use_id")) return false;
25
+ const data = (key: string): unknown => {
26
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
27
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : undefined;
28
+ };
29
+ const tool = data("tool_name"), input = data("input"), id = data("tool_use_id");
30
+ return typeof tool === "string" && tool.length <= 128 && tool === tool.trim() && /^[A-Za-z][A-Za-z0-9_.:/-]{0,127}$/.test(tool)
31
+ && input !== null && typeof input === "object" && !Array.isArray(input)
32
+ && typeof id === "string" && id.length > 0 && id.length <= 256;
33
+ } catch { return false; }
34
+ }
35
+
36
+ /** A temporary bounded validation input, never retained evidence or an authorization request. */
37
+ export function buildNativePermissionProbe(request: unknown): { t: "permission_failure_probe"; request: unknown } | undefined {
38
+ try {
39
+ let nodes = 0, bytes = 0;
40
+ const ancestors = new Set<object>(), encoder = new TextEncoder();
41
+ const charge = (count: number) => { bytes += count; if (bytes > NATIVE_PERMISSION_PROBE_MAX_BYTES) throw new Error(); };
42
+ const string = (value: string) => {
43
+ if (value.length > NATIVE_PERMISSION_PROBE_MAX_BYTES) throw new Error();
44
+ charge(encoder.encode(JSON.stringify(value)).byteLength);
45
+ return value;
46
+ };
47
+ const clone = (value: unknown, depth: number): unknown => {
48
+ if (++nodes > MAX_NODES || depth > MAX_DEPTH) throw new Error();
49
+ if (typeof value === "string") return string(value);
50
+ if (value === null || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
51
+ charge(value === null ? 4 : typeof value === "boolean" ? value ? 4 : 5 : String(value).length); return value;
52
+ }
53
+ if (!value || typeof value !== "object" || types.isProxy(value) || ancestors.has(value)) throw new Error();
54
+ const array = Array.isArray(value), prototype = Object.getPrototypeOf(value);
55
+ if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) throw new Error();
56
+ const keys = Reflect.ownKeys(value);
57
+ if (keys.length > MAX_NODES || keys.some(key => typeof key !== "string")) throw new Error();
58
+ ancestors.add(value);
59
+ try {
60
+ const copy: Record<string, unknown> | unknown[] = array ? Object.setPrototypeOf([], null) : Object.create(null);
61
+ const length = array ? Object.getOwnPropertyDescriptor(value, "length")?.value : undefined;
62
+ if (array && (!Number.isSafeInteger(length) || length < 0 || length > MAX_NODES || keys.length !== length + 1)) throw new Error();
63
+ const names = array ? Array.from({ length }, (_, index) => String(index)) : keys as string[];
64
+ charge(2 + Math.max(0, names.length - 1) + (array ? 0 : names.length));
65
+ for (const key of names) {
66
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
67
+ if (!descriptor || !Object.hasOwn(descriptor, "value") || !descriptor.enumerable) throw new Error();
68
+ if (!array) string(key);
69
+ Object.defineProperty(copy, key, { value: clone(descriptor.value, depth + 1), enumerable: true, writable: true, configurable: true });
70
+ }
71
+ return copy;
72
+ } finally { ancestors.delete(value); }
73
+ };
74
+ const payload = Object.assign(Object.create(null), { t: "permission_failure_probe" as const, request: clone(request, 0) });
75
+ return encoder.encode(JSON.stringify(payload)).byteLength <= NATIVE_PERMISSION_PROBE_MAX_BYTES ? payload : undefined;
76
+ } catch { return; }
77
+ }
@@ -1,10 +1,16 @@
1
1
  import type { SettingDef } from "./settings.ts";
2
2
 
3
- /** Existing live registry rows only. A new registry key never inherits conductor authority from a prefix. */
3
+ /**
4
+ * Existing live registry rows only. A new registry key never inherits conductor authority from a prefix.
5
+ * #933: every live config key outside the person's group. The registry's groups cannot tell spending from operations
6
+ * (`tasks.wip_limits.*`, `tasks.auto_pull` and `tasks.queue_backpressure.*` decide how much work the hub hands out unattended,
7
+ * and an agent never turns auto pull on), and Permissions, Roles and Start are trust or not live, so the list stays explicit.
8
+ */
4
9
  export const CONDUCTOR_SETTING_KEYS = [
5
10
  "task_sweep.enabled", "task_sweep.auto_reassign", "task_sweep.no_writes_turns", "task_sweep.interval_s",
6
11
  "task_sweep.unaccepted_min", "task_sweep.idle_min", "task_sweep.review_min", "task_sweep.ready_min", "task_sweep.ladder_min",
7
12
  "snapshots.enabled", "research.enabled", "approvals.notify",
13
+ "tasks.escalate_after",
8
14
  ] as const;
9
15
  const settingKeys: ReadonlySet<string> = new Set(CONDUCTOR_SETTING_KEYS);
10
16
  export const operationalSettingKey = (value: unknown): value is string => typeof value === "string" && settingKeys.has(value);
@@ -24,19 +30,67 @@ export function operationalRosterSetting(def: Pick<SettingDef,"key"|"path"|"appl
24
30
  export const CONDUCTOR_LOCAL_ACTIONS = ["doctor orphans kill","logs","export"] as const;
25
31
  export type ConductorLocalAction = typeof CONDUCTOR_LOCAL_ACTIONS[number];
26
32
  export const conductorLocalAction = (value:unknown): value is ConductorLocalAction => typeof value==="string" && (CONDUCTOR_LOCAL_ACTIONS as readonly string[]).includes(value);
27
- export type ConductorOperation = ConductorLocalAction | "roster add" | "roster remove" | "stop" | "queue show" | "queue resolve" | "queue discard" | "task cancel" | "settings set";
33
+ /** Daemon-authorized labels. Recovery labels pass the initial role check, then require their separate per-operation proof. */
34
+ export const CONDUCTOR_DAEMON_OPERATIONS = ["roster add","roster remove","stop","queue show","queue resolve","queue discard","settings set","doctor orphans kill","logs","export",
35
+ "board import","intake approve","schedule run","schedule pause","schedule resume","goal link","task unassign","restart","upgrade","recovery resume","recovery abort"] as const;
36
+ /**
37
+ * #933: forms whose effect runs in the CLI process itself (local files). The daemon admits and audits them once at the handshake (`admit`),
38
+ * after the CLI has validated their arguments. Recovery-bearing restart/upgrade forms use the separate source-issued continuation proof.
39
+ * ponytail: `memory promote|demote|forget|supersede|rebuild|machine --label` and the local `goal` changes stay the person's because the pinned
40
+ * amem (0.12.0) records their actor as "user"; once the hub passes an agent actor through agent-mem 0.13.0 attribution (agent-mem#143, after
41
+ * the pin bump in #958) they can join this list. `goal link` is a daemon form: the hub records the peer as its actor.
42
+ */
43
+ export const CONDUCTOR_LOCAL_OPERATIONS = ["research backfill","bench run","source add"] as const;
44
+ export type ConductorOperation = typeof CONDUCTOR_DAEMON_OPERATIONS[number] | typeof CONDUCTOR_LOCAL_OPERATIONS[number];
45
+ export const conductorLocalOperation = (value:unknown): boolean => typeof value==="string" && (CONDUCTOR_LOCAL_OPERATIONS as readonly string[]).includes(value);
46
+
47
+ function operationalDisplayArgument(arg: string): boolean {
48
+ return arg === "--json" || arg === "--verbose" || arg.startsWith("--color=");
49
+ }
50
+
51
+ function recoveryConductorAction(args: readonly string[]): "resume" | "abort" | undefined {
52
+ const words = args.filter(arg => !operationalDisplayArgument(arg));
53
+ const action = words[0];
54
+ if (action !== "resume" && action !== "abort") return undefined;
55
+ const tail = words.slice(1);
56
+ if (tail.length > 1 || tail.some(arg => arg.startsWith("-"))) return undefined;
57
+ return action;
58
+ }
59
+
60
+ function restartOrUpgrade(command: string, args: readonly string[]): "restart" | "upgrade" | undefined {
61
+ const words = args.filter(arg => !operationalDisplayArgument(arg));
62
+ if (words.includes("--dry-run")) return undefined;
63
+ if (command === "restart") return words.length === 1 && words[0] === "--yes" ? "restart" : undefined;
64
+ if (command !== "upgrade" || words.length !== 3) return undefined;
65
+ if (words[0] === "--yes" && words[1] === "--to" && !words[2]!.startsWith("-")) return "upgrade";
66
+ if (words[0] === "--to" && !words[1]!.startsWith("-") && words[2] === "--yes") return "upgrade";
67
+ return undefined;
68
+ }
28
69
 
29
70
  /** Select the closed command family; the existing command parser still validates all arguments before effects. */
30
71
  export function conductorOperation(command:string,args:readonly string[]): ConductorOperation | undefined {
31
72
  const words=args.filter(arg=>!arg.startsWith("--"));
32
73
  const sub=words[0];
74
+ if (command === "restart" || command === "upgrade") return restartOrUpgrade(command, args);
75
+ if (command === "recovery") {
76
+ const action = recoveryConductorAction(args);
77
+ return action ? `recovery ${action}` : undefined;
78
+ }
33
79
  if (command==="roster" && (sub==="add" || sub==="remove")) return `roster ${sub}`;
34
80
  if (command==="stop" && args.length===1 && /^[a-z][a-z0-9-]{0,31}$/.test(args[0]!)) return "stop";
35
81
  if (command==="queue" && (sub==="show" || sub==="resolve" || sub==="discard")) return `queue ${sub}`;
36
- if (command==="task" && sub==="cancel") return "task cancel";
82
+ if (command==="task" && sub==="unassign") return "task unassign";
37
83
  if (command==="doctor" && args.includes("--orphans") && args.includes("--kill")) return "doctor orphans kill";
38
84
  if (command==="logs" || command==="export") return command;
39
85
  if (command==="settings" && sub==="set" && operationalSettingKey(words[1])) return "settings set";
86
+ // #933: operational forms the person no longer has to run; recovery-bearing forms above use their own proof.
87
+ if (command==="board" && args[0]==="import") return "board import";
88
+ if (command==="research" && args[0]==="backfill") return "research backfill";
89
+ if (command==="bench" && args.find(arg=>arg!=="--json")==="run") return "bench run";
90
+ if (command==="source" && sub==="add") return "source add";
91
+ if (command==="intake" && sub==="approve" && words.length===2) return "intake approve";
92
+ if (command==="schedule" && (sub==="run" || sub==="pause" || sub==="resume")) return `schedule ${sub}`;
93
+ if (command==="goal" && args.find(arg=>arg!=="--json")==="link") return "goal link";
40
94
  return undefined;
41
95
  }
42
96
 
@@ -51,16 +105,20 @@ export const OPERATIONAL_PERSON_GUIDANCE="the person runs it in ahub console or
51
105
  export function operationalRefusal(peer:string,command:string):string {
52
106
  return `${peer} cannot run ahub ${command}; ${OPERATIONAL_PERSON_GUIDANCE}; this operation requires the explicit conductor role`;
53
107
  }
108
+ /** #933: the console task ops the conductor may now run, by audit label. The daemon admits each through `admitOperational` before Tasks sees it. */
109
+ export const CONDUCTOR_TASK_OPS: Readonly<Record<string,ConductorOperation>> = {github_import:"board import",intake_approve:"intake approve",schedule_run:"schedule run",schedule_pause:"schedule pause",schedule_resume:"schedule resume",task_unassign:"task unassign"};
54
110
  /** Fixed labels only; refused requests never put argument text in the audit. */
55
111
  export function operationalRequestLabel(value:unknown):string|undefined {
56
112
  if(!value || typeof value!=="object" || Array.isArray(value))return undefined;
57
113
  const message=value as Record<string,unknown>;
58
114
  if(message.t==="conductor_operational") {
59
- if(message.op==="authorize")return typeof message.command==="string" && ["roster add","roster remove","stop","queue show","queue resolve","queue discard","task cancel","settings set","doctor orphans kill","logs","export"].includes(message.command)?message.command:"unknown";
115
+ if(message.op==="authorize")return typeof message.command==="string" && (CONDUCTOR_DAEMON_OPERATIONS as readonly string[]).includes(message.command)?message.command:"unknown";
116
+ if(message.op==="admit")return conductorLocalOperation(message.command)?message.command as string:"unknown";
60
117
  return message.op==="orphans"?"doctor orphans kill":message.op==="logs"||message.op==="export"?message.op:"unknown";
61
118
  }
62
119
  if(message.t==="peer_stop")return "stop";
63
- if(message.t==="task" && message.op==="hub_task_cancel")return "task cancel";
120
+ if(message.t==="goal_link")return "goal link";
121
+ if(message.t==="task" && typeof message.op==="string" && Object.hasOwn(CONDUCTOR_TASK_OPS,message.op))return CONDUCTOR_TASK_OPS[message.op];
64
122
  if(message.t==="queue" && typeof message.op==="string" && ["show","resolve","discard"].includes(message.op))return `queue ${message.op}`;
65
123
  if(message.t==="settings_set"||message.t==="settings_preview")return "settings set";
66
124
  return undefined;
@@ -0,0 +1,154 @@
1
+ import type { SettingDef } from "./settings.ts";
2
+
3
+ /** Numeric operational metadata only: existing schema, settings and reload retain all authority. */
4
+ export interface OperationalNumberSetting extends Pick<SettingDef, "key" | "group" | "label" | "path" | "applies" | "restartReason"> {
5
+ type: "number" | "integer";
6
+ store: "config";
7
+ risk: "raises";
8
+ min: number;
9
+ max: number;
10
+ defaultValue: number;
11
+ unit: string;
12
+ /** The independently classified H0 item, not a safety floor or wire/storage contract. */
13
+ inventory: string;
14
+ }
15
+
16
+ export const GROOM_FYI_WINDOW_SETTING = {
17
+ key: "tasks.groom_fyi_window_min",
18
+ path: ["tasks", "groom_fyi_window_min"],
19
+ group: "Routing",
20
+ label: "Planner state-update FYI interval (minutes)",
21
+ type: "number",
22
+ store: "config",
23
+ risk: "raises",
24
+ applies: "live",
25
+ min: 1,
26
+ max: 1440,
27
+ defaultValue: 10,
28
+ unit: "minutes",
29
+ inventory: "src/hub/tasks.ts:GROOM_FYI_WINDOW_MS",
30
+ } as const satisfies OperationalNumberSetting;
31
+
32
+ const taskNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies">>(def: T) => ({ group: "Routing", store: "config", risk: "raises", applies: "live", ...def }) as const satisfies OperationalNumberSetting;
33
+
34
+ /** #489 H2, tasks and routing. Each default equals the constant it replaces (pinned in test/operational-settings.test.ts); none is a safety floor or a contract. */
35
+ export const DEMOTE_AT_SETTING = taskNumber({
36
+ key: "tasks.demote_at", path: ["tasks", "demote_at"], label: "Peer demotion threshold (decayed recent failures)",
37
+ type: "number", min: 0.5, max: 20, defaultValue: 1.5, unit: "failures", inventory: "src/hub/tasks.ts:DEMOTE_AT",
38
+ });
39
+ export const DEMOTION_HALF_LIFE_SETTING = taskNumber({
40
+ key: "tasks.demotion_half_life_h", path: ["tasks", "demotion_half_life_h"], label: "Demotion half-life: a failure counts half after (hours)",
41
+ type: "number", min: 1, max: 168, defaultValue: 24, unit: "hours", inventory: "src/hub/tasks.ts:DEMOTION_HALF_LIFE_MS",
42
+ });
43
+ export const CONTRADICTION_WINDOW_SETTING = taskNumber({
44
+ key: "tasks.contradiction_window_days", path: ["tasks", "contradiction_window_days"], label: "Approval contradiction window (days)",
45
+ type: "number", min: 1, max: 90, defaultValue: 7, unit: "days", inventory: "src/hub/tasks.ts:CONTRADICTION_WINDOW_MS",
46
+ });
47
+ export const RESCREEN_MAX_SETTING = taskNumber({
48
+ key: "tasks.rescreen_max", path: ["tasks", "rescreen_max"], label: "PII re-screen attempts per task in a hub run",
49
+ type: "integer", min: 1, max: 100, defaultValue: 10, unit: "attempts", inventory: "src/hub/tasks.ts:RESCREEN_MAX",
50
+ });
51
+ export const SPLIT_MIN_SETTING = taskNumber({
52
+ key: "tasks.split_min_observations", path: ["tasks", "split_min_observations"], label: "Shadow split prediction: measured tasks a peer needs",
53
+ type: "integer", min: 3, max: 100, defaultValue: 5, unit: "tasks", inventory: "src/hub/routing.ts:SPLIT_MIN",
54
+ });
55
+ export const SPLIT_FAILURE_SHARE_SETTING = taskNumber({
56
+ key: "tasks.split_max_failure_share", path: ["tasks", "split_max_failure_share"], label: "Shadow split prediction: failed share a history may hold",
57
+ type: "number", min: 0, max: 1, defaultValue: 0.3, unit: "share", inventory: "src/hub/routing.ts:SPLIT_FAILURE_SHARE",
58
+ });
59
+
60
+ const busNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies" | "type" | "min">>(def: T) => ({ group: "Routing", store: "config", risk: "raises", applies: "live", type: "integer", min: 1, ...def }) as const satisfies OperationalNumberSetting;
61
+
62
+ export const BUS_MAX_ATTEMPTS_SETTING = busNumber({
63
+ key: "bus.max_attempts", path: ["bus", "max_attempts"], label: "Safe-failure delivery attempts",
64
+ max: 100, defaultValue: 3, unit: "attempts", inventory: "src/hub/bus.ts:MAX_ATTEMPTS",
65
+ });
66
+ export const BUS_DIGEST_MAX_SETTING = busNumber({
67
+ key: "bus.digest_max", path: ["bus", "digest_max"], label: "Maximum originals in a push digest",
68
+ max: 10, defaultValue: 10, unit: "envelopes", inventory: "src/hub/bus.ts:DIGEST_MAX",
69
+ });
70
+ export const BUS_PULL_BUDGET_BYTES_SETTING = busNumber({
71
+ key: "bus.pull_budget_bytes", path: ["bus", "pull_budget_bytes"], label: "Pull rendered-text budget (bytes)",
72
+ max: 65_536, defaultValue: 65_536, unit: "bytes", inventory: "src/hub/bus.ts:PULL_BUDGET_BYTES",
73
+ });
74
+ export const BUS_NOTES_KEPT_SETTING = busNumber({
75
+ key: "bus.notes_kept", path: ["bus", "notes_kept"], label: "Shared notes retained in a merged preface",
76
+ max: 10, defaultValue: 10, unit: "notes", inventory: "src/hub/bus.ts:NOTES_KEPT",
77
+ });
78
+ export const BUS_TERMINAL_HISTORY_LIMIT_SETTING = busNumber({
79
+ key: "bus.terminal_history_limit", path: ["bus", "terminal_history_limit"], label: "Unprotected terminal delivery history target",
80
+ max: 2048, defaultValue: 2048, unit: "rows", inventory: "src/hub/delivery-journal.ts:TERMINAL_CAP",
81
+ });
82
+
83
+ /**
84
+ * #489 H4, recovery and upgrade waits. These are read by the upgrade coordinator (a CLI process) from the project's own config at the START of each wait, so a change applies at the next wait and a wait
85
+ * already running keeps its deadline. Every row's minimum is today's constant: a person-visible wait can be raised, never cut below what ships (a request that times out may already have applied, and a
86
+ * shorter reconnect wait fails a recovery earlier than the person was told). The one row that may shorten, `recovery.terminal_idle_wait_s`, only ends a wait in manual-required, which retains the terminal.
87
+ */
88
+ const recoveryNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies" | "type">>(def: T) => ({ group: "Routing", store: "config", risk: "raises", applies: "next use", type: "integer", ...def }) as const satisfies OperationalNumberSetting;
89
+ export const RECOVERY_RECONNECT_WAIT_SETTING = recoveryNumber({
90
+ key: "recovery.reconnect_wait_s", path: ["recovery", "reconnect_wait_s"], label: "Upgrade recovery: wait for a managed terminal to attach (seconds)",
91
+ min: 600, max: 3600, defaultValue: 600, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:RECONNECT_WAIT_MS",
92
+ });
93
+ export const RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING = recoveryNumber({
94
+ key: "recovery.unmanaged_reconnect_wait_s", path: ["recovery", "unmanaged_reconnect_wait_s"], label: "Upgrade recovery: wait for an unmanaged Claude to reconnect (seconds)",
95
+ min: 90, max: 900, defaultValue: 90, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:UNMANAGED_RECONNECT_WAIT_MS",
96
+ });
97
+ export const RECOVERY_CONTROL_TIMEOUT_SETTING = recoveryNumber({
98
+ key: "recovery.control_timeout_s", path: ["recovery", "control_timeout_s"], label: "Upgrade recovery: wait for one control request to a hub (seconds)",
99
+ min: 30, max: 300, defaultValue: 30, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:RECOVERY_CONTROL_TIMEOUT_MS",
100
+ });
101
+ export const RECOVERY_SHUTDOWN_WAIT_SETTING = recoveryNumber({
102
+ key: "recovery.shutdown_wait_s", path: ["recovery", "shutdown_wait_s"], label: "Upgrade recovery: wait for the old hub to stop after commit (seconds)",
103
+ min: 30, max: 300, defaultValue: 30, unit: "seconds", inventory: "src/cli/upgrade.ts:deadline",
104
+ });
105
+ export const RECOVERY_TERMINAL_IDLE_WAIT_SETTING = recoveryNumber({
106
+ key: "recovery.terminal_idle_wait_s", path: ["recovery", "terminal_idle_wait_s"], label: "Upgrade recovery: wait for a terminal to go idle before closing it (seconds)",
107
+ min: 30, max: 600, defaultValue: 120, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:waitForIdle",
108
+ });
109
+
110
+ /** Fixed metadata; never registration from a file, request, adapter or environment variable. */
111
+ export const OPERATIONAL_NUMBERS: readonly OperationalNumberSetting[] = [GROOM_FYI_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, CONTRADICTION_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_MIN_SETTING, SPLIT_FAILURE_SHARE_SETTING,
112
+ BUS_MAX_ATTEMPTS_SETTING, BUS_DIGEST_MAX_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_NOTES_KEPT_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING,
113
+ RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING];
114
+ /** The `tasks` leaves the six #489 H2 readers need; `HubConfig.tasks` has them all. */
115
+ export interface TaskOperationalConfig { tasks: { demote_at?: number; demotion_half_life_h?: number; contradiction_window_days?: number; rescreen_max?: number; split_min_observations?: number; split_max_failure_share?: number } }
116
+ /**
117
+ * The one place the daemon turns the six task settings into `TasksDeps` readers. `config` is asked at every use, so a reload applies at the next demotion read, verdict, re-screen or prediction; an unset leaf is the row's default,
118
+ * which is the constant it replaced.
119
+ */
120
+ export const taskOperationalReaders = (config: () => TaskOperationalConfig) => ({
121
+ demoteAt: () => config().tasks.demote_at ?? DEMOTE_AT_SETTING.defaultValue,
122
+ demotionHalfLifeH: () => config().tasks.demotion_half_life_h ?? DEMOTION_HALF_LIFE_SETTING.defaultValue,
123
+ contradictionWindowDays: () => config().tasks.contradiction_window_days ?? CONTRADICTION_WINDOW_SETTING.defaultValue,
124
+ rescreenMax: () => config().tasks.rescreen_max ?? RESCREEN_MAX_SETTING.defaultValue,
125
+ splitMinObservations: () => config().tasks.split_min_observations ?? SPLIT_MIN_SETTING.defaultValue,
126
+ splitMaxFailureShare: () => config().tasks.split_max_failure_share ?? SPLIT_FAILURE_SHARE_SETTING.defaultValue,
127
+ });
128
+ export interface BusOperationalConfig { bus?: { max_attempts?: number; digest_max?: number; pull_budget_bytes?: number; notes_kept?: number; terminal_history_limit?: number } }
129
+ /** Read accepted configuration at the next operation; a reload itself never trims or retries work. */
130
+ export const busOperationalReaders = (config: () => BusOperationalConfig) => ({
131
+ maxAttempts: () => config().bus?.max_attempts ?? BUS_MAX_ATTEMPTS_SETTING.defaultValue,
132
+ digestMax: () => config().bus?.digest_max ?? BUS_DIGEST_MAX_SETTING.defaultValue,
133
+ pullBudgetBytes: () => config().bus?.pull_budget_bytes ?? BUS_PULL_BUDGET_BYTES_SETTING.defaultValue,
134
+ notesKept: () => config().bus?.notes_kept ?? BUS_NOTES_KEPT_SETTING.defaultValue,
135
+ terminalHistoryLimit: () => config().bus?.terminal_history_limit ?? BUS_TERMINAL_HISTORY_LIMIT_SETTING.defaultValue,
136
+ });
137
+ export interface RecoveryOperationalConfig { recovery?: { reconnect_wait_s?: unknown; unmanaged_reconnect_wait_s?: unknown; control_timeout_s?: unknown; shutdown_wait_s?: unknown; terminal_idle_wait_s?: unknown } }
138
+ export interface RecoveryWaits { reconnectWaitMs: number; unmanagedReconnectWaitMs: number; controlTimeoutMs: number; shutdownWaitMs: number; terminalIdleWaitMs: number }
139
+ const recoveryMs = (def: OperationalNumberSetting, raw: unknown): number => {
140
+ // A value outside the row's bounds, a non-integer or a non-number is the default, never an error inside a recovery: the file was validated when it was written, and a recovery must not fail on a later edit.
141
+ return typeof raw === "number" && Number.isSafeInteger(raw) && raw >= def.min && raw <= def.max ? raw * 1000 : def.defaultValue * 1000;
142
+ };
143
+ /** The one place the coordinator turns the five recovery settings into milliseconds. `config` is asked at each wait, so the next wait reads the file as it is then. */
144
+ export const recoveryWaits = (config: () => RecoveryOperationalConfig | undefined): RecoveryWaits => {
145
+ const leaf = config()?.recovery;
146
+ return {
147
+ reconnectWaitMs: recoveryMs(RECOVERY_RECONNECT_WAIT_SETTING, leaf?.reconnect_wait_s),
148
+ unmanagedReconnectWaitMs: recoveryMs(RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING, leaf?.unmanaged_reconnect_wait_s),
149
+ controlTimeoutMs: recoveryMs(RECOVERY_CONTROL_TIMEOUT_SETTING, leaf?.control_timeout_s),
150
+ shutdownWaitMs: recoveryMs(RECOVERY_SHUTDOWN_WAIT_SETTING, leaf?.shutdown_wait_s),
151
+ terminalIdleWaitMs: recoveryMs(RECOVERY_TERMINAL_IDLE_WAIT_SETTING, leaf?.terminal_idle_wait_s),
152
+ };
153
+ };
154
+ export const operationalNumber = (key: string): OperationalNumberSetting | undefined => OPERATIONAL_NUMBERS.find(def => def.key === key);
@@ -66,7 +66,7 @@ function metadataValue(type:string,key:string,value:unknown):boolean {
66
66
  if(key==="item")return oneOf(value,["task","summary","review note","handoff","note"]);
67
67
  if(key==="category")return oneOf(value,["name","student_id","phone","address","grade","health","other"]);
68
68
  if(key==="miss")return oneOf(value,["too long","off campus","timeout","unreadable","failed"]);
69
- 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"]):identifier(value);
69
+ 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
70
  if(key==="measuredAt")return type==="context_pressure" || type==="quota_reset"?typeof value==="number"&&Number.isFinite(value):typeof value==="string"&&Number.isFinite(Date.parse(value));
71
71
  if(key==="window")return value===null || typeof value==="number"&&Number.isFinite(value)&&value>=0 || identifier(value);
72
72
  if(key==="priority")return value===null || typeof value==="number"&&Number.isFinite(value) || oneOf(value,["status","important","fyi"]);
@@ -0,0 +1,132 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PROFILE_NAMES, resolveProfile, type ProfileName } from "./profiles.ts";
4
+ import { loadRoleCatalog } from "./roles.ts";
5
+ import { SHIPPED_PACKS } from "./shipped-packs.ts";
6
+ import { criterionId } from "./workflow.ts";
7
+
8
+ /**
9
+ * A domain pack (#513a): a profile plus the roles it expects, a review rubric and example checks, in one file. It is data, never authority: it selects an
10
+ * existing profile, names roles that must already be in the catalog, and contributes only `profile_overrides` text (`review_checklist`, `example_checks`)
11
+ * that `resolveProfile` already bounds. Checks are display text exactly like `example_checks`: nothing here selects or runs a command.
12
+ * The rubric ids follow #507's criterion ids (`criterionId`), so a reviewer names an unmet item by the same id the task's checklist uses.
13
+ */
14
+ export interface DomainPack {
15
+ name: string; profile: ProfileName; summary: string; roles: string[];
16
+ rubric: { id: string; text: string; required: boolean }[];
17
+ checks: { id: string; note: string }[];
18
+ layer: "shipped" | "project";
19
+ }
20
+ export interface PackCatalog { packs: Map<string, DomainPack>; errors: { id: string; reason: string }[] }
21
+ export const PACK_SUFFIX = ".pack.json";
22
+ const NAME = /^[a-z][a-z0-9-]{0,47}$/;
23
+ const MAX_BYTES = 8192, MAX_FILES = 16, MAX_ROLES = 8, MAX_RUBRIC = 12, MAX_CHECKS = 8;
24
+ const KEYS = ["name", "profile", "summary", "roles", "rubric", "checks"];
25
+
26
+ const plain = (value: unknown, cap: number, what: string): string => {
27
+ if (typeof value !== "string" || !value.trim() || value.length > cap || value !== value.trim()) throw new Error(`${what} must be trimmed text of at most ${cap} characters`);
28
+ for (const char of value) if (/[\x00-\x1f\x7f]/.test(char) || (char.codePointAt(0)! > 127 && /[\p{P}\p{S}\p{Z}\p{C}]/u.test(char))) throw new Error(`${what} must use plain punctuation and no control characters`);
29
+ return value;
30
+ };
31
+ const object = (value: unknown, keys: string[], what: string): Record<string, unknown> => {
32
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) throw new Error(`${what}: unexpected shape or field`);
33
+ return value as Record<string, unknown>;
34
+ };
35
+
36
+ /** One pack file. `stem` is the file name without `.pack.json`; the name inside must equal it. */
37
+ export function parsePack(text: string, stem: string, layer: DomainPack["layer"]): DomainPack {
38
+ if (Buffer.byteLength(text) > MAX_BYTES || text.includes("\r") || !NAME.test(stem)) throw new Error("invalid pack size, encoding or file name");
39
+ let raw: unknown;
40
+ try { raw = JSON.parse(text); } catch { throw new Error("pack is not valid JSON"); }
41
+ const data = object(raw, KEYS, "pack");
42
+ if (KEYS.some(key => data[key] === undefined)) throw new Error("pack needs name, profile, summary, roles, rubric and checks");
43
+ if (data.name !== stem) throw new Error("pack name must equal its file name");
44
+ if ((PROFILE_NAMES as readonly string[]).includes(stem)) throw new Error("a pack cannot take a profile's name");
45
+ if (typeof data.profile !== "string" || !(PROFILE_NAMES as readonly string[]).includes(data.profile)) throw new Error(`pack profile must be one of ${PROFILE_NAMES.join(", ")}`);
46
+ const summary = plain(data.summary, 160, "summary");
47
+ if (!Array.isArray(data.roles) || data.roles.length > MAX_ROLES || new Set(data.roles).size !== data.roles.length || data.roles.some(id => typeof id !== "string" || !NAME.test(id))) throw new Error(`pack roles must be at most ${MAX_ROLES} distinct role ids`);
48
+ if (!Array.isArray(data.rubric) || !data.rubric.length || data.rubric.length > MAX_RUBRIC) throw new Error(`pack rubric needs 1 to ${MAX_RUBRIC} criteria`);
49
+ const rubric = data.rubric.map((entry, index) => {
50
+ const item = object(entry, ["id", "text", "required"], `rubric ${index + 1}`);
51
+ if (!criterionId(item.id) || typeof item.required !== "boolean") throw new Error(`rubric ${index + 1} needs a criterion id and a required flag`);
52
+ return { id: item.id, text: plain(item.text, 300, `rubric ${item.id} text`), required: item.required };
53
+ });
54
+ if (new Set(rubric.map(item => item.id)).size !== rubric.length) throw new Error("rubric criterion ids must be distinct");
55
+ if (!Array.isArray(data.checks) || data.checks.length > MAX_CHECKS) throw new Error(`pack checks must be at most ${MAX_CHECKS} entries`);
56
+ const checks = data.checks.map((entry, index) => {
57
+ const item = object(entry, ["id", "note"], `check ${index + 1}`);
58
+ if (typeof item.id !== "string" || !NAME.test(item.id)) throw new Error(`check ${index + 1} needs a check id`);
59
+ return { id: item.id, note: plain(item.note, 500, `check ${item.id} note`) };
60
+ });
61
+ if (new Set(checks.map(item => item.id)).size !== checks.length) throw new Error("check ids must be distinct");
62
+ return { name: stem, profile: data.profile as ProfileName, summary, roles: [...data.roles as string[]], rubric, checks, layer };
63
+ }
64
+
65
+ /** Read one bounded regular file without following links, as `loadRoleCatalog` reads a project role. */
66
+ function readPack(file: string): string {
67
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
68
+ try {
69
+ const before = fstatSync(fd);
70
+ if (!before.isFile() || before.nlink !== 1 || before.size > MAX_BYTES) throw new Error("pack must be a bounded regular file");
71
+ const bytes = Buffer.alloc(MAX_BYTES + 1); let length = 0, n = 0;
72
+ while ((n = readSync(fd, bytes, length, bytes.length - length, null)) > 0) { length += n; if (length > MAX_BYTES) throw new Error("pack exceeds file cap"); }
73
+ const after = fstatSync(fd);
74
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || length !== after.size) throw new Error("pack changed during read");
75
+ return bytes.subarray(0, length).toString("utf8");
76
+ } finally { closeSync(fd); }
77
+ }
78
+
79
+ /**
80
+ * Project-layer packs, `.agenthub/roles/<name>.pack.json` (#513a AC4): the same directory the role catalog reads, which ignores these files (it takes `.md` only).
81
+ * A pack whose roles are not all in the catalog (shipped or project) is refused with the reason, never loaded half-usable. Reasons carry ids, never file contents.
82
+ */
83
+ export function loadPacks(cwd?: string, shippedPacks: Readonly<Record<string, string>> = SHIPPED_PACKS): PackCatalog {
84
+ const packs = new Map<string, DomainPack>(), errors: PackCatalog["errors"] = [];
85
+ // The shipped layer comes first and a project pack of the same name replaces it, as a project role replaces a shipped one. Its roles are checked against the shipped catalog only: it must not depend on a project file.
86
+ const shipped = loadRoleCatalog();
87
+ for (const [id, text] of Object.entries(shippedPacks)) {
88
+ try {
89
+ const pack = parsePack(text, id, "shipped");
90
+ const missing = pack.roles.filter(role => !shipped.roles.has(role));
91
+ if (missing.length) throw new Error(`unknown roles: ${missing.join(", ")}`);
92
+ packs.set(id, pack);
93
+ } catch { errors.push({ id, reason: "invalid shipped pack" }); }
94
+ }
95
+ if (!cwd) return { packs, errors };
96
+ const catalog = loadRoleCatalog(cwd);
97
+ try {
98
+ const config = join(cwd, ".agenthub"), dir = join(config, "roles");
99
+ // lstat, never stat: a link is not a directory to lstat, so a symlinked `.agenthub` or `roles` is refused before anything is read through it.
100
+ for (const path of [config, dir]) if (!lstatSync(path).isDirectory()) throw new Error("unsafe role directory");
101
+ // ponytail: 16 flat pack files, no recursive scan; an explicit pack index before larger collections.
102
+ const files = readdirSync(dir).filter(name => name.endsWith(PACK_SUFFIX)).sort();
103
+ if (files.length > MAX_FILES) throw new Error("too many project packs");
104
+ for (const file of files) {
105
+ const id = file.slice(0, -PACK_SUFFIX.length);
106
+ try {
107
+ const pack = parsePack(readPack(join(dir, file)), id, "project");
108
+ const missing = pack.roles.filter(role => !catalog.roles.has(role));
109
+ if (missing.length) throw new Error(`unknown roles: ${missing.join(", ")}`);
110
+ packs.set(id, pack);
111
+ } catch (error) { errors.push({ id: NAME.test(id) ? id : "invalid-file-id", reason: (error as Error).message.slice(0, 200) }); }
112
+ }
113
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push({ id: "project", reason: "project pack directory unavailable or unsafe" }); }
114
+ return { packs, errors };
115
+ }
116
+
117
+ /** A stable text of plain JSON data: object keys sorted at every depth, array order kept (a checklist's order is its meaning). Two values with the same data compare equal whatever order their keys were written in. */
118
+ export function canonicalJson(value: unknown): string {
119
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
120
+ if (value && typeof value === "object") return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
121
+ return JSON.stringify(value) ?? "null";
122
+ }
123
+
124
+ /** What a pack contributes to the project's `profile_overrides`: the rubric as the profile's review checklist, the checks as advisory examples. Validated by `resolveProfile`. */
125
+ export function packOverrides(pack: DomainPack): Record<string, unknown> {
126
+ const overrides: Record<string, unknown> = {
127
+ review_checklist: [...pack.rubric.map(item => `${item.id}${item.required ? "" : " (optional)"}: ${item.text}`), "{check}"],
128
+ ...(pack.checks.length ? { example_checks: pack.checks.map(check => `${check.id}: ${check.note}`) } : {}),
129
+ };
130
+ resolveProfile(pack.profile, overrides);
131
+ return overrides;
132
+ }
@@ -2,6 +2,13 @@ import { accessSync, constants, lstatSync, opendirSync, readlinkSync, statSync }
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { realPath } from "./project.ts";
4
4
  import { foldSegment } from "./permission-mode.ts";
5
+ /**
6
+ * Whether `rel`, the result of `relative(root, path)`, names something inside the root: not `..`, not a path that starts with a `..` SEGMENT, and not absolute.
7
+ * A string prefix test is wrong here: `..ignored/f.txt` is a valid directory named `..ignored` inside the project (#552), while `../f.txt` leaves it.
8
+ * An empty `rel` is the root itself, which is inside; callers that need a file name check for it themselves.
9
+ */
10
+ export const insideRoot = (rel: string): boolean => rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
11
+
5
12
  /** Bounded project-relative exact paths/subtrees or *, ? and whole-segment ** globs. */
6
13
  export function policyPaths(raw: unknown, field: string): string[] {
7
14
  if (raw === undefined) return [];