@staix/agent-hub 0.12.30 → 0.12.32

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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
@@ -0,0 +1,120 @@
1
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
2
+ import { spawn } from "node:child_process";
3
+ import { hostExecutable } from "./host-executable.ts";
4
+ import type { Task } from "./board.ts";
5
+ import { childEnv, stopOwnedProcess, trackGroup } from "./child-process.ts";
6
+
7
+ const BODY_LIMIT = 64 * 1024, RESULT_LIMIT = 80 * 1024, STDOUT_LIMIT = 512 * 1024;
8
+ const REPO = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
9
+ const bytes = (text: string) => Buffer.byteLength(text, "utf8");
10
+ export interface SpecResult { number: number; title: string; state: string; updated_at: string; html_url: string; body: string; }
11
+ /** PATH/shorter timeout are host-owned fixture seams, never tool inputs. */
12
+ export interface SpecOptions { path?: string; timeoutMs?: number; projectDir?: string; }
13
+ export interface FetchSpecOptions extends SpecOptions { screen: (whole: string) => boolean | Promise<boolean>; }
14
+ /** Canonical owner/repo identity accepted by the fixed github.com fetch boundary. */
15
+ export const isGitHubRepository = (repo: unknown): repo is string => typeof repo === "string" && REPO.test(repo);
16
+ function validRepo(repo: string): void { if (!isGitHubRepository(repo)) throw new PreEffectToolRefusal("other repository"); }
17
+
18
+ /** Resolve only task-bound issue references; foreign references never fall through to local #N. */
19
+ export function specRefs(task: Pick<Task, "title" | "detail" | "refs"> & Partial<Pick<Task, "external">>, repo: string, options: { bareTitle?: boolean } = {}): number[] {
20
+ const references: { repo?: string; number: number }[] = [];
21
+ // Bare detail numbers name hub tasks. File paths are not issue references.
22
+ for (const section of [{ text: task.title, bare: true }, { text: task.detail, bare: false }]) {
23
+ for (const match of section.text.matchAll(/(?<![A-Za-z0-9_./-])(?:https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/([1-9][0-9]*)\b|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)\b|#([1-9][0-9]*)\b)/g)) {
24
+ const qualified = match[1] ?? match[3];
25
+ if (!qualified && (!section.bare || options.bareTitle === false)) continue;
26
+ references.push({ repo: qualified, number: Number(match[2] ?? match[4] ?? match[5]) });
27
+ }
28
+ }
29
+ if (!references.length && task.external === undefined && !task.refs.repo) return [];
30
+ validRepo(repo);
31
+ if (task.refs.repo && task.refs.repo.toLowerCase() !== repo.toLowerCase()) throw new PreEffectToolRefusal("other repository");
32
+ const issues = new Set<number>();
33
+ if (task.external !== undefined) {
34
+ const source = task.external;
35
+ if (!source || source.provider !== "github" || typeof source.repo !== "string") throw new PreEffectToolRefusal("not found");
36
+ validRepo(source.repo);
37
+ if (source.repo.toLowerCase() !== repo.toLowerCase()) throw new PreEffectToolRefusal("other repository");
38
+ if (!Number.isSafeInteger(source.issue) || source.issue < 1 || typeof source.url !== "string"
39
+ || source.url.toLowerCase() !== `https://github.com/${repo}/issues/${source.issue}`.toLowerCase()) throw new PreEffectToolRefusal("not found");
40
+ issues.add(source.issue);
41
+ }
42
+ for (const reference of references) {
43
+ if (reference.repo && reference.repo.toLowerCase() !== repo.toLowerCase()) throw new PreEffectToolRefusal("other repository");
44
+ if (!Number.isSafeInteger(reference.number)) throw new PreEffectToolRefusal("not found");
45
+ issues.add(reference.number);
46
+ if (issues.size > 3) throw new PreEffectToolRefusal("too many spec references");
47
+ }
48
+ return [...issues];
49
+ }
50
+
51
+ interface GhResult { code: number; stdout: string; }
52
+ /** One fixed-argv authenticated boundary, no shell, no retries and no retained stderr. */
53
+ function gh(args: string[], opts: SpecOptions): Promise<GhResult> {
54
+ return new Promise((resolve, reject) => {
55
+ const env: NodeJS.ProcessEnv = { ...childEnv(), GH_HOST: "github.com", GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1", ...(opts.path === undefined ? {} : { PATH: opts.path }) };
56
+ if (opts.path !== undefined) {
57
+ for (const key of ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]) delete env[key];
58
+ env.GH_CONFIG_DIR = opts.path;
59
+ }
60
+ let child: ReturnType<typeof spawn>;
61
+ try {
62
+ child = spawn(hostExecutable("gh", opts.projectDir ?? process.cwd(), opts.path ?? process.env.PATH), args, { env, detached: true, stdio: ["ignore", "pipe", "ignore"] });
63
+ trackGroup(child);
64
+ }
65
+ catch { reject(new Error("gh unavailable")); return; }
66
+ let output: Buffer[] = [], size = 0, failure: string | undefined;
67
+ const stop = (reason: string) => {
68
+ if (failure) return;
69
+ failure = reason; output = [];
70
+ void stopOwnedProcess(child, { group: true, termMs: 100, killMs: 500 }).catch(() => {});
71
+ };
72
+ const timeout = opts.timeoutMs === undefined || !Number.isFinite(opts.timeoutMs) ? 15_000 : Math.max(1, Math.min(15_000, opts.timeoutMs));
73
+ const timer = setTimeout(() => stop("timeout"), timeout);
74
+ child.stdout?.on("data", (chunk: Buffer) => {
75
+ if (failure) return;
76
+ size += chunk.length;
77
+ if (size > STDOUT_LIMIT) stop("too large"); else output.push(chunk);
78
+ });
79
+ child.once("error", () => { clearTimeout(timer); reject(new Error("gh unavailable")); });
80
+ child.once("close", code => {
81
+ clearTimeout(timer);
82
+ if (failure) reject(new Error(failure));
83
+ else resolve({ code: code ?? 1, stdout: Buffer.concat(output).toString("utf8") });
84
+ });
85
+ });
86
+ }
87
+
88
+ /** Startup-only availability evidence; daemon caches it until restart. */
89
+ export async function specAvailable(opts: SpecOptions = {}): Promise<{ available: boolean; reason?: string }> {
90
+ try {
91
+ const result = await gh(["auth", "status", "--hostname", "github.com"], opts);
92
+ return result.code === 0 ? { available: true } : { available: false, reason: "gh unauthenticated" };
93
+ } catch (error) { return { available: false, reason: error instanceof Error ? error.message : "gh unavailable" }; }
94
+ }
95
+
96
+ export async function fetchSpec(repo: string, n: number, opts: FetchSpecOptions): Promise<SpecResult> {
97
+ validRepo(repo);
98
+ if (!Number.isSafeInteger(n) || n < 1) throw new PreEffectToolRefusal("not found");
99
+ const raw = await gh(["api", "-X", "GET", `repos/${repo}/issues/${n}`], opts);
100
+ let value: unknown;
101
+ try { value = JSON.parse(raw.stdout); } catch { throw new Error(raw.code === 4 ? "unauthenticated" : "not found"); }
102
+ if (raw.code !== 0) {
103
+ const status = (value as { status?: unknown })?.status;
104
+ throw new Error(raw.code === 4 || status === 401 ? "unauthenticated" : "not found");
105
+ }
106
+ if (!value || typeof value !== "object") throw new Error("not found");
107
+ const row = value as Record<string, unknown>;
108
+ if (typeof row.repository_url !== "string" || row.repository_url.toLowerCase() !== `https://api.github.com/repos/${repo}`.toLowerCase()
109
+ || typeof row.html_url !== "string" || ![`https://github.com/${repo}/issues/${n}`, `https://github.com/${repo}/pull/${n}`].some(url => row.html_url!.toString().toLowerCase() === url.toLowerCase())) throw new PreEffectToolRefusal("other repository");
110
+ if (row.number !== n || typeof row.title !== "string" || typeof row.state !== "string" || typeof row.updated_at !== "string" || (row.body !== null && typeof row.body !== "string")) throw new Error("not found");
111
+ const result: SpecResult = { number: n, title: row.title, state: row.state, updated_at: row.updated_at, html_url: row.html_url, body: row.body ?? "" };
112
+ // GET completed without task/check mutations; these explicit complete-contract refusals withhold only its result.
113
+ const length = bytes(result.body);
114
+ if (length > BODY_LIMIT) throw new PreEffectToolRefusal(`spec too large (${length} > 65536); ask the reviewer to put the contract in the repo`);
115
+ if (bytes(JSON.stringify(result)) > RESULT_LIMIT) throw new PreEffectToolRefusal("too large");
116
+ let allowed = false;
117
+ try { allowed = await opts.screen(`${result.title}\n${result.body}`); } catch { /* policy unavailable withholds the entire spec */ }
118
+ if (allowed !== true) throw new PreEffectToolRefusal("spec withheld by privacy policy");
119
+ return result;
120
+ }
@@ -1,4 +1,4 @@
1
- import type { HistoryEntry, Task, TaskMoveReason } from "./board.ts";
1
+ import { isClosedTaskState, type HistoryEntry, type Task, type TaskMoveReason, type TaskStall } from "./board.ts";
2
2
  import { sanitize, type Kind, type Priority } from "./envelope.ts";
3
3
 
4
4
  export type SupervisionScope = "own" | "all" | "off";
@@ -11,7 +11,7 @@ export interface SupervisionFields {
11
11
  check?: "passed" | "failed" | "unknown";
12
12
  verdict?: "approved" | "changes_requested";
13
13
  reason?: SupervisionReason;
14
- sweep?: "unaccepted-assignment" | "idle-owner" | "review-pending";
14
+ sweep?: "unaccepted-assignment" | "idle-owner" | "review-pending" | "ready-unassigned";
15
15
  }
16
16
  export interface SupervisionNotice {
17
17
  peer: string;
@@ -31,7 +31,7 @@ export interface SupervisionDeps {
31
31
  isPrivate: (task: Task) => boolean;
32
32
  /** false means not admitted, so latches must remain retryable. No separate batching timer. */
33
33
  emit: (notice: SupervisionNotice) => boolean | void;
34
- approvalAgeMs?: number;
34
+ approvalAgeMs?: number | (() => number);
35
35
  now?: () => number;
36
36
  /** Durable round state belongs to the daemon's store, not the controller. */
37
37
  readRound?: (peer: string) => string | undefined;
@@ -102,23 +102,36 @@ export class SupervisionFeed {
102
102
  this.checkRound();
103
103
  }
104
104
 
105
+ /** Tasks owns durable cause/replan deduplication; the feed keeps its configured recipient and scope. */
106
+ taskStalled(stall: TaskStall): boolean {
107
+ const task = this.d.tasks().find(task => task.id === stall.task);
108
+ if (!task || isClosedTaskState(task.state) || !this.includes(task)) return false;
109
+ const cause = stall.cause;
110
+ const bits = [`#${stall.task}: stalled`, `cause ${cause.kind}`, `reason ${cause.code}`];
111
+ if (cause.peer) bits.push(`peer ${cause.peer}`);
112
+ if (cause.dependency !== undefined) bits.push(`dependency #${cause.dependency}`);
113
+ if (cause.refusals?.length) bits.push(`refusals ${cause.refusals.map(refusal => `${refusal.peer}:${refusal.code}`).join(", ")}`);
114
+ return this.send(`task:${stall.task}:stall`, bits.join("; "), "status", stall.task);
115
+ }
116
+
105
117
  peerOffline(peer: string): boolean {
106
- if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
118
+ if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer))) return false;
107
119
  return this.send(`peer:${peer}:offline`, `${peer}: offline`);
108
120
  }
109
121
 
110
122
  budgetPaused(peer: string, resetsAt?: number): boolean {
111
- if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
123
+ if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer))) return false;
112
124
  const resetDate = new Date(resetsAt ?? NaN);
113
125
  const reset = Number.isFinite(resetDate.getTime()) ? resetDate.toISOString() : "unknown";
114
126
  return this.send(`peer:${peer}:budget`, `${peer}: budget paused; reset ${reset}`, "status", undefined, "budget");
115
127
  }
116
128
 
117
129
  approvalWaiting(request: { id: string; peer: string; tool: string; createdAt: number }): boolean {
130
+ const approvalAgeMs = typeof this.d.approvalAgeMs === "function" ? this.d.approvalAgeMs() : this.d.approvalAgeMs ?? 60_000;
118
131
  const peer = this.recipient();
119
132
  const age = (this.d.now?.() ?? Date.now()) - request.createdAt;
120
133
  const key = `${peer}:${request.id}`;
121
- if (!peer || !Number.isFinite(age) || age < (this.d.approvalAgeMs ?? 60_000) || this.approvals.has(key)) return false;
134
+ if (!peer || !Number.isFinite(age) || age < approvalAgeMs || this.approvals.has(key)) return false;
122
135
  const sent = this.send(`approval:${request.id}`, `${request.peer}: approval waiting; tool ${request.tool}; age ${Math.floor(age / 1000)}s; ask the person to answer it in ahub console`, "important");
123
136
  if (sent) this.approvals.add(key);
124
137
  return sent;
@@ -137,13 +150,16 @@ export class SupervisionFeed {
137
150
  const peer = this.recipient();
138
151
  if (!peer) return false;
139
152
  const tasks = this.d.tasks().filter((t) => this.includes(t));
140
- if (!tasks.length || tasks.some((t) => t.state !== "approved")) return false;
153
+ if (!tasks.length || tasks.some((t) => !isClosedTaskState(t.state))) return false;
141
154
  const signature = tasks.map((t) => t.id).sort((a, b) => a - b).join(",");
142
155
  const previous = this.d.readRound?.(peer) ?? this.rounds.get(peer);
143
156
  const completed = new Set(previous?.split(",") ?? []);
144
157
  const newlyJoined = tasks.filter((t) => !completed.has(String(t.id)));
145
158
  if (!newlyJoined.length) return false;
146
- const sent = this.send("round", `Supervision round complete: ${newlyJoined.length} task${newlyJoined.length === 1 ? "" : "s"} approved; report the results and open decisions to the person.`);
159
+ const count = `${newlyJoined.length} task${newlyJoined.length === 1 ? "" : "s"}`;
160
+ const cancelled = newlyJoined.filter(t => t.state === "cancelled").length;
161
+ const outcome = cancelled ? `${count} closed (${newlyJoined.length - cancelled} approved, ${cancelled} cancelled)` : `${count} approved`;
162
+ const sent = this.send("round", `Supervision round complete: ${outcome}; report the results and open decisions to the person.`);
147
163
  if (sent) {
148
164
  this.rounds.set(peer, signature);
149
165
  this.d.writeRound?.(peer, signature);
@@ -1,6 +1,6 @@
1
1
  import type { Task } from "./board.ts";
2
2
 
3
- export type SweepKind = "unaccepted-assignment" | "idle-owner" | "review-pending";
3
+ export type SweepKind = "unaccepted-assignment" | "idle-owner" | "review-pending" | "ready-unassigned";
4
4
  export interface SweepRecord {
5
5
  kind: SweepKind;
6
6
  /** Index of the last real activity, distinguishing even events at the same time. */
@@ -14,11 +14,12 @@ export interface TaskSweepConfig {
14
14
  unaccepted_min: number;
15
15
  idle_min: number;
16
16
  review_min: number;
17
+ ready_min?: number;
17
18
  ladder_min: number;
18
19
  auto_reassign: boolean;
19
20
  }
20
21
  export const DEFAULT_TASK_SWEEP: TaskSweepConfig = {
21
- enabled: false, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
22
+ enabled: false, ready_min: 60, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
22
23
  };
23
24
 
24
25
  /** Config cannot silently enable a sweep or reassignment through a truthy string. */
@@ -32,7 +33,7 @@ export function taskSweepConfig(input: unknown): TaskSweepConfig {
32
33
  if (typeof values[key] !== "boolean") throw new Error(`task_sweep.${key} must be a boolean`);
33
34
  out[key] = values[key];
34
35
  }
35
- for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ladder_min"] as const) {
36
+ for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"] as const) {
36
37
  if (values[key] === undefined) continue;
37
38
  const value = values[key];
38
39
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value * (key === "interval_s" ? 1000 : 60_000) > 2_147_483_647) throw new Error(`task_sweep.${key} must be a bounded number of at least 1`);
@@ -42,16 +43,16 @@ export function taskSweepConfig(input: unknown): TaskSweepConfig {
42
43
  }
43
44
 
44
45
  /** Pure time/kind ladder; Tasks decides whether the responsible peer is available. */
45
- export function nextSweep(task: Task, config: TaskSweepConfig, now: number): SweepRecord | undefined {
46
+ export function nextSweep(task: Task, config: TaskSweepConfig, now: number, ready = true): SweepRecord | undefined {
46
47
  if (!config.enabled || !Number.isFinite(now)) return undefined;
47
48
  const kind = task.state === "proposed" && task.owner ? "unaccepted-assignment"
48
49
  : task.state === "in_progress" && task.owner ? "idle-owner"
49
- : task.state === "in_review" && task.reviewer ? "review-pending" : undefined;
50
+ : task.state === "in_review" && task.reviewer ? "review-pending" : task.state === "proposed" && !task.owner && !task.reviewer && ready ? "ready-unassigned" : undefined;
50
51
  if (!kind) return undefined;
51
52
  let activity = task.history.length - 1;
52
53
  while (activity >= 0 && (task.history[activity]?.sweep || task.history[activity]?.event === "memory reference started")) activity--;
53
54
  const at = task.history[activity]?.at ?? task.created;
54
- const threshold = kind === "unaccepted-assignment" ? config.unaccepted_min : kind === "idle-owner" ? config.idle_min : config.review_min;
55
+ const threshold = kind === "ready-unassigned" ? config.ready_min ?? 60 : kind === "unaccepted-assignment" ? config.unaccepted_min : kind === "idle-owner" ? config.idle_min : config.review_min;
55
56
  if (now - at < threshold * 60_000) return undefined;
56
57
  const previous = task.history.flatMap((h) => h.sweep?.kind === kind && h.sweep.activity === activity ? [h.sweep] : []).at(-1);
57
58
  if (previous && (previous.step === 3 || now - previous.at < config.ladder_min * 60_000)) return undefined;