@staix/agent-hub 0.21.0 → 0.23.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 (100) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +2 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -2
  6. package/docs/agent-notes/daemon.md +5 -4
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/models.md +2 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +2 -2
  11. package/docs/dogfood.md +10 -2
  12. package/docs/operations.md +192 -6
  13. package/docs/profiles.md +29 -4
  14. package/docs/quickstart.md +9 -1
  15. package/docs/security.md +7 -1
  16. package/docs/smoke.md +6 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +94 -1
  18. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  19. package/docs/verification/2026-10-30-0.23.0.md +70 -0
  20. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  21. package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
  22. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  23. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  24. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  25. package/docs/verification/2026-10-30-model-operational-settings.md +211 -0
  26. package/docs/verification/2026-10-30-operational-settings-closure.md +1217 -0
  27. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  28. package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
  29. package/docs/verified.json +15 -15
  30. package/package.json +1 -1
  31. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  32. package/plugins/agent-hub/ROLE-NOTICE.md +1 -1
  33. package/plugins/agent-hub/server.js +559 -115
  34. package/src/adapters/acp.ts +48 -2
  35. package/src/adapters/claude-channel.ts +6 -4
  36. package/src/adapters/codex-appserver.ts +19 -0
  37. package/src/adapters/jcode.ts +1 -1
  38. package/src/adapters/local-worker.ts +3 -3
  39. package/src/adapters/native-capabilities.ts +50 -0
  40. package/src/adapters/registry-types.ts +3 -10
  41. package/src/cli/capabilities-output.ts +30 -0
  42. package/src/cli/console-state.ts +8 -5
  43. package/src/cli/console.ts +27 -4
  44. package/src/cli/help.ts +3 -1
  45. package/src/cli/identity.ts +4 -1
  46. package/src/cli/learn-output.ts +38 -0
  47. package/src/cli/main.ts +28 -2
  48. package/src/cli/recovery-waits.ts +26 -0
  49. package/src/cli/smoke-pty-bridge.ts +67 -18
  50. package/src/cli/smoke-pty.ts +10 -2
  51. package/src/cli/upgrade-runtime.ts +16 -14
  52. package/src/cli/upgrade.ts +2 -1
  53. package/src/hub/board.ts +8 -2
  54. package/src/hub/bus.ts +117 -17
  55. package/src/hub/capabilities.ts +215 -0
  56. package/src/hub/config-apply.ts +8 -4
  57. package/src/hub/config-trust.ts +1 -0
  58. package/src/hub/control-client.ts +41 -8
  59. package/src/hub/daemon.ts +436 -33
  60. package/src/hub/deferred-hub.ts +2 -2
  61. package/src/hub/delivery-hints.ts +2 -2
  62. package/src/hub/delivery-journal.ts +80 -61
  63. package/src/hub/envelope.ts +12 -2
  64. package/src/hub/events.ts +2 -0
  65. package/src/hub/hub-tools.ts +16 -1
  66. package/src/hub/inference.ts +10 -5
  67. package/src/hub/invocations.ts +316 -0
  68. package/src/hub/learning.ts +33 -1
  69. package/src/hub/operational-settings.ts +180 -1
  70. package/src/hub/peers.ts +3 -0
  71. package/src/hub/restart.ts +2 -2
  72. package/src/hub/room-invocations.ts +145 -0
  73. package/src/hub/settings.ts +22 -2
  74. package/src/hub/shipped-packs.ts +5 -2
  75. package/src/hub/shipped-roles.ts +13 -0
  76. package/src/hub/tasks.ts +92 -1
  77. package/src/memory/capture.ts +2 -2
  78. package/src/models/relay.ts +20 -9
  79. package/src/rooms/application.ts +67 -2
  80. package/src/rooms/boundary.ts +43 -11
  81. package/src/rooms/service.ts +40 -2
  82. package/templates/checks/analysis.mjs +59 -0
  83. package/templates/checks/fixtures/analysis-expected.json +1 -0
  84. package/templates/checks/fixtures/analysis-input.json +1 -0
  85. package/templates/checks/fixtures/analysis.mjs +7 -0
  86. package/templates/checks/fixtures/ops.json +4 -0
  87. package/templates/checks/fixtures/writing-rules.json +4 -0
  88. package/templates/checks/fixtures/writing.json +5 -0
  89. package/templates/checks/ops.mjs +40 -0
  90. package/templates/checks/writing.mjs +40 -0
  91. package/templates/packs/analysis.pack.json +14 -0
  92. package/templates/packs/ops.pack.json +14 -0
  93. package/templates/packs/writing-pack.pack.json +14 -0
  94. package/templates/roles/NOTICE.md +1 -1
  95. package/templates/roles/ops/meeting-actions-owner.md +26 -0
  96. package/templates/roles/ops/project-manager.md +26 -0
  97. package/templates/roles/writing/editor.md +26 -0
  98. package/templates/roles/writing/style-checker.md +26 -0
  99. package/templates/roles/writing/terminology-checker.md +26 -0
  100. package/templates/roles/writing/translator.md +26 -0
@@ -4,7 +4,7 @@ import { HUB, DIGEST, MAX_HOP, USER, sanitize, type Envelope } from "../hub/enve
4
4
  import { mentionsPrivatePath } from "../hub/memory-policy.ts";
5
5
  import { detectSignals, type Routing } from "../hub/routing.ts";
6
6
  import type { PiiVerdict } from "../hub/inference.ts";
7
- import { encodeRoomMessage, isRoomLocalPeerId, parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
7
+ import { encodeRoomMessage, isRoomLocalPeerId, parseRemotePeerId, type RoomChatEnvelope, type RoomInvocationPayload, type RoomInvocationKind, encodeRoomInvocationBody, decodeRoomInvocationBody } from "./application.ts";
8
8
 
9
9
  export interface RoomBoundaryContext {
10
10
  cwd: string; target: string; current: () => boolean;
@@ -15,6 +15,8 @@ export interface RoomBoundaryContext {
15
15
  modelScreenOn: () => boolean;
16
16
  /** The existing host-bound screenPii port owns device/campus admission, deadlines and fixed models. */
17
17
  screen?: (text: string) => Promise<PiiVerdict>;
18
+ /** Host ledger provenance, never a serializable request/peer capability. */
19
+ invocation?: RoomInvocationAuthorization;
18
20
  }
19
21
  export type RoomBoundaryCode = "invalid_message" | "not_exposed" | "boundary_refused" | "hop";
20
22
  declare const proofBrand: unique symbol;
@@ -25,6 +27,25 @@ const ENVELOPE_FIELDS = new Set(["id", "trace", "hop", "from", "to", "kind", "pr
25
27
  const encoder = new TextEncoder();
26
28
  const refused = (code: RoomBoundaryCode): RoomBoundaryResult => ({ status: "refused", code });
27
29
 
30
+ declare const invocationBrand: unique symbol;
31
+ export interface RoomInvocationAuthorization { readonly [invocationBrand]: true }
32
+ interface AuthorizedInvocation { binding: string; sender: string; kind: RoomInvocationKind; body: string; current: () => boolean }
33
+ const invocations = new WeakMap<RoomInvocationAuthorization, AuthorizedInvocation>();
34
+ /** Host-only construction: the ledger owner supplies the actual caller/callee and exact request/result. */
35
+ export function authorizeRoomInvocation(envelope: Envelope, sender: string, payload: RoomInvocationPayload, current: () => boolean): RoomInvocationAuthorization {
36
+ if (envelope.from !== HUB || envelope.kind !== "invocation" && envelope.kind !== "invocation_result" || !isRoomLocalPeerId(sender) || sender === USER) throw new Error("room: invocation provenance unavailable");
37
+ const body = encodeRoomInvocationBody(envelope.kind, payload);
38
+ const token = Object.freeze({}) as RoomInvocationAuthorization;
39
+ invocations.set(token, { binding: JSON.stringify(envelope), sender, kind: envelope.kind, body, current });
40
+ return token;
41
+ }
42
+ function invocationOf(token: RoomInvocationAuthorization | undefined, envelope: Envelope): AuthorizedInvocation | undefined {
43
+ if (!token || typeof token !== "object") return undefined;
44
+ const bound = invocations.get(token);
45
+ try { return bound && bound.kind === envelope.kind && bound.binding === JSON.stringify(envelope) && bound.current() === true ? bound : undefined; }
46
+ catch { return undefined; }
47
+ }
48
+
28
49
  interface BodyPolicy { routing: Routing; paths: string[]; model: boolean; signature: string }
29
50
  function bodyPolicy(context: RoomBoundaryContext): BodyPolicy | undefined {
30
51
  try {
@@ -47,10 +68,12 @@ export async function roomBoundary(input: Envelope, ctx: RoomBoundaryContext): P
47
68
  const env = structuredClone(input), context: RoomBoundaryContext = { ...ctx };
48
69
  const target = parseRemotePeerId(context.target);
49
70
  if (!target || !env || typeof env !== "object" || Array.isArray(env) || Object.keys(env).some(key => !ENVELOPE_FIELDS.has(key))) return refused("invalid_message");
50
- if (env.from === HUB || env.from === DIGEST || !isRoomLocalPeerId(env.from)) return refused("boundary_refused");
51
- if (env.kind !== "chat" && env.kind !== "status" || env.private === true) return refused("boundary_refused");
71
+ const invocation = invocationOf(context.invocation, env), sender = invocation?.sender ?? env.from;
72
+ if (env.from === HUB && !invocation || env.from === DIGEST || !isRoomLocalPeerId(sender)) return refused("boundary_refused");
73
+ if (!invocation && env.kind !== "chat" && env.kind !== "status" || env.private === true) return refused("boundary_refused");
52
74
  if (env.private !== undefined && typeof env.private !== "boolean") return refused("invalid_message");
53
75
  if (!Array.isArray(env.to) || !env.to.includes(context.target)) return refused("boundary_refused");
76
+ if (invocation && (env.to.length !== 1 || env.to[0] !== context.target)) return refused("boundary_refused");
54
77
  if (env.to.length > 64 || env.to.some(id => !isRoomLocalPeerId(id) && !parseRemotePeerId(id))) return refused("invalid_message");
55
78
  if (!Number.isSafeInteger(env.hop) || env.hop < 0 || env.hop > MAX_HOP) return refused("hop");
56
79
  if (env.refs !== undefined && (!env.refs || typeof env.refs !== "object" || Array.isArray(env.refs))) return refused("invalid_message");
@@ -62,32 +85,41 @@ export async function roomBoundary(input: Envelope, ctx: RoomBoundaryContext): P
62
85
  const authorized = (): RoomBoundaryCode | undefined => {
63
86
  try {
64
87
  if (context.current() !== true) return "boundary_refused";
65
- if (env.from !== USER && context.senderExposed(env.from) !== true) return "not_exposed";
66
- if (context.senderPrivate(env.from) !== false || taskId !== undefined && context.taskPrivate(taskId) !== false) return "boundary_refused";
88
+ if (context.invocation && invocationOf(context.invocation, env) !== invocation) return "boundary_refused";
89
+ if (sender !== USER && context.senderExposed(sender) !== true) return "not_exposed";
90
+ if (context.senderPrivate(sender) !== false || taskId !== undefined && context.taskPrivate(taskId) !== false) return "boundary_refused";
67
91
  return context.current() === true ? undefined : "boundary_refused";
68
92
  } catch { return "boundary_refused"; }
69
93
  };
70
94
  const initial = authorized(); if (initial) return refused(initial);
71
- const envelope: RoomChatEnvelope = { id: env.id, trace: env.trace, hop: env.hop, from: env.from, to: target.peer, kind: env.kind, priority: env.priority === "important" ? "status" : env.priority, body: env.body, ts: env.ts };
95
+ const envelope: RoomChatEnvelope = { id: env.id, trace: env.trace, hop: env.hop, from: sender, to: target.peer, kind: env.kind as RoomChatEnvelope["kind"], priority: invocation ? "status" : env.priority === "important" ? "status" : env.priority, body: invocation?.body ?? env.body, ts: env.ts };
72
96
  encodeRoomMessage({ v: 1, type: "chat", envelope }); // Validate metadata/body budget before any model sees it.
73
97
  const policy = bodyPolicy(context);
74
- let withheld = !clearBody(env.body, paths, context, policy);
98
+ const body = envelope.body;
99
+ let withheld = !clearBody(body, paths, context, policy);
75
100
  if (!withheld && policy?.model) {
76
101
  const before = authorized(); if (before) return refused(before);
77
102
  let verdict: PiiVerdict | undefined;
78
- try { verdict = await context.screen?.(env.body); } catch { /* unknown fails closed to the fixed stub */ }
103
+ try { verdict = await context.screen?.(body); } catch { /* unknown fails closed to the fixed stub */ }
79
104
  const after = authorized(); if (after) return refused(after);
80
105
  withheld = verdict?.label !== "clear";
81
106
  const fresh = bodyPolicy(context);
82
107
  if (!fresh || fresh.signature !== policy.signature) return refused("boundary_refused");
83
- withheld ||= !clearBody(env.body, paths, context, fresh);
108
+ withheld ||= !clearBody(body, paths, context, fresh);
84
109
  }
85
110
  const final = authorized(); if (final) return refused(final);
86
- const outgoing = Object.freeze({ ...envelope, body: withheld ? `[room message ${envelope.id} withheld]` : sanitize(envelope.body) });
111
+ if (invocation?.kind === "invocation" && withheld) return refused("boundary_refused");
112
+ let outgoingBody = withheld ? `[room message ${envelope.id} withheld]` : sanitize(envelope.body);
113
+ if (invocation?.kind === "invocation_result") {
114
+ const result = decodeRoomInvocationBody("invocation_result", body);
115
+ const hiddenOutcome = result.outcome === "needs_review" || result.outcome === "timeout" ? "needs_review" : result.outcome === "refused" ? "refused" : "withheld";
116
+ outgoingBody = encodeRoomInvocationBody("invocation_result", withheld ? { ...result, outcome: hiddenOutcome, body: "" } : { ...result, body: sanitize(result.body) });
117
+ } else if (invocation) outgoingBody = body; // Canonical structured args, sanitized only when the target renders them.
118
+ const outgoing = Object.freeze({ ...envelope, body: outgoingBody });
87
119
  encodeRoomMessage({ v: 1, type: "chat", envelope: outgoing });
88
120
  const current = () => authorized() === undefined && (withheld || (() => {
89
121
  const latest = bodyPolicy(context);
90
- return latest !== undefined && latest.signature === policy?.signature && clearBody(env.body, paths, context, latest);
122
+ return latest !== undefined && latest.signature === policy?.signature && clearBody(body, paths, context, latest);
91
123
  })());
92
124
  const proof = Object.freeze({}) as RoomOutboundProof;
93
125
  proofs.set(proof, { envelope: outgoing, current });
@@ -1,6 +1,8 @@
1
1
  /** Host integration for X4. Remote identities carry chat, never local control authority. */
2
2
  import type { Bus } from "../hub/bus.ts";
3
3
  import { USER, MAX_HOP, type Envelope, type PeerState } from "../hub/envelope.ts";
4
+ import type { RoomInvocationAuthorization } from "./boundary.ts";
5
+ import { roomInvocationHash } from "../hub/room-invocations.ts";
4
6
  import { Limiter, PROJECT_LIMITS, type LimitsConfig } from "../hub/limits.ts";
5
7
  import type { PiiVerdict } from "../hub/inference.ts";
6
8
  import type { Routing } from "../hub/routing.ts";
@@ -8,9 +10,14 @@ import { roomMembers } from "./wire.ts";
8
10
  import { RoomStore, RoomStoreError, RoomApplicationLimitError, type RoomOutgoing } from "./store.ts";
9
11
  import { RoomPolicy } from "./policy.ts";
10
12
  import { RemotePeer } from "./remote-peer.ts";
11
- import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason } from "./application.ts";
13
+ import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope } from "./application.ts";
12
14
  import { RoomApplicationDeferredError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
13
15
 
16
+ export interface RoomInvocationIdentity { room: string; member: string; epoch: string; current: () => boolean }
17
+ export interface RoomInvocationIngress {
18
+ envelope: RoomChatEnvelope; room: string; member: string; epoch: string; remotePeer: string; hash: string;
19
+ current: () => boolean; claim: () => boolean;
20
+ }
14
21
  export interface RoomChatOptions {
15
22
  bus: Bus; cwd: string; now?: () => number;
16
23
  routing: () => Routing; piiPaths: () => readonly string[];
@@ -18,6 +25,8 @@ export interface RoomChatOptions {
18
25
  taskPrivate: (id: number) => boolean | undefined;
19
26
  modelScreenOn: () => boolean; screen: (text: string) => Promise<PiiVerdict>;
20
27
  policyVersion: () => string | number;
28
+ invocationAuthorization?: (env: Envelope, identity: RoomInvocationIdentity) => RoomInvocationAuthorization | undefined;
29
+ invocation?: (input: RoomInvocationIngress) => Promise<"admitted" | "refused">;
21
30
  roster?: (ids: readonly string[]) => void;
22
31
  changed?: () => void;
23
32
  limits?: () => LimitsConfig;
@@ -109,6 +118,15 @@ export class RoomChat {
109
118
  this.bindings.set(name, binding);
110
119
  this.refreshTargets(); this.refreshOutgoing(binding); this.queueChanged();
111
120
  }
121
+ invocationIdentity(id: string, sender?: string): RoomInvocationIdentity | undefined {
122
+ const target = this.targets.get(id), state = target?.binding.store.get(target.binding.name);
123
+ if (!target || !state || !this.valid(target.binding) || sender !== undefined && !this.exposed(target.binding, sender)) return undefined;
124
+ const epoch = state.current.digest, room = state.current.room, member = target.member;
125
+ return { room, member, epoch, current: () => {
126
+ const next = this.targets.get(id), current = target.binding.store.get(target.binding.name);
127
+ return (sender === undefined || this.exposed(target.binding, sender)) && this.valid(target.binding) && next?.binding === target.binding && next.member === member && current?.current.room === room && current.current.digest === epoch;
128
+ } };
129
+ }
112
130
  private refreshTargets(): void {
113
131
  const candidates = new Map<string, Target[]>();
114
132
  for (const binding of this.bindings.values()) {
@@ -146,7 +164,9 @@ export class RoomChat {
146
164
  const digest = state?.current.digest;
147
165
  const current = () => this.valid(binding) && this.targets.get(id)?.binding === binding && this.targets.get(id)?.member === target.member && this.options.policyVersion() === version
148
166
  && binding.store.get(binding.name)?.current.digest === digest;
149
- return { cwd: this.options.cwd, target: id, current,
167
+ const identity = this.invocationIdentity(id);
168
+ const invocation = identity ? this.options.invocationAuthorization?.(env, identity) : undefined;
169
+ return { cwd: this.options.cwd, target: id, current, ...(invocation ? { invocation } : {}),
150
170
  senderExposed: peer => this.exposed(binding, peer), senderPrivate: this.options.senderPrivate,
151
171
  taskPrivate: this.options.taskPrivate, routing: this.options.routing, piiPaths: this.options.piiPaths,
152
172
  modelScreenOn: this.options.modelScreenOn, screen: this.options.screen };
@@ -299,6 +319,24 @@ export class RoomChat {
299
319
  else if (chat.to !== USER && this.options.bus.hushed(env).some(row => row.peer === chat.to)) refusal = "boundary_refused";
300
320
  else if (!this.rate(from, incoming.sender, chat.to, chat.body)) refusal = "rate_limited";
301
321
  if (refusal) context.store.finishApplication(context.name, input.digest, "failed_safe", refusal, context.current);
322
+ else if (chat.kind === "invocation" || chat.kind === "invocation_result") {
323
+ if (!this.options.invocation) context.store.finishApplication(context.name, input.digest, "failed_safe", "boundary_refused", context.current);
324
+ else {
325
+ const epoch = state.current.digest;
326
+ const current = () => context.current() && this.valid(binding) && binding.store.get(binding.name)?.current.digest === epoch
327
+ && this.sourceUnambiguous(binding, incoming.sender, member!.label) && member!.exposedPeers.includes(chat.from) && this.exposed(binding, chat.to)
328
+ && !this.options.bus.hushed(env).some(row => row.peer === chat.to);
329
+ try {
330
+ const admitted = await this.options.invocation({ envelope: chat, room: state.current.room, member: incoming.sender, epoch, remotePeer: from,
331
+ hash: roomInvocationHash(chat), current, claim: () => current() && context.store.claimApplication(context.name, input.digest, current) });
332
+ if (!current()) throw new Error("room: state_changed");
333
+ context.store.finishApplication(context.name, input.digest, admitted === "admitted" ? "completed" : "failed_safe", admitted === "admitted" ? "admitted" : "boundary_refused", current);
334
+ if (admitted === "admitted") this.count(binding, "inbound");
335
+ } catch (error) {
336
+ context.store.quarantineApplication(context.name, input.digest); this.capacityChanged(); this.options.changed?.(); throw error;
337
+ }
338
+ }
339
+ }
302
340
  else if (chat.to === USER) {
303
341
  if (!context.current() || !this.valid(binding)) throw new Error("room: state_changed");
304
342
  const admission = context.store.admitPersonInbox(context.name, input.digest, context.current);
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // Runs only the explicitly selected program. No shell and no automatic invocation.
3
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+
7
+ const MAX_BYTES = 1024 * 1024, TIMEOUT_MS = 5000;
8
+ function boundedFile(file) {
9
+ const before = lstatSync(file);
10
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_BYTES) throw new Error("input refused");
11
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
12
+ try {
13
+ const opened = fstatSync(fd);
14
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > MAX_BYTES) throw new Error("input changed");
15
+ const bytes = Buffer.alloc(opened.size + 1); let size = 0;
16
+ while (size < bytes.length) { const count = readSync(fd, bytes, size, bytes.length - size, size); if (!count) break; size += count; }
17
+ const after = fstatSync(fd);
18
+ if (size !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) throw new Error("input changed");
19
+ return bytes.subarray(0, size);
20
+ } finally { closeSync(fd); }
21
+ }
22
+ function run(program, input) {
23
+ return new Promise(resolveRun => {
24
+ const group = process.platform !== "win32";
25
+ const child = spawn(process.execPath, [program, input], { shell: false, detached: group, stdio: ["ignore", "pipe", "ignore"] });
26
+ const chunks = []; let size = 0, refused = false, settled = false;
27
+ const kill = () => { refused = true; try { if (group && child.pid) process.kill(-child.pid, "SIGKILL"); else child.kill("SIGKILL"); } catch { /* Already gone. */ } };
28
+ const timer = setTimeout(kill, TIMEOUT_MS);
29
+ const finish = (code) => {
30
+ if (settled) return; settled = true; clearTimeout(timer);
31
+ // A program may have started descendants; release only this invocation's process group.
32
+ if (group && child.pid) try { process.kill(-child.pid, "SIGKILL"); } catch { /* Already gone. */ }
33
+ resolveRun(!refused && code === 0 ? Buffer.concat(chunks, size) : undefined);
34
+ };
35
+ child.on("error", () => { kill(); finish(null); });
36
+ child.stdout.on("data", bytes => {
37
+ if (refused) return;
38
+ if (bytes.length > MAX_BYTES - size) { chunks.length = 0; kill(); return; }
39
+ size += bytes.length; chunks.push(Buffer.from(bytes));
40
+ });
41
+ child.on("close", finish);
42
+ });
43
+ }
44
+ if (process.argv.length !== 5) {
45
+ console.error("usage: analysis.mjs <program.mjs> <input.json> <expected-output>"); console.log("analysis: FAIL"); process.exitCode = 2;
46
+ } else {
47
+ let ok = false;
48
+ try {
49
+ const program = resolve(process.argv[2]), input = resolve(process.argv[3]), expectedFile = resolve(process.argv[4]);
50
+ const programBytes = boundedFile(program), inputBytes = boundedFile(input), expected = boundedFile(expectedFile);
51
+ JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(inputBytes));
52
+ const unchanged = () => boundedFile(program).equals(programBytes) && boundedFile(input).equals(inputBytes) && boundedFile(expectedFile).equals(expected);
53
+ const first = await run(program, input);
54
+ if (first === undefined || !unchanged()) throw new Error("first run refused or declared files changed");
55
+ const second = await run(program, input);
56
+ ok = unchanged() && first !== undefined && second !== undefined && first.equals(expected) && second.equals(expected);
57
+ } catch { /* Refuse without echoing program stderr, private paths, inputs or expected bytes. */ }
58
+ console.log(`analysis-reproduce: ${ok ? "OK" : "FAIL"}`); console.log(`analysis: ${ok ? "OK" : "FAIL"}`); process.exitCode = ok ? 0 : 1;
59
+ }
@@ -0,0 +1 @@
1
+ {"count":4,"sum":20}
@@ -0,0 +1 @@
1
+ [2, 4, 6, 8]
@@ -0,0 +1,7 @@
1
+ // Explicit fixture program: node analysis.mjs <input.json>.
2
+ import { readFileSync } from "node:fs";
3
+ const values = JSON.parse(readFileSync(process.argv[2], "utf8"));
4
+ if (!Array.isArray(values) || !values.every(value => typeof value === "number" && Number.isFinite(value))) process.exit(1);
5
+ const sum = values.reduce((total, value) => total + value, 0);
6
+ if (!Number.isFinite(sum)) process.exit(1);
7
+ process.stdout.write(JSON.stringify({ count: values.length, sum }) + "\n");
@@ -0,0 +1,4 @@
1
+ [
2
+ { "id": "prepare-review", "owner": "project-owner", "due": "2026-11-10", "status": "in_progress" },
3
+ { "id": "confirm-evidence", "owner": "reviewer", "due": "2026-11-12", "status": "open" }
4
+ ]
@@ -0,0 +1,4 @@
1
+ {
2
+ "requiredTerms": ["evidence", "repeatable check"],
3
+ "bannedPhrases": ["guaranteed success", "unlimited capacity"]
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "title": "Evidence review",
3
+ "language": "en",
4
+ "text": "The pilot records evidence from a repeatable check. The next review confirms the measured result."
5
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // Structure only. An empty array does not establish that all required actions were recorded.
3
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from "node:fs";
4
+
5
+ const MAX_BYTES = 1024 * 1024;
6
+ function actions(file) {
7
+ const before = lstatSync(file);
8
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_BYTES) throw new Error("input refused");
9
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
10
+ try {
11
+ const opened = fstatSync(fd);
12
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > MAX_BYTES) throw new Error("input changed");
13
+ const bytes = Buffer.alloc(opened.size + 1); let size = 0;
14
+ while (size < bytes.length) { const count = readSync(fd, bytes, size, bytes.length - size, size); if (!count) break; size += count; }
15
+ const after = fstatSync(fd);
16
+ if (size !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) throw new Error("input changed");
17
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, size)));
18
+ } finally { closeSync(fd); }
19
+ }
20
+ function date(value) {
21
+ if (typeof value !== "string" || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(value)) return false;
22
+ const parsed = new Date(`${value}T00:00:00.000Z`);
23
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
24
+ }
25
+ function valid(value) {
26
+ if (!Array.isArray(value)) return false;
27
+ const seen = new Set(), keys = ["id", "owner", "due", "status"], statuses = new Set(["open", "in_progress", "blocked", "done"]);
28
+ return value.every(action => {
29
+ if (!action || typeof action !== "object" || Array.isArray(action) || Object.keys(action).length !== keys.length || !keys.every(key => Object.hasOwn(action, key))) return false;
30
+ if (typeof action.id !== "string" || !action.id.trim() || seen.has(action.id) || typeof action.owner !== "string" || !action.owner.trim() || !date(action.due) || !statuses.has(action.status)) return false;
31
+ seen.add(action.id); return true;
32
+ });
33
+ }
34
+ if (process.argv.length !== 3) {
35
+ console.error("usage: ops.mjs <actions.json>"); console.log("ops: FAIL"); process.exitCode = 2;
36
+ } else {
37
+ let ok = false;
38
+ try { ok = valid(actions(process.argv[2])); } catch { /* Never echo action content, identifiers or paths. */ }
39
+ console.log(`ops-records: ${ok ? "OK" : "FAIL"}`); console.log(`ops: ${ok ? "OK" : "FAIL"}`); process.exitCode = ok ? 0 : 1;
40
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // Explicit invocation only: node writing.mjs <document.json> <rules.json>.
3
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from "node:fs";
4
+
5
+ const MAX_BYTES = 1024 * 1024;
6
+ function readJSON(file) {
7
+ const before = lstatSync(file);
8
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_BYTES) throw new Error("input refused");
9
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
10
+ try {
11
+ const opened = fstatSync(fd);
12
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > MAX_BYTES) throw new Error("input changed");
13
+ const bytes = Buffer.alloc(opened.size + 1); let size = 0;
14
+ while (size < bytes.length) { const count = readSync(fd, bytes, size, bytes.length - size, size); if (!count) break; size += count; }
15
+ const after = fstatSync(fd);
16
+ if (size !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) throw new Error("input changed");
17
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, size)));
18
+ } finally { closeSync(fd); }
19
+ }
20
+ const object = value => !!value && typeof value === "object" && !Array.isArray(value);
21
+ const nonempty = value => typeof value === "string" && value.trim().length > 0;
22
+ const terms = value => Array.isArray(value) && value.every(nonempty);
23
+ function report(results) {
24
+ for (const [id, ok] of results) console.log(`${id}: ${ok ? "OK" : "FAIL"}`);
25
+ const ok = results.every(([, passed]) => passed);
26
+ console.log(`writing: ${ok ? "OK" : "FAIL"}`); process.exitCode = ok ? 0 : 1;
27
+ }
28
+ if (process.argv.length !== 4) {
29
+ console.error("usage: writing.mjs <document.json> <rules.json>"); console.log("writing: FAIL"); process.exitCode = 2;
30
+ } else {
31
+ let document, rules;
32
+ try { document = readJSON(process.argv[2]); } catch { /* Refuse without echoing document bytes or paths. */ }
33
+ try { rules = readJSON(process.argv[3]); } catch { /* Rule values never become diagnostics or regular expressions. */ }
34
+ const metadata = object(document) && [document.title, document.language, document.text].every(nonempty);
35
+ const body = metadata ? `${document.title}\n${document.text}` : "";
36
+ const literals = metadata && object(rules) && terms(rules.requiredTerms) && terms(rules.bannedPhrases)
37
+ && rules.requiredTerms.every(term => document.text.includes(term)) && rules.bannedPhrases.every(term => !body.includes(term));
38
+ const style = metadata && !/\b(?:TODO|TBD)\b/i.test(body) && body.split(/\r\n|\r|\n/).every(line => line === line.trimEnd());
39
+ report([["writing-metadata", metadata], ["writing-terms", literals], ["writing-style", style]]);
40
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "analysis",
3
+ "profile": "research",
4
+ "summary": "Reproducible analysis of declared inputs, with separate person review of provenance and method.",
5
+ "roles": ["analyst"],
6
+ "rubric": [
7
+ { "id": "analysis-reproduce", "text": "The local reproduction check compares the declared program and input against the expected output and records its observed result.", "required": true },
8
+ { "id": "analysis-inputs", "text": "Person review confirms input provenance, selection and limits, with unknowns and missing data stated explicitly.", "required": true },
9
+ { "id": "analysis-method", "text": "Person review assesses method suitability, assumptions and interpretation; output equality alone does not establish validity.", "required": true }
10
+ ],
11
+ "checks": [
12
+ { "id": "analysis-reproduce", "note": "Manual, advisory example only: bun templates/checks/analysis.mjs <program.mjs> <input.json> <expected-output>. A match proves reproduction, not method validity." }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "ops",
3
+ "profile": "general",
4
+ "summary": "Traceable project and meeting action records with explicit ownership, status and human follow-up review.",
5
+ "roles": ["project-manager", "meeting-actions-owner"],
6
+ "rubric": [
7
+ { "id": "ops-records", "text": "Action records pass the local schema, identity, ownership and status checks, with observed results retained.", "required": true },
8
+ { "id": "ops-coverage", "text": "Person review checks coverage against the source meeting or project record and identifies missing or duplicated actions.", "required": true },
9
+ { "id": "ops-followup", "text": "Person review confirms assignments and follow-up dates were agreed, or marks them proposed or unknown; completion requires evidence.", "required": true }
10
+ ],
11
+ "checks": [
12
+ { "id": "ops-records", "note": "Manual, advisory example only: bun templates/checks/ops.mjs <actions.json>. A schema pass does not prove meeting coverage or agreement." }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "writing-pack",
3
+ "profile": "writing",
4
+ "summary": "Scoped editing and translation with explicit metadata, terminology and style evidence.",
5
+ "roles": ["editor", "translator", "style-checker", "terminology-checker"],
6
+ "rubric": [
7
+ { "id": "writing-metadata", "text": "Required document metadata is present and agrees with the supplied rules.", "required": true },
8
+ { "id": "writing-terms", "text": "The document passes the supplied terminology rules; unresolved usage decisions are identified for person review.", "required": true },
9
+ { "id": "writing-style", "text": "The document passes the supplied mechanical style rules; readability and audience fit have a separate reviewer judgment.", "required": true }
10
+ ],
11
+ "checks": [
12
+ { "id": "writing-check", "note": "Manual, advisory example only: bun templates/checks/writing.mjs <document.json> <rules.json>. Output is evidence, not publication approval." }
13
+ ]
14
+ }
@@ -1,6 +1,6 @@
1
1
  # Role catalog attribution
2
2
 
3
- Selected role guidance is adapted from msitarzewski/agency-agents at f99f6aa910a442b0197b768ce0ea7751e35e2060. Each adapted definition names its upstream path. The other three research definitions are original agent-hub guidance. Upstream persona, code samples and autonomy claims are excluded.
3
+ Selected role guidance is adapted from msitarzewski/agency-agents at f99f6aa910a442b0197b768ce0ea7751e35e2060. Each adapted definition names its upstream path. The other three research definitions and the writing and ops definitions are original agent-hub guidance. Upstream persona, code samples and autonomy claims are excluded.
4
4
 
5
5
  MIT License
6
6
 
@@ -0,0 +1,26 @@
1
+ ---
2
+ id: meeting-actions-owner
3
+ name: Meeting actions owner
4
+ group: ops
5
+ summary: Extract and maintain traceable action records from a bounded meeting or project record.
6
+ base: implementer
7
+ classes: implement,summarize
8
+ source: original agent-hub guidance
9
+ ---
10
+ ## Scope
11
+ Create action records from the supplied meeting or project sources. Keep decisions, proposals, assignments, deadlines and unknowns separate, with stable action IDs and source references.
12
+
13
+ ## Not in scope
14
+ Do not infer attendance, acceptance, completion or dates from silence. External follow-up, calendar changes and commitments by others require explicit authorization.
15
+
16
+ ## Deliverables
17
+ Action records with the required fields, source locations, stated owner and date status, plus unresolved questions and a concise change account.
18
+
19
+ ## Hub use
20
+ Use the existing task flow for the assigned base role. Name source and output paths in the plan; report missing records and proposed follow-up before treating it as agreed work.
21
+
22
+ ## Review duties
23
+ Check coverage against the source, duplicate actions, ownership and status consistency. Verify that a completion claim has an actual receipt rather than a planned date.
24
+
25
+ ## Evidence
26
+ Meeting excerpts or decision references and the observed record-check result. Person review establishes source coverage and confirms proposed assignments or follow-up dates.
@@ -0,0 +1,26 @@
1
+ ---
2
+ id: project-manager
3
+ name: Project manager
4
+ group: ops
5
+ summary: Define a bounded delivery plan with explicit ownership, dependencies and acceptance evidence.
6
+ base: planner
7
+ classes: plan
8
+ source: original agent-hub guidance
9
+ ---
10
+ ## Scope
11
+ Turn the authorized objective into scoped tasks, dependencies, proposed owners, milestones and acceptance evidence. Distinguish recorded commitments from planning assumptions.
12
+
13
+ ## Not in scope
14
+ Do not invent progress, assign authority through prose or commit other people to dates. Budget, staffing, external messages and release decisions remain with their authorized actors.
15
+
16
+ ## Deliverables
17
+ Reviewable task plan, dependency and risk notes, owner and date proposals, acceptance criteria, and decisions or missing inputs requiring a person.
18
+
19
+ ## Hub use
20
+ Use the existing planning flow and task board. Reference the source request and affected paths; surface overlap, blockers and scope changes through the established coordination tools.
21
+
22
+ ## Review duties
23
+ Check that each proposed outcome has an owner, a feasible dependency order and observable completion evidence. Keep unknown capacity and unaccepted dates explicit.
24
+
25
+ ## Evidence
26
+ Source requests, recorded decisions, task references and verified progress receipts. A plan or inventory count is not evidence of completed work or available capacity.
@@ -0,0 +1,26 @@
1
+ ---
2
+ id: editor
3
+ name: Editor
4
+ group: writing
5
+ summary: Edit a scoped document for structure, clarity and fidelity to its source material.
6
+ base: implementer
7
+ classes: implement,bulk_edit
8
+ source: original agent-hub guidance
9
+ ---
10
+ ## Scope
11
+ Edit the assigned document against its audience, purpose, source material and declared style rules. Keep supported meaning and attribution intact.
12
+
13
+ ## Not in scope
14
+ Do not invent facts, quotes or approvals. Publication, external submission and changes to project policy require their own authorized actors.
15
+
16
+ ## Deliverables
17
+ Revised document, a concise account of substantive changes, unresolved questions and references to the exact inputs and rules used.
18
+
19
+ ## Hub use
20
+ Use the existing task flow for the assigned base role. Identify the document paths and proposed edits in the plan; report blockers and changed scope before extending the work.
21
+
22
+ ## Review duties
23
+ Check organization, unsupported claims, ambiguous wording and consistency. Separate a mechanical check result from a person's editorial judgment.
24
+
25
+ ## Evidence
26
+ Versioned source and document references, recorded rule checks, and a clear list of unverified claims or editorial decisions requiring review.
@@ -0,0 +1,26 @@
1
+ ---
2
+ id: style-checker
3
+ name: Style checker
4
+ group: writing
5
+ summary: Check a document against supplied style rules and separate mechanical findings from editorial judgment.
6
+ base: verifier,reviewer
7
+ classes: test,review
8
+ source: original agent-hub guidance
9
+ ---
10
+ ## Scope
11
+ Check the assigned document against the exact supplied style rules. Distinguish deterministic constraints from readability or tone judgments that need human review.
12
+
13
+ ## Not in scope
14
+ Do not invent a style policy, rewrite the whole document or treat a mechanical pass as approval to publish. Verification grants no additional permissions.
15
+
16
+ ## Deliverables
17
+ Findings with rule IDs, document locations, observed text and suggested corrections. Record passed, failed, unrun and human-review items separately.
18
+
19
+ ## Hub use
20
+ Use the existing task or review flow for the assigned base role. Cite the document and rule versions, and bind each finding to a reproducible observation.
21
+
22
+ ## Review duties
23
+ Check declared formatting, consistency and language constraints without changing their thresholds. Identify conflicts or ambiguous rules instead of silently choosing a new standard.
24
+
25
+ ## Evidence
26
+ Observed local check results and source locations support mechanical findings. Readability, audience fit and tone require a stated reviewer judgment.
@@ -0,0 +1,26 @@
1
+ ---
2
+ id: terminology-checker
3
+ name: Terminology checker
4
+ group: writing
5
+ summary: Verify declared terms, names and translations against a versioned glossary.
6
+ base: verifier,reviewer
7
+ classes: test,review
8
+ source: original agent-hub guidance
9
+ ---
10
+ ## Scope
11
+ Check the assigned document for the supplied preferred, restricted and language-specific terms. Keep exact glossary rules distinct from context-dependent usage judgments.
12
+
13
+ ## Not in scope
14
+ Do not create institutional names, expand unknown acronyms by guesswork or change the authoritative glossary. A literal match does not prove factual or translation correctness.
15
+
16
+ ## Deliverables
17
+ Term findings with rule IDs, source locations and glossary references; explicit ambiguity and exception requests where context prevents a firm verdict.
18
+
19
+ ## Hub use
20
+ Use the existing task or review flow for the assigned base role. Identify document and glossary paths and versions; report a missing or conflicting glossary before treating terms as verified.
21
+
22
+ ## Review duties
23
+ Check proper nouns, abbreviations and consistent translations. Preserve quoted source text and distinguish a declared exception from an unnoticed mismatch.
24
+
25
+ ## Evidence
26
+ Versioned glossary entries, exact document occurrences and observed checker output. Domain usage and proposed exceptions need a recorded person review.
@@ -0,0 +1,26 @@
1
+ ---
2
+ id: translator
3
+ name: Translator
4
+ group: writing
5
+ summary: Translate a bounded document while preserving meaning, attribution and declared terminology.
6
+ base: implementer
7
+ classes: implement,bulk_edit
8
+ source: original agent-hub guidance
9
+ ---
10
+ ## Scope
11
+ Translate the assigned source into the requested language and register. Preserve names, quantities, qualifiers, citations and the distinction between completed and proposed activity.
12
+
13
+ ## Not in scope
14
+ Do not supply missing facts, certify a translation or imply publication approval. Leave legal or specialist interpretation and external delivery to their authorized actors.
15
+
16
+ ## Deliverables
17
+ Translated document, source-to-output references, terminology decisions and unresolved ambiguities with their source locations.
18
+
19
+ ## Hub use
20
+ Use the existing task flow for the assigned base role. Name source, output and glossary paths in the plan and completion summary; report unavailable inputs explicitly.
21
+
22
+ ## Review duties
23
+ Compare meaning and coverage against the source. Check omissions, changed certainty, numbers and proper nouns; identify wording that needs a fluent or domain reviewer.
24
+
25
+ ## Evidence
26
+ Exact source and glossary versions, check results and examples of reviewed ambiguities. A terminology match alone does not establish translation fidelity.