@staix/agent-hub 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/bus.md +1 -1
  4. package/docs/agent-notes/daemon.md +2 -2
  5. package/docs/agent-notes/tasks.md +1 -1
  6. package/docs/agent-notes/tests.md +1 -1
  7. package/docs/dogfood.md +3 -1
  8. package/docs/operations.md +45 -0
  9. package/docs/profiles.md +1 -1
  10. package/docs/quickstart.md +5 -1
  11. package/docs/smoke.md +6 -0
  12. package/docs/specs/2026-09-19-agent-hub-design.md +31 -0
  13. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  14. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  15. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  16. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  17. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  18. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  19. package/docs/verified.json +12 -12
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +1 -1
  23. package/src/cli/help.ts +1 -0
  24. package/src/cli/identity.ts +3 -1
  25. package/src/cli/learn-output.ts +38 -0
  26. package/src/cli/main.ts +20 -2
  27. package/src/cli/recovery-waits.ts +26 -0
  28. package/src/cli/smoke-pty-bridge.ts +67 -18
  29. package/src/cli/smoke-pty.ts +10 -2
  30. package/src/cli/upgrade-runtime.ts +16 -14
  31. package/src/cli/upgrade.ts +2 -1
  32. package/src/hub/bus.ts +18 -10
  33. package/src/hub/config-apply.ts +2 -1
  34. package/src/hub/daemon.ts +61 -14
  35. package/src/hub/delivery-journal.ts +78 -57
  36. package/src/hub/learning.ts +33 -1
  37. package/src/hub/operational-settings.ts +79 -1
  38. package/src/hub/settings.ts +21 -1
  39. package/src/hub/tasks.ts +78 -1
package/src/hub/bus.ts CHANGED
@@ -41,6 +41,11 @@ export interface BusOptions {
41
41
  /** How long a status envelope may wait for company before it is delivered on its own. */
42
42
  batchMs: number;
43
43
  queueCap: number;
44
+ /** Accepted live operational settings; read only at the next matching delivery/note operation. */
45
+ maxAttempts?: () => number;
46
+ digestMax?: () => number;
47
+ pullBudgetBytes?: () => number;
48
+ notesKept?: () => number;
44
49
  /** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
45
50
  condense?: (envs: Envelope[]) => Promise<Envelope[]>;
46
51
  journal?: DeliveryJournal;
@@ -118,7 +123,7 @@ function resolveTo(to: PeerId[], originals: Envelope[] | undefined): PeerId[] {
118
123
  }
119
124
 
120
125
  const SEEN_CAP = 2048;
121
- const MAX_ATTEMPTS = 3;
126
+ export const MAX_ATTEMPTS = 3;
122
127
  // ponytail: provenance is capped at 2048 ids/peer, 4096 peers, 100000 ids total and 256 KiB per snapshot.
123
128
  // Omitted metadata means unknown age only; use a measured per-envelope store if larger queues need provenance.
124
129
  const QUEUE_TIME_CAP = 2048, QUEUE_TIME_PEERS_CAP = 4096, QUEUE_TIME_TOTAL_CAP = 100_000, QUEUE_TIME_SNAPSHOT_BYTES = 256 * 1024;
@@ -127,11 +132,11 @@ const remoteId = (id: string): boolean => id.includes("@");
127
132
  const remoteFingerprint = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
128
133
  // ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
129
134
  // Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
130
- const DIGEST_MAX = 10;
135
+ export const DIGEST_MAX = 10;
131
136
  // #713: a pull-only peer reads between steps, often minutes apart, so one pull drains its queue up to this many bytes of rendered text instead of ten items.
132
137
  // Bounded, not unbounded: a queue larger than the budget takes more than one pull, and the reply says how many are still waiting. Push peers keep DIGEST_MAX.
133
- const PULL_BUDGET_BYTES = 64 * 1024;
134
- const NOTES_KEPT = 10;
138
+ export const PULL_BUDGET_BYTES = 64 * 1024;
139
+ export const NOTES_KEPT = 10;
135
140
 
136
141
  /**
137
142
  * N-peer fan-out. Never delivers back to `from`, caps hops, dedupes by id. Each peer has one queue,
@@ -797,7 +802,7 @@ export class Bus {
797
802
  note(id: PeerId, line: string): void {
798
803
  if (remoteId(id)) return;
799
804
  const existing = this.prefaces.get(id);
800
- this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
805
+ this.prefaces.set(id, existing ? { ...existing, body: keepNotes(uniqueMemoryLines(appendNote(existing.body, line)), this.opts.notesKept?.() ?? NOTES_KEPT) } : newEnvelope(HUB, uniqueMemoryLines(line), { to: [id], kind: "presence" }));
801
806
  this.persist();
802
807
  }
803
808
 
@@ -805,7 +810,7 @@ export class Bus {
805
810
  private restorePreface(id: PeerId, env: Envelope): void {
806
811
  if (remoteId(id)) return;
807
812
  const now = this.prefaces.get(id);
808
- this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
813
+ this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), this.opts.notesKept?.() ?? NOTES_KEPT) });
809
814
  }
810
815
 
811
816
  /**
@@ -1505,13 +1510,14 @@ export class Bus {
1505
1510
  * An envelope that already failed once goes alone, so a poison one cannot take a digest down with it.
1506
1511
  */
1507
1512
  private take(id: PeerId, queue: Envelope[], remove = true): Envelope[] {
1513
+ const digestMax = this.opts.digestMax?.() ?? DIGEST_MAX;
1508
1514
  const ordered = [...queue.filter((e) => e.priority === "important"), ...queue.filter((e) => e.priority !== "important")];
1509
1515
  const batch: Envelope[] = [];
1510
1516
  for (const env of ordered) {
1511
1517
  const failedBefore = this.attempts.has(`${id}:${env.id}`);
1512
1518
  if (failedBefore && batch.length) break;
1513
1519
  batch.push(env);
1514
- if (failedBefore || remoteId(id) || batch.length === DIGEST_MAX) break;
1520
+ if (failedBefore || remoteId(id) || batch.length === digestMax) break;
1515
1521
  }
1516
1522
  if (remove) for (const env of batch) queue.splice(queue.indexOf(env), 1);
1517
1523
  return batch;
@@ -1523,13 +1529,14 @@ export class Bus {
1523
1529
  * envelope cannot stall the queue. An envelope that already failed once still goes alone, as in `take`.
1524
1530
  */
1525
1531
  private takeBudget(id: PeerId, queue: Envelope[]): Envelope[] {
1532
+ const pullBudgetBytes = this.opts.pullBudgetBytes?.() ?? PULL_BUDGET_BYTES;
1526
1533
  const batch: Envelope[] = [];
1527
1534
  let bytes = 0;
1528
1535
  for (const env of queue) {
1529
1536
  const failedBefore = this.attempts.has(`${id}:${env.id}`);
1530
1537
  if (failedBefore && batch.length) break;
1531
1538
  const size = Buffer.byteLength(frame(env)) + 2;
1532
- if (batch.length && bytes + size > PULL_BUDGET_BYTES) break;
1539
+ if (batch.length && bytes + size > pullBudgetBytes) break;
1533
1540
  batch.push(env); bytes += size;
1534
1541
  if (failedBefore) break;
1535
1542
  }
@@ -1555,11 +1562,12 @@ export class Bus {
1555
1562
  }
1556
1563
 
1557
1564
  private failed(id: PeerId, envs: Envelope[], reason = "delivery failed without an error detail", quota = false): void {
1565
+ const maxAttempts = this.opts.maxAttempts?.() ?? MAX_ATTEMPTS;
1558
1566
  if (!this.journal) this.deliveryFailed(id);
1559
1567
  // A quota refusal ran no work. The budget pause fences retries until reset; it is not a poison attempt.
1560
1568
  const quotaPaused = quota && this.paused.has(id);
1561
1569
  this.lastFailure.set(id, reason.replace(/\s+/g, " ").slice(0, 300));
1562
- if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= MAX_ATTEMPTS)) {
1570
+ if (!quotaPaused && envs.some((env) => !(env.from === HUB && env.kind === "presence") && (this.attempts.get(`${id}:${env.id}`) ?? 0) + 1 >= maxAttempts)) {
1563
1571
  this.failureStreak.set(id, (this.failureStreak.get(id) ?? 0) + 1);
1564
1572
  }
1565
1573
  const keep = envs.filter((env) => {
@@ -1571,7 +1579,7 @@ export class Bus {
1571
1579
  if (quotaPaused) return true;
1572
1580
  const key = `${id}:${env.id}`;
1573
1581
  const n = (this.attempts.get(key) ?? 0) + 1;
1574
- if (n < MAX_ATTEMPTS) return this.attempts.set(key, n);
1582
+ if (n < maxAttempts) return this.attempts.set(key, n);
1575
1583
  this.attempts.delete(key);
1576
1584
  this.emit({ t: "undeliverable", env, peer: id, reason: this.lastFailure.get(id) });
1577
1585
  return false;
@@ -194,6 +194,7 @@ const schema: Record<string, Check> = {
194
194
  conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
195
195
  budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
196
196
  context: block({ gate: number(0, 1), stale_min: timerMinutes, max_assignment_used: number(0, 1) }),
197
+ bus: block({ max_attempts: operationalNumberCheck("bus.max_attempts"), digest_max: operationalNumberCheck("bus.digest_max"), pull_budget_bytes: operationalNumberCheck("bus.pull_budget_bytes"), notes_kept: operationalNumberCheck("bus.notes_kept"), terminal_history_limit: operationalNumberCheck("bus.terminal_history_limit") }),
197
198
  inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
198
199
  omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
199
200
  mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
@@ -213,7 +214,7 @@ const schema: Record<string, Check> = {
213
214
  peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
214
215
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
215
216
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
216
- review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool }),
217
+ review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool, reconnect_wait_s: operationalNumberCheck("recovery.reconnect_wait_s"), unmanaged_reconnect_wait_s: operationalNumberCheck("recovery.unmanaged_reconnect_wait_s"), control_timeout_s: operationalNumberCheck("recovery.control_timeout_s"), shutdown_wait_s: operationalNumberCheck("recovery.shutdown_wait_s"), terminal_idle_wait_s: operationalNumberCheck("recovery.terminal_idle_wait_s") }),
217
218
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
218
219
  };
219
220
  export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
package/src/hub/daemon.ts CHANGED
@@ -81,6 +81,7 @@ import { childEnv } from "./child-process.ts";
81
81
  import { runCheck } from "./checks.ts";
82
82
  import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
83
83
  import { CONTRADICTION_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, GROOM_FYI_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_FAILURE_SHARE_SETTING, SPLIT_MIN_SETTING, taskOperationalReaders } from "./operational-settings.ts";
84
+ import { BUS_DIGEST_MAX_SETTING, BUS_MAX_ATTEMPTS_SETTING, BUS_NOTES_KEPT_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING, busOperationalReaders, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING } from "./operational-settings.ts";
84
85
  import { foldersConfig, scheduleViews, schedulesConfig, ScheduleStore, type FolderEntry, type ScheduleEntry } from "./schedules.ts";
85
86
  import { FolderStore, registerScanned, scanFolder, type FolderPolicy } from "./folder-trigger.ts";
86
87
  import type { ScannedFile } from "./schedules.ts";
@@ -155,7 +156,8 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
155
156
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig, type PiiVerdict } from "./inference.ts";
156
157
  import { taskEvidencePrivacy, type TaskEvidencePrivacy, ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
157
158
  import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
158
- import { NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
159
+ import { withChecklistLine } from "./learning.ts";
160
+ import { LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
159
161
  import { githubOriginRepository } from "./task-repository.ts";
160
162
  import { Bus } from "./bus.ts";
161
163
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
@@ -219,6 +221,8 @@ export interface HubConfig {
219
221
  batch_max: number;
220
222
  batch_ms: number;
221
223
  queue_cap: number;
224
+ /** #489 H3: accepted delivery policy, read at the next delivery operation without rewriting queued work. */
225
+ bus?: { max_attempts?: number; digest_max?: number; pull_budget_bytes?: number; notes_kept?: number; terminal_history_limit?: number };
222
226
  /** `native` (#258): the hub's own store in ~/.agenthub/memory, on with any project config; `short_ttl_days`: short-term lifetime after approval; `sync_interval_min`: machine sync while a remote is set (default 10, 0 off). */
223
227
  memory: { enabled: boolean; worker_url?: string; inject_tokens: number; brief_items: number; native?: boolean; short_ttl_days?: number; sync_interval_min?: number; suggest_edges?: boolean };
224
228
  roster: PeerRoster;
@@ -269,7 +273,8 @@ export interface HubConfig {
269
273
  /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
270
274
  review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean; same_family?: "fallback" | "never" };
271
275
  /** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
272
- recovery: { auto_resume_after_crash: boolean };
276
+ /** #489 H4: the five waits the upgrade coordinator reads from this project's config at the start of each wait; the daemon itself only validates and carries them. */
277
+ recovery: { auto_resume_after_crash: boolean; reconnect_wait_s?: number; unmanaged_reconnect_wait_s?: number; control_timeout_s?: number; shutdown_wait_s?: number; terminal_idle_wait_s?: number };
273
278
  /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
274
279
  capabilities: Record<string, string[]>;
275
280
  /**
@@ -297,6 +302,7 @@ export const DEFAULT_CONFIG: HubConfig = {
297
302
  batch_max: 3,
298
303
  batch_ms: 15_000,
299
304
  queue_cap: 200,
305
+ bus: { max_attempts: BUS_MAX_ATTEMPTS_SETTING.defaultValue, digest_max: BUS_DIGEST_MAX_SETTING.defaultValue, pull_budget_bytes: BUS_PULL_BUDGET_BYTES_SETTING.defaultValue, notes_kept: BUS_NOTES_KEPT_SETTING.defaultValue, terminal_history_limit: BUS_TERMINAL_HISTORY_LIMIT_SETTING.defaultValue },
300
306
  memory: { enabled: true, inject_tokens: 2000, brief_items: 8 },
301
307
  roster: {},
302
308
  roles: DEFAULT_ROLES,
@@ -324,7 +330,7 @@ export const DEFAULT_CONFIG: HubConfig = {
324
330
  terminal: { open: [] },
325
331
  limits: DEFAULT_LIMITS,
326
332
  review: { adaptive: false, min_reviews: 5 },
327
- recovery: { auto_resume_after_crash: false },
333
+ recovery: { auto_resume_after_crash: false, reconnect_wait_s: RECOVERY_RECONNECT_WAIT_SETTING.defaultValue, unmanaged_reconnect_wait_s: RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING.defaultValue, control_timeout_s: RECOVERY_CONTROL_TIMEOUT_SETTING.defaultValue, shutdown_wait_s: RECOVERY_SHUTDOWN_WAIT_SETTING.defaultValue, terminal_idle_wait_s: RECOVERY_TERMINAL_IDLE_WAIT_SETTING.defaultValue },
328
334
  capabilities: {},
329
335
  coordination: "advisory",
330
336
  };
@@ -339,7 +345,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
339
345
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
340
346
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
341
347
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
342
- const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
348
+ const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
343
349
 
344
350
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
345
351
  export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
@@ -532,6 +538,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
532
538
  notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
533
539
  },
534
540
  tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
541
+ bus: { ...DEFAULT_CONFIG.bus, ...file.bus },
535
542
  task_sweep: taskSweepConfig(file.task_sweep),
536
543
  schedules: schedulesConfig(file.schedules),
537
544
  folder_triggers: foldersConfig(file.folder_triggers),
@@ -911,6 +918,8 @@ export async function startDaemon(opts: DaemonOptions) {
911
918
  const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
912
919
  return typeof found === "string" && stale ? { key: `roster.${stale}`, group: "Roster", label: `${stale}: stale roster key (instance no longer declared)`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", stale] } : found;
913
920
  };
921
+ // #514b: undo resolves the managed checklist key, which is not a settings row, and every other key through the project-scoped lookup above (declared instances, the adapter catalog, a stale roster key).
922
+ const undoableDef = (key: unknown): SettingDef | string => key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key);
914
923
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
915
924
  const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
916
925
  let rosterChanging: string | undefined;
@@ -1172,7 +1181,8 @@ export async function startDaemon(opts: DaemonOptions) {
1172
1181
 
1173
1182
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
1174
1183
  let inference: Inference | undefined;
1175
- const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation });
1184
+ const { terminalHistoryLimit, ...busReaders } = busOperationalReaders(() => config);
1185
+ const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
1176
1186
  // What the crash left in flight, per recipient: opening the journal just marked these needs_review.
1177
1187
  const lost = new Map<PeerId, JournalDelivery[]>();
1178
1188
  if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
@@ -1292,7 +1302,7 @@ export async function startDaemon(opts: DaemonOptions) {
1292
1302
  const queuePolicy = () => queueBackpressurePolicy(config.tasks.queue_backpressure);
1293
1303
  let remotePending = (_room: string): readonly string[] => [];
1294
1304
  let remoteQueueChanged = () => {};
1295
- const bus: Bus = new Bus({ rostered: membershipAllowed, trackQueuePressure: () => { const policy = queuePolicy(); return policy.max_queued > 0 || policy.max_oldest_s > 0; }, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
1305
+ const bus: Bus = new Bus({ rostered: membershipAllowed, ...busReaders, trackQueuePressure: () => { const policy = queuePolicy(); return policy.max_queued > 0 || policy.max_oldest_s > 0; }, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => reviewOnlyPeer(peer) && !reviewOnlyItem(peer, env) ? `not for ${peer}: a review-only instance receives review requests only` : env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
1296
1306
  remotePending: room => remotePending(room),
1297
1307
  remotePrivate: (env): boolean => {
1298
1308
  const task = env.refs?.task;
@@ -3432,6 +3442,33 @@ export async function startDaemon(opts: DaemonOptions) {
3432
3442
  return JSON.stringify((await tasks.approveIntake(by, a.id)).map(task => tasks.publicView(task)));
3433
3443
  case "intake_discard":
3434
3444
  return JSON.stringify((await tasks.discardIntake(by, a.id)).map(task => tasks.publicView(task)));
3445
+ // #514b: the person's verdict on a recurring-criterion candidate. Person only (Tasks refuses any other `by`, and the generic gate above already refuses every non-person caller, the configured
3446
+ // conductor included: none of these is in CONDUCTOR_TASK_OPS). Accept writes ONE key, derived from the card's key line, through the settings writer.
3447
+ case "learn_list":
3448
+ return JSON.stringify(tasks.learnCandidates(by, { all: a.all === true }));
3449
+ case "learn_reject":
3450
+ return JSON.stringify(tasks.publicView(await tasks.learnClose(by, a.id, "rejected")));
3451
+ case "learn_accept": {
3452
+ // A card the person already accepted answers with a success that writes nothing: no config, no undo record, no event. A rejected or otherwise closed card falls through and is refused.
3453
+ const decided = tasks.learnAccepted(by, a.id);
3454
+ if (decided) return JSON.stringify({ card: tasks.publicView(board.get(decided.id)!), accepted: { profile: decided.profile, class: decided.class, criterion: decided.criterion, alreadyAccepted: true, file: ".agenthub/config.local.json" } });
3455
+ const plan = tasks.learnAcceptPlan(by, a.id);
3456
+ const stored = storedConfig();
3457
+ if (!stored) throw new PreEffectToolRefusal("the config files do not load; fix them in your editor first: nothing was written");
3458
+ const active = profileName(stored.profile);
3459
+ if (plan.profile !== active) throw new PreEffectToolRefusal(`this card is for the ${plan.profile} profile and the project's profile is ${active}: nothing was written; reject the card, or switch profile first`);
3460
+ // review_checklist replaces as a unit: extend the checklist the profile uses now (the person's, else the shipped one), never start from one line.
3461
+ const current = resolveProfile(stored.profile, stored.profile_overrides).review_checklist ?? [];
3462
+ const next = withChecklistLine(current, plan.line);
3463
+ if ("refused" in next) throw new PreEffectToolRefusal(next.refused);
3464
+ const already = next.list.length === current.length;
3465
+ if (!already) {
3466
+ const written = await settingsSet(LEARN_CHECKLIST.key, next.list, "terminal", undefined, undefined, `ahub learn accept #${plan.id}`, LEARN_CHECKLIST);
3467
+ if (written.ok !== true) throw new PreEffectToolRefusal(String(written.error ?? "the checklist could not be written") + "; the candidate stays open");
3468
+ }
3469
+ const closed = await tasks.learnClose(by, a.id, "accepted");
3470
+ return JSON.stringify({ card: tasks.publicView(closed), accepted: { profile: plan.profile, class: plan.class, criterion: plan.criterion, line: plan.line, tasks: plan.tasks, lines: next.list.length, alreadyPresent: already, file: ".agenthub/config.local.json", applies: "after a restart (profile contracts are bound at startup)", undo: already ? undefined : "ahub settings undo" } });
3471
+ }
3435
3472
  case "review_brief": {
3436
3473
  const task = board.get(Number(a.id));
3437
3474
  return task ? tasks.briefText(task) || `task #${task.id} names no commit, or is private: no brief` : `no task #${a.id}`;
@@ -6475,13 +6512,17 @@ export async function startDaemon(opts: DaemonOptions) {
6475
6512
  if (typeof reason !== "string" || !reason.trim() || Buffer.byteLength(reason) > 400) throw new Error("setting reason must contain 1..400 UTF-8 bytes");
6476
6513
  return sanitize(reason.trim());
6477
6514
  };
6478
- async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
6515
+ /** `managed` (#514b) is the learn command's own definition: a function argument, never read from a request, so no other key or caller can reach the managed row. */
6516
+ async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, managed?: SettingDef): Promise<Record<string, unknown>> {
6479
6517
  checkOperational(authority);
6480
6518
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
6481
6519
  let reason: string | undefined;
6482
6520
  try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
6483
6521
  if(authority==="conductor" && reason && !tasks.publicMetadataText(reason))return {ok:false,error:"setting reason withheld by privacy policy"};
6484
- const input = settingInput(key, raw);
6522
+ if (managed && (managed !== LEARN_CHECKLIST || authority !== "terminal")) return { ok: false, error: "a managed setting is written only by its own command, as the person" };
6523
+ let input: ReturnType<typeof settingInput>;
6524
+ if (managed) { try { input = { def: managed, value: checkSettingValue(managed, raw) }; } catch (error) { return { ok: false, error: (error as Error).message }; } }
6525
+ else input = settingInput(key, raw);
6485
6526
  if ("error" in input) return { ok: false, error: input.error };
6486
6527
  const { def, value } = input;
6487
6528
  const refusal = settingRefusal(def, value, authority);
@@ -6520,11 +6561,13 @@ export async function startDaemon(opts: DaemonOptions) {
6520
6561
  const result = await reloadConfig(authority, def.path.join("."));
6521
6562
  if (result.ok !== true) return { ...result, saved: true };
6522
6563
  const to = settingScalar(valueAt(storedConfig(), def.path));
6523
- recordSetting(def.key, from, to, source, false, reason);
6564
+ // A managed checklist is the person's own profile text: the event and the notice carry line counts, never the lines.
6565
+ const shown = (v: SettingValue): SettingValue => managed ? (Array.isArray(v) ? `${v.length} line(s)` : v === null ? "(not set)" : v) : v;
6566
+ recordSetting(def.key, shown(from), shown(to), source, false, reason);
6524
6567
  if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
6525
6568
  const timing = def.applies === "live" ? "in force now" : def.applies === "restart" ? "pending restart"
6526
6569
  : ["permission_modes", "catalog_roles", "peers"].includes(def.path[0]!) ? "applies at the peer's next start" : def.key === "coordination" ? "applies to new tasks" : "applies at next use";
6527
- return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
6570
+ return { ok: true, text: `${def.key}: ${settingText(shown(to))} (${timing})` };
6528
6571
  } catch (error) {
6529
6572
  return { ok: false, error: sanitize((error as Error).message), ...(error instanceof OperationalAuthorityError ? { authorityRefused: true, authorityScope: error.scope } : {}), ...(saved ? { saved: true } : {}) };
6530
6573
  }
@@ -6541,8 +6584,9 @@ export async function startDaemon(opts: DaemonOptions) {
6541
6584
  if (typeof expectedKey !== "string" || !expectedKey || typeof expectedDigest !== "string" || !/^[a-f0-9]{64}$/.test(expectedDigest)) return { ok: false, error: "invalid undo preview identity; preview again; nothing was undone" };
6542
6585
  if (!identity || identity.key !== expectedKey || identity.undoDigest !== expectedDigest) return { ok: false, error: "undo record changed since preview; preview again; nothing was undone" };
6543
6586
  }
6544
- const def = settingDef(identity?.key);
6587
+ const def = undoableDef(identity?.key);
6545
6588
  if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
6589
+ if (def.managed && authority !== "terminal") return { ok: false, error: `${def.key} was written by ahub learn accept: put it back with ahub settings undo in a terminal` };
6546
6590
  if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
6547
6591
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
6548
6592
  const read = (): SettingValue => def.store === "routing" ? routingValue(activeRouting(), def) : settingScalar(valueAt(storedConfig(), def.path));
@@ -6553,9 +6597,11 @@ export async function startDaemon(opts: DaemonOptions) {
6553
6597
  undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, def.path.join(".")), (_routing, overlay) => routingWriteCheck(authority, overlay, def.key), identity);
6554
6598
  const result = await reloadConfig(authority, def.store === "routing" ? def.key : def.path.join("."));
6555
6599
  if (result.ok !== true) return { ...result, saved: true };
6556
- recordSetting(def.key, from, read(), settingSource(authority), true, reason);
6600
+ // A managed checklist is the person's own profile text: the event, the notice and the reply carry its line count, never the lines (the same form as the write).
6601
+ const shown = (v: SettingValue): SettingValue => def.managed ? (Array.isArray(v) ? `${v.length} line(s)` : v === null ? "(not set)" : v) : v;
6602
+ recordSetting(def.key, shown(from), shown(read()), settingSource(authority), true, reason);
6557
6603
  if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
6558
- return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
6604
+ return { ok: true, text: `${def.key}: put back to ${settingText(shown(read()))}` };
6559
6605
  } catch (error) {
6560
6606
  return { ok: false, error: (error as Error).message };
6561
6607
  }
@@ -6594,8 +6640,9 @@ export async function startDaemon(opts: DaemonOptions) {
6594
6640
  }
6595
6641
 
6596
6642
  function settingsUndoPreview(): Record<string, unknown> {
6597
- const identity = pendingUndoIdentity(opts.stateDir), def = settingDef(identity?.key);
6643
+ const identity = pendingUndoIdentity(opts.stateDir), def = undoableDef(identity?.key);
6598
6644
  if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
6645
+ if (def.managed) return { ok: true, ...identity, lines: [`Undo ${def.key}: put back the previous machine-local file (the review checklist ahub learn accept changed; its lines are not shown here)`, "The saved previous machine-local file is revalidated before writing."] };
6599
6646
  const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
6600
6647
  if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
6601
6648
  return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
@@ -20,8 +20,8 @@ export type JournalDeliveryState =
20
20
  | "discarded";
21
21
  export type JournalResolution = "completed" | "retry" | "discard";
22
22
  export const MAX_ENVELOPE_BODY = 1_000_000;
23
+ export const TERMINAL_CAP = 2048;
23
24
  const SCHEMA_VERSION = 1,
24
- TERMINAL_CAP = 2048,
25
25
  MAX_ROWS = 100_000,
26
26
  MAX_JSON = 20 * 1024 * 1024;
27
27
  const STATES = new Set<JournalDeliveryState>([
@@ -56,6 +56,8 @@ export interface DeliveryJournalOptions {
56
56
  projectId: string;
57
57
  instanceId: string;
58
58
  operationId?: string;
59
+ /** Accepted live history target; only terminal mutations apply it, other pruning keeps the legacy cap. */
60
+ terminalHistoryLimit?: () => number;
59
61
  }
60
62
  export interface JournalSnapshot {
61
63
  schemaVersion: number;
@@ -271,13 +273,16 @@ export class DeliveryJournal {
271
273
  private closed = false;
272
274
  private queueMetadataTracking = false;
273
275
  private queueMetadataInvalidated = false;
276
+ private pruningDeferred = 0;
274
277
  private readonly operationId?: string;
278
+ private readonly terminalHistoryLimit?: () => number;
275
279
  constructor(options: DeliveryJournalOptions) {
276
280
  this.file = options.file;
277
281
  this.projectRoot = text(options.projectRoot, "projectRoot", 4096);
278
282
  this.projectId = text(options.projectId, "projectId", 256);
279
283
  this.instanceId = text(options.instanceId, "instanceId", 256);
280
284
  this.operationId = options.operationId;
285
+ this.terminalHistoryLimit = options.terminalHistoryLimit;
281
286
  mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
282
287
  this.db = new Database(options.file, { create: true });
283
288
  this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
@@ -704,53 +709,56 @@ export class DeliveryJournal {
704
709
  updatedAt: delivery.createdAt,
705
710
  });
706
711
  return this.queueMetadataTransaction(trackQueuePressure, () => {
707
- const evidence = this.retryEvidence(delivery.peer, delivery.originals);
708
- if (evidence === "used") {
709
- // Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
710
- this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
711
- delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
712
- } else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
713
- const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
714
- for (const row of this.list(delivery.peer)) {
715
- if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
716
- this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
717
- if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
718
- delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
719
- if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
712
+ // Grouped parents are not linked until the handoff INSERT.
713
+ return this.withPruningDeferred(() => {
714
+ const evidence = this.retryEvidence(delivery.peer, delivery.originals);
715
+ if (evidence === "used") {
716
+ // Inheritance commits with the handoff, before safe failure or pruning can erase a grouped parent.
717
+ this.recordRetryEvidence(delivery.peer, delivery.originals, delivery.id, this.retryWarning(delivery.peer, delivery.originals));
718
+ delivery = { ...delivery, attempt: Math.max(1, delivery.attempt ?? 0) };
719
+ } else if (evidence === "unknown") this.markRetryUnknown([{ ...delivery, revision: 0, updatedAt: delivery.createdAt }], {});
720
+ const ids = new Set(delivery.originals.map((env) => env.id)), immutableIds = new Set(retryOriginals(delivery.originals).map(env => env.id));
721
+ for (const row of this.list(delivery.peer)) {
722
+ if (row.state !== "queued" || !row.originals.some((env) => ids.has(env.id))) continue;
723
+ this.transition(row.id, "discarded", `grouped into delivery ${delivery.id}`);
724
+ if (retryOriginals(row.originals).some(env => immutableIds.has(env.id))) {
725
+ delivery = { ...delivery, previousId: delivery.previousId ?? row.id };
726
+ if ((row.attempt ?? 0) > 0) delivery = { ...delivery, attempt: Math.max(delivery.attempt ?? 0, row.attempt!) };
727
+ }
720
728
  }
721
- }
722
- const revision = this.bump();
723
- const now = Date.now();
724
- this.db
725
- .query(
726
- "UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
727
- )
728
- .run(
729
- JSON.stringify(bus),
730
- JSON.stringify(bus.manualPaused ?? []),
731
- this.projectId,
732
- );
733
- this.writeQueueMetadata(bus, revision, trackQueuePressure);
734
- this.db
735
- .query(
736
- "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
737
- )
738
- .run(
739
- delivery.id,
740
- this.projectId,
741
- delivery.peer,
742
- delivery.state,
743
- revision,
744
- delivery.createdAt,
745
- now,
746
- JSON.stringify(delivery.originals),
747
- JSON.stringify(delivery.out),
748
- delivery.reason ?? null,
749
- delivery.attempt ?? null,
750
- delivery.previousId ?? null,
751
- );
752
- this.harden();
753
- return { ...delivery, revision, updatedAt: now };
729
+ const revision = this.bump();
730
+ const now = Date.now();
731
+ this.db
732
+ .query(
733
+ "UPDATE delivery_meta SET bus_snapshot = ?, manual_paused = ? WHERE project_id = ?",
734
+ )
735
+ .run(
736
+ JSON.stringify(bus),
737
+ JSON.stringify(bus.manualPaused ?? []),
738
+ this.projectId,
739
+ );
740
+ this.writeQueueMetadata(bus, revision, trackQueuePressure);
741
+ this.db
742
+ .query(
743
+ "INSERT INTO deliveries (id, project_id, peer, state, revision, created_at, updated_at, originals, out_json, reason, attempt, previous_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
744
+ )
745
+ .run(
746
+ delivery.id,
747
+ this.projectId,
748
+ delivery.peer,
749
+ delivery.state,
750
+ revision,
751
+ delivery.createdAt,
752
+ now,
753
+ JSON.stringify(delivery.originals),
754
+ JSON.stringify(delivery.out),
755
+ delivery.reason ?? null,
756
+ delivery.attempt ?? null,
757
+ delivery.previousId ?? null,
758
+ );
759
+ this.harden();
760
+ return { ...delivery, revision, updatedAt: now };
761
+ });
754
762
  });
755
763
  }
756
764
  createDelivery(
@@ -785,7 +793,7 @@ export class DeliveryJournal {
785
793
  input.attempt ?? null,
786
794
  input.previousId ?? null,
787
795
  );
788
- this.pruneTerminal();
796
+ this.pruneTerminal(input.state, input.id);
789
797
  return { ...input, revision, updatedAt };
790
798
  })();
791
799
  }
@@ -802,9 +810,14 @@ export class DeliveryJournal {
802
810
  return this.db.transaction((): JournalDelivery => {
803
811
  const row = this.get(deliveryId);
804
812
  if (!row || row.peer !== peer || !["dispatching", "accepted"].includes(row.state)) throw new Error("delivery is not active for this peer");
805
- const completed = this.transition(deliveryId, "completed", reason, row.revision);
806
- const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
807
- for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
813
+ const completed = this.withPruningDeferred(() => {
814
+ const result = this.transition(deliveryId, "completed", reason, row.revision);
815
+ const put = this.db.query("INSERT OR IGNORE INTO task_offer_reads (project_id,peer,envelope_id,delivery_id,source,offer_revision) VALUES (?,?,?,?,?,?)");
816
+ for (const env of row.originals) if (env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.taskOffer) && env.refs!.taskOffer! >= 1) put.run(this.projectId, peer, env.id, deliveryId, source, env.refs!.taskOffer!);
817
+ return result;
818
+ });
819
+ // Proof is now durable in this transaction, and the return receipt is captured.
820
+ this.pruneTerminal("completed", "");
808
821
  return completed;
809
822
  })();
810
823
  }
@@ -834,7 +847,7 @@ export class DeliveryJournal {
834
847
  "UPDATE deliveries SET state = ?, revision = ?, updated_at = ?, reason = ? WHERE id = ? AND project_id = ?",
835
848
  )
836
849
  .run(state, revision, Date.now(), reason ?? null, id, this.projectId);
837
- this.pruneTerminal();
850
+ this.pruneTerminal(state, id);
838
851
  return this.get(id)!;
839
852
  })();
840
853
  }
@@ -1023,7 +1036,7 @@ export class DeliveryJournal {
1023
1036
  JSON.stringify(retryOriginals(current.originals).map(env => env.id)),
1024
1037
  );
1025
1038
  if (action === "retry") this.recordRetryEvidence(current.peer, current.originals, id, actor === `peer:${current.peer}`);
1026
- this.pruneTerminal();
1039
+ this.pruneTerminal(result.state, result.id);
1027
1040
  return result;
1028
1041
  })();
1029
1042
  }
@@ -1031,7 +1044,15 @@ export class DeliveryJournal {
1031
1044
  const row = this.db.query("SELECT action,reason,observed_revision,actor FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number; actor: string } | null;
1032
1045
  return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision, actor: row.actor } : undefined;
1033
1046
  }
1034
- private pruneTerminal(): void {
1047
+ private withPruningDeferred<T>(body: () => T): T {
1048
+ this.pruningDeferred++;
1049
+ try { return body(); } finally { this.pruningDeferred--; }
1050
+ }
1051
+ private pruneTerminal(state: JournalDeliveryState, currentId: string): void {
1052
+ if (this.pruningDeferred) return;
1053
+ const terminal = state === "completed" || state === "discarded" || state === "failed";
1054
+ const terminalCap = terminal ? this.terminalHistoryLimit?.() ?? TERMINAL_CAP : TERMINAL_CAP;
1055
+ // Never delete the receipt this mutation/resolve is still using, at any target.
1035
1056
  const count = Number(
1036
1057
  (
1037
1058
  this.db
@@ -1041,12 +1062,12 @@ export class DeliveryJournal {
1041
1062
  .get(this.projectId) as { n: number }
1042
1063
  ).n,
1043
1064
  );
1044
- if (count <= TERMINAL_CAP) return;
1065
+ if (count <= terminalCap) return;
1045
1066
  this.db
1046
1067
  .query(
1047
- "DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
1068
+ "DELETE FROM deliveries WHERE id IN (SELECT d.id FROM deliveries d WHERE d.project_id = ? AND d.state IN ('completed','discarded','failed') AND d.id != ? AND NOT EXISTS (SELECT 1 FROM deliveries c WHERE c.project_id = d.project_id AND c.previous_id = d.id) ORDER BY d.updated_at, d.id LIMIT ?)",
1048
1069
  )
1049
- .run(this.projectId, count - TERMINAL_CAP);
1070
+ .run(this.projectId, currentId, count - terminalCap);
1050
1071
  }
1051
1072
  importSnapshot(snapshot: JournalSnapshot, operationId: string, trackQueuePressure = false): boolean {
1052
1073
  this.ensureOpen();
@@ -78,8 +78,40 @@ export function candidateCard(group: CriterionGroup, windowDays: number, workUni
78
78
  detail: [
79
79
  criterionKey(group),
80
80
  `Criterion ${group.criterion} was named unmet ${group.count} time(s) on ${group.tasks.length} ${group.profile}/${group.class} task(s) in the last ${windowDays} days (${workUnits} separate piece(s) of work): ${group.tasks.slice(0, 8).map(id => `#${id}`).join(", ")}.`,
81
- "This is a candidate for a person to review. Nothing was applied, and nothing in the hub writes it. profile_overrides is not machine-local: it is read from the shared .agenthub/config.json (tracked, so a change there reaches every checkout) and from .agenthub/config.local.json (this machine only), and the layers merge. Put it where you want it to apply. If you agree, add the line below to profile_overrides.review_checklist; that list replaces as a unit, so add it to your current checklist, do not paste it over it:",
81
+ "This is a candidate for a person to review. Nothing was applied: the hub writes it only when the person runs ahub learn accept <id> (to .agenthub/config.local.json, this machine only, added to your current checklist; ahub settings undo puts it back), or ahub learn reject <id> to close this card. By hand: profile_overrides is not machine-local: it is read from the shared .agenthub/config.json (tracked, so a change there reaches every checkout) and from .agenthub/config.local.json (this machine only), and the layers merge. Put it where you want it to apply. If you agree, add the line below to profile_overrides.review_checklist; that list replaces as a unit, so add it to your current checklist, do not paste it over it:",
82
82
  JSON.stringify(diff, null, 2),
83
83
  ].join("\n"),
84
84
  };
85
85
  }
86
+
87
+ /** The cap `resolveProfile` puts on a review checklist (`profiles.ts`): the learn writer never builds a list the loader would refuse. */
88
+ export const CHECKLIST_MAX_LINES = 16;
89
+ const PROFILE = /^[a-z][a-z0-9_-]{0,31}$/, CLASS = /^[a-z][a-z0-9_]{0,47}$/, CRITERION = /^[A-Za-z][A-Za-z0-9-]{0,47}$/;
90
+
91
+ /**
92
+ * What `ahub learn accept` may act on (#514b), read from a card the hub itself filed. ONLY the key line is read: `Criterion candidate key: <profile>:<class>:<criterion>`, as the
93
+ * FIRST line of the detail, on a backlog card the hub filed in the `plan` class. The three parts are re-checked against the shapes the loaders accept, and the line to write is
94
+ * REGENERATED from them by `overridesDiff` with a count the caller recomputes from the board, so no text of the card (the diff it prints, a count it states, a title) is ever
95
+ * parsed as a value or written. A card that does not match is refused with a reason, never half read.
96
+ */
97
+ export type CandidateCard = { profile: string; class: string; criterion: string } | { refused: string };
98
+ export function parseCandidateCard(task: Pick<Task, "class" | "detail" | "history" | "title">): CandidateCard {
99
+ if (task.class !== "plan" || task.history[0]?.by !== "hub") return { refused: "not a candidate the hub filed" };
100
+ const first = task.detail.split("\n", 1)[0] ?? "";
101
+ const match = /^Criterion candidate key: ([^:\s]+):([^:\s]+):([^:\s]+)$/.exec(first);
102
+ if (!match) return { refused: "not a recurring-criterion candidate (no key line first)" };
103
+ const [, profile, cls, criterion] = match as unknown as [string, string, string, string];
104
+ if (!PROFILE.test(profile) || !CLASS.test(cls) || !CRITERION.test(criterion)) return { refused: "the key line names a profile, class or criterion the loaders would not accept" };
105
+ if (task.title !== `Review checklist candidate for ${profile}/${cls} criterion ${criterion}`) return { refused: "the card's title does not match its key line" };
106
+ return { profile, class: cls, criterion };
107
+ }
108
+
109
+ /**
110
+ * The checklist to store after accepting: the person's CURRENT checklist (what the profile uses now) with the one regenerated line added, because `review_checklist` replaces as a
111
+ * unit. The line is skipped when it is already there, so a repeated accept writes nothing new. Refused, not trimmed, when the list would pass `CHECKLIST_MAX_LINES`.
112
+ */
113
+ export function withChecklistLine(current: readonly string[], line: string): { list: string[] } | { refused: string } {
114
+ if (current.includes(line)) return { list: [...current] };
115
+ if (current.length + 1 > CHECKLIST_MAX_LINES) return { refused: `the checklist already has ${current.length} lines and the profile allows ${CHECKLIST_MAX_LINES}; remove one in .agenthub/config.local.json first` };
116
+ return { list: [...current, line] };
117
+ }