@staix/agent-hub 0.12.23 → 0.12.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/README.md +8 -3
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +10 -1
  11. package/docs/agent-notes/bus.md +3 -1
  12. package/docs/agent-notes/daemon.md +7 -1
  13. package/docs/agent-notes/local-worker.md +4 -2
  14. package/docs/agent-notes/tasks.md +8 -0
  15. package/docs/agent-notes/tests.md +5 -1
  16. package/docs/bench.md +28 -3
  17. package/docs/cooperbench.md +4 -0
  18. package/docs/data-policy.md +36 -0
  19. package/docs/events.md +3 -0
  20. package/docs/gsd-collaboration-analysis.md +48 -0
  21. package/docs/memory.md +231 -0
  22. package/docs/operations.md +666 -19
  23. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  24. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  25. package/docs/profiles.md +57 -0
  26. package/docs/quickstart.md +2 -2
  27. package/docs/research.md +31 -1
  28. package/docs/roles.md +85 -0
  29. package/docs/sdlc.md +170 -0
  30. package/docs/security.md +111 -4
  31. package/docs/smoke.md +288 -0
  32. package/docs/specs/2026-09-19-agent-hub-design.md +633 -7
  33. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  34. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  35. package/docs/text-projection.md +19 -0
  36. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  37. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  38. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  39. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  40. package/docs/verification/2026-10-13-board-phone.md +31 -0
  41. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  42. package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
  43. package/docs/verification/2026-10-14-0.12.25.md +27 -0
  44. package/docs/verified.json +22 -13
  45. package/package.json +2 -1
  46. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  47. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  48. package/plugins/agent-hub/server.js +1175 -339
  49. package/scripts/check-docs.mjs +129 -0
  50. package/src/adapters/acp.ts +112 -13
  51. package/src/adapters/claude-channel.ts +48 -11
  52. package/src/adapters/codex-appserver.ts +11 -0
  53. package/src/adapters/jcode.ts +110 -0
  54. package/src/adapters/local-worker.ts +10 -7
  55. package/src/adapters/pi.ts +171 -23
  56. package/src/cli/attention.ts +60 -0
  57. package/src/cli/bench.ts +24 -5
  58. package/src/cli/board-read.ts +12 -0
  59. package/src/cli/console-state.ts +103 -17
  60. package/src/cli/console.ts +54 -8
  61. package/src/cli/facts-hook.ts +12 -4
  62. package/src/cli/help.ts +35 -9
  63. package/src/cli/identity.ts +18 -7
  64. package/src/cli/init.ts +48 -10
  65. package/src/cli/jcode-mcp.ts +58 -0
  66. package/src/cli/kimi-mcp.ts +53 -0
  67. package/src/cli/launch.ts +16 -1
  68. package/src/cli/main.ts +392 -57
  69. package/src/cli/memory.ts +193 -0
  70. package/src/cli/output.ts +45 -4
  71. package/src/cli/pet-config.ts +1 -0
  72. package/src/cli/pet.ts +83 -0
  73. package/src/cli/preview.ts +38 -5
  74. package/src/cli/status-lines.ts +1 -1
  75. package/src/cli/tail-render.ts +1 -0
  76. package/src/cli/upgrade-runtime.ts +32 -3
  77. package/src/cli/watch.ts +31 -0
  78. package/src/hub/activity.ts +53 -0
  79. package/src/hub/ask.ts +10 -5
  80. package/src/hub/bench.ts +10 -3
  81. package/src/hub/board.ts +222 -16
  82. package/src/hub/bus.ts +48 -9
  83. package/src/hub/citations.ts +7 -0
  84. package/src/hub/conductor.ts +12 -7
  85. package/src/hub/config-trust.ts +4 -2
  86. package/src/hub/crash.ts +9 -0
  87. package/src/hub/daemon.ts +914 -120
  88. package/src/hub/delivery-journal.ts +5 -0
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +12 -2
  91. package/src/hub/facts.ts +26 -7
  92. package/src/hub/git.ts +8 -2
  93. package/src/hub/github-import.ts +28 -0
  94. package/src/hub/hub-tools.ts +49 -10
  95. package/src/hub/inference.ts +1 -1
  96. package/src/hub/lifecycle-schema.ts +17 -0
  97. package/src/hub/listen.ts +15 -0
  98. package/src/hub/manager.ts +12 -5
  99. package/src/hub/path-policy.ts +115 -0
  100. package/src/hub/peer-catalog.ts +28 -0
  101. package/src/hub/permission-mode.ts +19 -5
  102. package/src/hub/planning.ts +70 -0
  103. package/src/hub/profiles.ts +74 -0
  104. package/src/hub/projected-diff.ts +73 -0
  105. package/src/hub/projection-exec.ts +24 -0
  106. package/src/hub/research.ts +144 -21
  107. package/src/hub/roles.ts +123 -0
  108. package/src/hub/roster.ts +23 -0
  109. package/src/hub/routing.ts +18 -5
  110. package/src/hub/sdlc-queue.ts +112 -0
  111. package/src/hub/sdlc-release.ts +125 -0
  112. package/src/hub/sdlc.ts +250 -0
  113. package/src/hub/settings.ts +34 -15
  114. package/src/hub/shipped-roles.ts +25 -0
  115. package/src/hub/snapshots.ts +89 -15
  116. package/src/hub/start-mode.ts +7 -5
  117. package/src/hub/supervision.ts +1 -0
  118. package/src/hub/task-sweep.ts +1 -1
  119. package/src/hub/tasks.ts +905 -89
  120. package/src/hub/text-projection.ts +151 -0
  121. package/src/hub/tooling.ts +170 -0
  122. package/src/hub/ui.ts +16 -2
  123. package/src/hub/workflow.ts +71 -0
  124. package/src/local/sandbox.ts +6 -4
  125. package/src/local/tools.ts +11 -6
  126. package/src/memory/brief.ts +28 -14
  127. package/src/memory/import.ts +55 -0
  128. package/src/memory/recall.ts +84 -0
  129. package/src/memory/store.ts +757 -0
  130. package/src/memory/sync.ts +145 -0
  131. package/src/pet/NOTICE.md +10 -0
  132. package/src/pet/characters.ts +1438 -0
  133. package/src/pet/config.ts +31 -0
  134. package/src/pet/frame.ts +17 -0
  135. package/src/pet/pixels.ts +28 -0
  136. package/src/pi/extension.ts +40 -5
  137. package/src/role-text.d.ts +1 -0
  138. package/src/ui/index.html +843 -46
  139. package/src/ui/needs.ts +58 -0
  140. package/src/ui/office-model.ts +440 -0
  141. package/src/ui/peer-palette.ts +16 -0
  142. package/src/ui/task-board.ts +49 -0
  143. package/src/ui/task-progress.ts +5 -1
  144. package/templates/AGENTS.block.md +3 -2
  145. package/templates/config.json +5 -1
  146. package/templates/profiles/dev.json +22 -0
  147. package/templates/profiles/general.json +26 -0
  148. package/templates/profiles/research.json +57 -0
  149. package/templates/profiles/writing.json +28 -0
  150. package/templates/roles/NOTICE.md +26 -0
  151. package/templates/roles/dev/backend-engineer.md +26 -0
  152. package/templates/roles/dev/code-reviewer.md +26 -0
  153. package/templates/roles/dev/frontend-engineer.md +26 -0
  154. package/templates/roles/dev/security-reviewer.md +26 -0
  155. package/templates/roles/dev/software-architect.md +26 -0
  156. package/templates/roles/dev/test-engineer.md +26 -0
  157. package/templates/roles/research/analyst.md +26 -0
  158. package/templates/roles/research/fact-checker.md +26 -0
  159. package/templates/roles/research/research-lead.md +26 -0
  160. package/templates/roles/research/researcher.md +26 -0
  161. package/templates/roles/research/statistician.md +26 -0
package/src/hub/bus.ts CHANGED
@@ -10,9 +10,12 @@ export type BusEvent =
10
10
  | { t: "undeliverable"; env: Envelope; peer: PeerId; reason?: string }
11
11
  | { t: "stale"; env: Envelope; peer: PeerId; reason: string }
12
12
  | { t: "quiet"; env: Envelope; peers: PeerId[]; reason: string }
13
+ | { t: "off_roster"; env: Envelope; peers: PeerId[] }
13
14
  | { t: "state"; peer: PeerId; state: PeerState };
14
15
 
15
16
  export interface BusOptions {
17
+ /** Live project membership; absent preserves the existing audience and delivery behaviour. */
18
+ rostered?: (peer: PeerId) => boolean;
16
19
  /** Transient owner lifecycle fence: retain new work without changing console/budget pauses. */
17
20
  deliveryHeld?: (peer: PeerId) => boolean;
18
21
  /** Observe original envelopes immediately before a transport starts its turn. */
@@ -155,6 +158,7 @@ export class Bus {
155
158
  if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
156
159
  peer.onDelivery = (receipt) => this.deliveryReceipt(peer.id, receipt);
157
160
  peer.onMessage = (text, opts) => {
161
+ if (!this.isRostered(peer.id)) return `off roster: ${peer.id}`;
158
162
  const { priority, body } = parseMarker(text);
159
163
  if (!body) return;
160
164
  // What the peer was handed differs from what it stands for once a delivery was condensed; both the audience
@@ -167,15 +171,22 @@ export class Bus {
167
171
  const audienceOriginals = originals ?? (opts?.to?.includes(DIGEST) && condensed ? last?.originals : undefined);
168
172
  const to = opts?.to?.length ? resolveTo(opts.to, audienceOriginals) : undefined;
169
173
  let env = newEnvelope(peer.id, body, { ...opts, ...(to ? { to } : {}), priority: opts?.priority ?? capPriority(peer, priority, opts?.inReplyTo, originals) });
174
+ const offRoster = this.offRosterRecipients(env);
175
+ if (offRoster.length && offRoster.length === new Set(env.to?.filter(id => id !== env.from)).size) {
176
+ const reason = `off roster: ${offRoster.join(", ")}`;
177
+ this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered: ${reason}`));
178
+ return reason; // no publication and no limiter spend for an entirely refused audience
179
+ }
180
+ const admittedEnv = offRoster.length ? { ...env, to: env.to!.filter(id => !offRoster.includes(id)) } : env;
170
181
  // Limits count what is sent: the envelope as built (a reply goes to its parent's sender, `digest` is resolved,
171
182
  // the priority is capped), never the raw `to`.
172
183
  const parent = opts?.inReplyTo?.id;
173
184
  // Held back from every recipient (issue #107): nothing goes out, so nothing counts against the sender's limits.
174
185
  const hushed = this.hushed(env);
175
186
  const heldBack = hushed.length > 0 && hushed.length === this.audience(env).length;
176
- let refused = heldBack ? undefined : this.opts.admit?.(env, parent);
187
+ let refused = heldBack ? undefined : this.opts.admit?.(admittedEnv, parent);
177
188
  // A turn answer has no caller to refuse: over its important budget it goes out as status, not at all.
178
- if (refused && env.priority === "important" && !this.opts.admit?.({ ...env, priority: "status" }, parent)) {
189
+ if (refused && env.priority === "important" && !this.opts.admit?.({ ...admittedEnv, priority: "status" }, parent)) {
179
190
  // It went out, so the advice on how to send it does not apply.
180
191
  this.note(peer.id, noteLine(HUB, "decision", `your [IMPORTANT] message went out as status: ${refused.replace(/[;:] (retry after \d+ s, or )?send it without \[IMPORTANT\]$/, "")}`));
181
192
  env = { ...env, priority: "status" };
@@ -186,6 +197,7 @@ export class Bus {
186
197
  return refused;
187
198
  }
188
199
  this.publish(env);
200
+ if (offRoster.length) this.note(peer.id, noteLine(HUB, "decision", `your message is retained off roster for ${offRoster.join(", ")}; delivered to the other recipients`));
189
201
  // The sender's result (issue #107): a turn answer has no caller, so it hears it on its next delivery. It counts as
190
202
  // not sent only when nobody got it.
191
203
  if (hushed.length) this.note(peer.id, noteLine(HUB, "decision", `your message was not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}`));
@@ -466,6 +478,17 @@ export class Bus {
466
478
  return (this.queues.get(id) ?? []).map((e) => e.id);
467
479
  }
468
480
 
481
+ queuedBacklogIds(id: PeerId): Set<number> {
482
+ const ids = new Set<number>();
483
+ const retained = [...(this.queues.get(id) ?? []), ...(this.journal?.unresolved(id).filter(row => row.state !== "queued").flatMap(row => row.originals) ?? [])];
484
+ for (const env of retained) {
485
+ const backlog = env.refs?.backlog;
486
+ if (env.from !== HUB || env.kind !== "task" || !Array.isArray(backlog) || backlog.length > 64) continue;
487
+ for (const task of backlog) if (Number.isSafeInteger(task) && task > 0) ids.add(task);
488
+ }
489
+ return ids;
490
+ }
491
+
469
492
  attemptState(): Record<string, number> {
470
493
  return Object.fromEntries(this.attempts);
471
494
  }
@@ -541,12 +564,12 @@ export class Bus {
541
564
 
542
565
  const hushed = this.hushed(env);
543
566
  if (hushed.length) this.emit({ t: "quiet", env, peers: hushed.map((h) => h.peer), reason: hushed[0]!.reason });
544
- const targets = this.audience(env).filter((id) => !hushed.some((h) => h.peer === id));
567
+ const targets = this.audience(env).filter((id) => !hushed.some((h) => h.peer === id) || !this.isRostered(id));
545
568
  this.suppressDrain = !!this.journal;
546
569
  try { for (const id of targets) {
547
570
  const peer = this.peers.get(id);
548
571
  if (!peer) { this.enqueue(id, env); continue; }
549
- if (!this.journal && !this.recoveryHeld && !this.recoveryHeldPeers.has(id) && !this.opts.deliveryHeld?.(id) && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
572
+ if (!this.journal && !this.held(id) && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
550
573
  // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
551
574
  this.steering++;
552
575
  const deliveryId = crypto.randomUUID();
@@ -566,23 +589,39 @@ export class Bus {
566
589
  this.onQueues?.();
567
590
  for (const id of targets) {
568
591
  const peer = this.peers.get(id);
569
- if (!this.recoveryHeld && !this.recoveryHeldPeers.has(id) && !this.opts.deliveryHeld?.(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
592
+ if (!this.held(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
570
593
  else void this.drain(id);
571
594
  }
572
595
  }
596
+ const offRoster = targets.filter(id => !this.isRostered(id));
597
+ if (offRoster.length) this.emit({ t: "off_roster", env, peers: offRoster });
573
598
  return targets;
574
599
  }
575
600
 
601
+ isRostered(peer: PeerId): boolean { return this.opts.rostered?.(peer) ?? true; }
602
+
603
+ /** Explicit off-roster recipients, including unattached retained targets; broadcasts have none. */
604
+ offRosterRecipients(env: Envelope): PeerId[] {
605
+ return [...new Set(env.to ?? [])].filter(peer => peer !== env.from && !this.isRostered(peer));
606
+ }
607
+
608
+ /** Membership changed externally: reconsider delivery, keeping every independent hold intact. */
609
+ rosterChanged(peer: PeerId): void {
610
+ if (this.closed) return;
611
+ this.onQueues?.();
612
+ void this.drain(peer);
613
+ }
614
+
576
615
  /** Who an envelope is queued for: its `to`, or every peer but its sender. */
577
616
  audience(env: Envelope): PeerId[] {
578
617
  const known = new Set([...this.peers.keys(), ...this.queues.keys(), ...(this.journal?.list().map((d) => d.peer) ?? [])]);
579
- return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && known.has(id));
618
+ return (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && (known.has(id) || !this.isRostered(id)) && (env.to !== undefined || this.isRostered(id)));
580
619
  }
581
620
 
582
621
  /** The recipients of `env` that the silence policy holds it back from, each with why (issue #107). */
583
622
  hushed(env: Envelope): { peer: PeerId; reason: string }[] {
584
623
  if (!this.opts.silence || env.hop > MAX_HOP || env.priority === "fyi") return [];
585
- return this.audience(env).flatMap((peer) => {
624
+ return this.audience(env).filter(peer => this.isRostered(peer)).flatMap((peer) => {
586
625
  const reason = this.opts.silence!(env, peer);
587
626
  return reason ? [{ peer, reason }] : [];
588
627
  });
@@ -728,9 +767,9 @@ export class Bus {
728
767
  finally { this.steering--; this.onQueues?.(); }
729
768
  }
730
769
 
731
- /** Holds on a peer's queue, for a drain and a pull alike: recovery, an uncertain delivery, a pause (console, budget, conductor). */
770
+ /** Holds on a peer's queue, for a drain and a pull alike: off roster, recovery, an uncertain delivery, a pause (console, budget, conductor). */
732
771
  private held(id: PeerId): boolean {
733
- return this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
772
+ return !this.isRostered(id) || this.recoveryHeld || this.recoveryHeldPeers.has(id) || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true;
734
773
  }
735
774
 
736
775
  /** What a peer was just handed stays resolvable for `reply_to`: moved to the newest end, the stored envelope kept. */
@@ -0,0 +1,7 @@
1
+ /** Bounded Markdown [@key] and BibTeX entry-key example check, no fetching or model verdict. */
2
+ export function citationKeys(markdown: string, bibliography: string): { cited: string[]; missing: string[] } {
3
+ if (Buffer.byteLength(markdown) > 256 * 1024 || Buffer.byteLength(bibliography) > 4 * 1024 * 1024) throw new Error("citation inputs exceed example check limits");
4
+ const known = new Set([...bibliography.matchAll(/@[a-zA-Z]+\s*\{\s*([^,\s{}]+)\s*,/g)].map(m => m[1]!));
5
+ const cited = [...new Set([...markdown.matchAll(/\[@[A-Za-z0-9_:./-]+(?:\s*;\s*@[A-Za-z0-9_:./-]+)*\]/g)].flatMap(m => [...m[0].matchAll(/@([A-Za-z0-9_:./-]+)/g)].map(k => k[1]!)))].sort();
6
+ return { cited, missing: cited.filter(key => !known.has(key)) };
7
+ }
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import type { Task } from "./board.ts";
3
3
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
4
4
  import { HUB, USER } from "./envelope.ts";
5
- import { OWNERSHIP_EVENTS } from "./tasks.ts";
5
+ import { OWNERSHIP_EVENTS, type TaskAssignmentRole } from "./tasks.ts";
6
6
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
7
7
  import { isStartablePeer, type StartablePeer, type StartMode } from "./start-mode.ts";
8
8
  import type { HubEvent } from "./events.ts";
@@ -111,7 +111,7 @@ export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer:
111
111
  export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: StartablePeer) => StartMode): ConductorStart {
112
112
  const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
113
113
  if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
114
- if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local may be started by the conductor");
114
+ if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
115
115
  const own = startMode(peer);
116
116
  if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
117
117
  return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
@@ -156,7 +156,7 @@ export interface ConductorHooks {
156
156
  task(id: number): Task | undefined;
157
157
  publicView(task: Task): Record<string, unknown>;
158
158
  /** These callbacks must call Tasks, never Board.update. */
159
- assign(actor: string, id: number, peer: string): Promise<unknown>;
159
+ assign(actor: string, id: number, peer: string, role?: TaskAssignmentRole): Promise<unknown>;
160
160
  escalate(actor: string, id: number): Promise<unknown>;
161
161
  preview(peer: "claude" | "codex" | "pi"): string;
162
162
  /** The mode a start of this peer takes: a person's setting. */
@@ -182,18 +182,20 @@ export class Conductor {
182
182
  constructor(private readonly holds: ConductorHolds, private readonly hooks: ConductorHooks) {}
183
183
  async execute(actor: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
184
184
  if (!CONDUCTOR_TOOL_NAMES.has(tool)) throw new PreEffectToolRefusal("unknown conductor tool");
185
+ const role = args.role ?? "owner";
186
+ if (tool === "hub_task_assign" && role !== "owner" && role !== "reviewer") throw new PreEffectToolRefusal("role must be owner or reviewer");
185
187
  if ((tool === "hub_task_show" || tool === "hub_task_assign") && conductorPeer(this.hooks.roles()) !== actor) {
186
188
  const id = taskId(args.id);
187
189
  const task = id === undefined ? undefined : this.hooks.task(id);
188
190
  // A task's current owner and reviewer read its public view without the role (#208); nobody else does.
189
- if (task && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
191
+ if (task && task.state !== "backlog" && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
190
192
  // Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
191
193
  // worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
192
194
  // reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
193
195
  // moves are not decisions).
194
- const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved"));
196
+ const moved = task?.history.findLast((h) => h.by !== HUB && (OWNERSHIP_EVENTS.has(h.event) || h.event === "reserved" || h.planning?.fields.includes("reserved")));
195
197
  const accepted = task?.history.some((h) => h.event === "accepted");
196
- if (task && tool === "hub_task_assign" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
198
+ if (task && tool === "hub_task_assign" && role === "owner" && task.history[0]?.by === actor && task.state === "proposed" && !accepted && moved?.by !== USER) {
197
199
  const peer = peerId(args.peer, true);
198
200
  if (peer !== actor) requireAssign(actor, this.hooks.capabilities(), true);
199
201
  await this.hooks.assign(actor, task.id, peer);
@@ -212,10 +214,13 @@ export class Conductor {
212
214
  if (id === undefined) throw new PreEffectToolRefusal("id must be a positive integer");
213
215
  const task = this.hooks.task(id);
214
216
  if (!task) throw new PreEffectToolRefusal(`no task #${args.id}`);
217
+ if (task.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools, not conductor task tools");
215
218
  if (tool === "hub_task_show") { const result = publicConductorTask(task, this.hooks.publicView); emit({ task: task.id }); return result; }
216
219
  if (tool === "hub_task_assign") {
217
220
  const peer = peerId(args.peer, true);
218
- await this.hooks.assign(actor, task.id, peer); emit({ task: task.id, peer });
221
+ if (role === "reviewer") await this.hooks.assign(actor, task.id, peer, role);
222
+ else await this.hooks.assign(actor, task.id, peer);
223
+ emit({ task: task.id, peer });
219
224
  } else { await this.hooks.escalate(actor, task.id); emit({ task: task.id }); }
220
225
  // Task callbacks may return raw objects; always re-read and apply the public view.
221
226
  const updated = this.hooks.task(task.id);
@@ -28,6 +28,8 @@ export const MACHINE_LOCAL = [
28
28
  "local.network_allow", // the hosts the egress proxy opens (#65)
29
29
  "terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
30
30
  ] as const;
31
+ /** Legacy exported field list stays compatible; new policies use the complete list below. */
32
+ export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection"] as const;
31
33
 
32
34
  /**
33
35
  * Why `.agenthub/<name>` must not set machine-local fields, or undefined when git confirms nobody committed it.
@@ -84,8 +86,8 @@ const drop = (file: Record<string, unknown>, path: string) => {
84
86
  * once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
85
87
  */
86
88
  export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
87
- for (const p of MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
88
- const set = MACHINE_LOCAL.filter((p) => {
89
+ for (const p of ALL_MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
90
+ const set = ALL_MACHINE_LOCAL.filter((p) => {
89
91
  const v = valueAt(file, p);
90
92
  return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
91
93
  });
package/src/hub/crash.ts CHANGED
@@ -47,6 +47,8 @@ export function removeSessions(stateDir: string, instanceId?: string): void {
47
47
  if (instanceId === undefined || readSessions(stateDir)?.instanceId === instanceId) rmSync(fileOf(stateDir), { force: true });
48
48
  }
49
49
 
50
+ export const qwenSessionId = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(value);
51
+
50
52
  const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
51
53
 
52
54
  /**
@@ -62,6 +64,13 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
62
64
  return { peer, how: "claude: the Claude Code plugin reconnects by itself while that session is still open" };
63
65
  case "codex":
64
66
  return { peer, how: `codex: its app-server died with the hub; run ahub codex again${str(meta.threadId) ? ` (its conversation was thread ${meta.threadId})` : ""}` };
67
+ case "jcode":
68
+ return { peer, how: "jcode: native 0.94.0 load does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
69
+ case "qwen": {
70
+ const sessionId = meta.sessionId;
71
+ if (!qwenSessionId(sessionId)) return { peer, how: "qwen: saved ACP session identity unavailable; start it deliberately with ahub qwen" };
72
+ return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: "qwen: the recorded conversation can be loaded again (ACP session/load)" };
73
+ }
65
74
  case "kimi": {
66
75
  const sessionId = str(meta.sessionId);
67
76
  if (!sessionId) return { peer, how: "kimi: no session id was recorded; start it again with ahub kimi" };