@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
@@ -57,8 +57,60 @@ export const SPLIT_FAILURE_SHARE_SETTING = taskNumber({
57
57
  type: "number", min: 0, max: 1, defaultValue: 0.3, unit: "share", inventory: "src/hub/routing.ts:SPLIT_FAILURE_SHARE",
58
58
  });
59
59
 
60
+ const busNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies" | "type" | "min">>(def: T) => ({ group: "Routing", store: "config", risk: "raises", applies: "live", type: "integer", min: 1, ...def }) as const satisfies OperationalNumberSetting;
61
+
62
+ export const BUS_MAX_ATTEMPTS_SETTING = busNumber({
63
+ key: "bus.max_attempts", path: ["bus", "max_attempts"], label: "Safe-failure delivery attempts",
64
+ max: 100, defaultValue: 3, unit: "attempts", inventory: "src/hub/bus.ts:MAX_ATTEMPTS",
65
+ });
66
+ export const BUS_DIGEST_MAX_SETTING = busNumber({
67
+ key: "bus.digest_max", path: ["bus", "digest_max"], label: "Maximum originals in a push digest",
68
+ max: 10, defaultValue: 10, unit: "envelopes", inventory: "src/hub/bus.ts:DIGEST_MAX",
69
+ });
70
+ export const BUS_PULL_BUDGET_BYTES_SETTING = busNumber({
71
+ key: "bus.pull_budget_bytes", path: ["bus", "pull_budget_bytes"], label: "Pull rendered-text budget (bytes)",
72
+ max: 65_536, defaultValue: 65_536, unit: "bytes", inventory: "src/hub/bus.ts:PULL_BUDGET_BYTES",
73
+ });
74
+ export const BUS_NOTES_KEPT_SETTING = busNumber({
75
+ key: "bus.notes_kept", path: ["bus", "notes_kept"], label: "Shared notes retained in a merged preface",
76
+ max: 10, defaultValue: 10, unit: "notes", inventory: "src/hub/bus.ts:NOTES_KEPT",
77
+ });
78
+ export const BUS_TERMINAL_HISTORY_LIMIT_SETTING = busNumber({
79
+ key: "bus.terminal_history_limit", path: ["bus", "terminal_history_limit"], label: "Unprotected terminal delivery history target",
80
+ max: 2048, defaultValue: 2048, unit: "rows", inventory: "src/hub/delivery-journal.ts:TERMINAL_CAP",
81
+ });
82
+
83
+ /**
84
+ * #489 H4, recovery and upgrade waits. These are read by the upgrade coordinator (a CLI process) from the project's own config at the START of each wait, so a change applies at the next wait and a wait
85
+ * already running keeps its deadline. Every row's minimum is today's constant: a person-visible wait can be raised, never cut below what ships (a request that times out may already have applied, and a
86
+ * shorter reconnect wait fails a recovery earlier than the person was told). The one row that may shorten, `recovery.terminal_idle_wait_s`, only ends a wait in manual-required, which retains the terminal.
87
+ */
88
+ const recoveryNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies" | "type">>(def: T) => ({ group: "Routing", store: "config", risk: "raises", applies: "next use", type: "integer", ...def }) as const satisfies OperationalNumberSetting;
89
+ export const RECOVERY_RECONNECT_WAIT_SETTING = recoveryNumber({
90
+ key: "recovery.reconnect_wait_s", path: ["recovery", "reconnect_wait_s"], label: "Upgrade recovery: wait for a managed terminal to attach (seconds)",
91
+ min: 600, max: 3600, defaultValue: 600, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:RECONNECT_WAIT_MS",
92
+ });
93
+ export const RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING = recoveryNumber({
94
+ key: "recovery.unmanaged_reconnect_wait_s", path: ["recovery", "unmanaged_reconnect_wait_s"], label: "Upgrade recovery: wait for an unmanaged Claude to reconnect (seconds)",
95
+ min: 90, max: 900, defaultValue: 90, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:UNMANAGED_RECONNECT_WAIT_MS",
96
+ });
97
+ export const RECOVERY_CONTROL_TIMEOUT_SETTING = recoveryNumber({
98
+ key: "recovery.control_timeout_s", path: ["recovery", "control_timeout_s"], label: "Upgrade recovery: wait for one control request to a hub (seconds)",
99
+ min: 30, max: 300, defaultValue: 30, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:RECOVERY_CONTROL_TIMEOUT_MS",
100
+ });
101
+ export const RECOVERY_SHUTDOWN_WAIT_SETTING = recoveryNumber({
102
+ key: "recovery.shutdown_wait_s", path: ["recovery", "shutdown_wait_s"], label: "Upgrade recovery: wait for the old hub to stop after commit (seconds)",
103
+ min: 30, max: 300, defaultValue: 30, unit: "seconds", inventory: "src/cli/upgrade.ts:deadline",
104
+ });
105
+ export const RECOVERY_TERMINAL_IDLE_WAIT_SETTING = recoveryNumber({
106
+ key: "recovery.terminal_idle_wait_s", path: ["recovery", "terminal_idle_wait_s"], label: "Upgrade recovery: wait for a terminal to go idle before closing it (seconds)",
107
+ min: 30, max: 600, defaultValue: 120, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:waitForIdle",
108
+ });
109
+
60
110
  /** Fixed metadata; never registration from a file, request, adapter or environment variable. */
61
- export const OPERATIONAL_NUMBERS: readonly OperationalNumberSetting[] = [GROOM_FYI_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, CONTRADICTION_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_MIN_SETTING, SPLIT_FAILURE_SHARE_SETTING];
111
+ export const OPERATIONAL_NUMBERS: readonly OperationalNumberSetting[] = [GROOM_FYI_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, CONTRADICTION_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_MIN_SETTING, SPLIT_FAILURE_SHARE_SETTING,
112
+ BUS_MAX_ATTEMPTS_SETTING, BUS_DIGEST_MAX_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_NOTES_KEPT_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING,
113
+ RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING];
62
114
  /** The `tasks` leaves the six #489 H2 readers need; `HubConfig.tasks` has them all. */
63
115
  export interface TaskOperationalConfig { tasks: { demote_at?: number; demotion_half_life_h?: number; contradiction_window_days?: number; rescreen_max?: number; split_min_observations?: number; split_max_failure_share?: number } }
64
116
  /**
@@ -73,4 +125,30 @@ export const taskOperationalReaders = (config: () => TaskOperationalConfig) => (
73
125
  splitMinObservations: () => config().tasks.split_min_observations ?? SPLIT_MIN_SETTING.defaultValue,
74
126
  splitMaxFailureShare: () => config().tasks.split_max_failure_share ?? SPLIT_FAILURE_SHARE_SETTING.defaultValue,
75
127
  });
128
+ export interface BusOperationalConfig { bus?: { max_attempts?: number; digest_max?: number; pull_budget_bytes?: number; notes_kept?: number; terminal_history_limit?: number } }
129
+ /** Read accepted configuration at the next operation; a reload itself never trims or retries work. */
130
+ export const busOperationalReaders = (config: () => BusOperationalConfig) => ({
131
+ maxAttempts: () => config().bus?.max_attempts ?? BUS_MAX_ATTEMPTS_SETTING.defaultValue,
132
+ digestMax: () => config().bus?.digest_max ?? BUS_DIGEST_MAX_SETTING.defaultValue,
133
+ pullBudgetBytes: () => config().bus?.pull_budget_bytes ?? BUS_PULL_BUDGET_BYTES_SETTING.defaultValue,
134
+ notesKept: () => config().bus?.notes_kept ?? BUS_NOTES_KEPT_SETTING.defaultValue,
135
+ terminalHistoryLimit: () => config().bus?.terminal_history_limit ?? BUS_TERMINAL_HISTORY_LIMIT_SETTING.defaultValue,
136
+ });
137
+ export interface RecoveryOperationalConfig { recovery?: { reconnect_wait_s?: unknown; unmanaged_reconnect_wait_s?: unknown; control_timeout_s?: unknown; shutdown_wait_s?: unknown; terminal_idle_wait_s?: unknown } }
138
+ export interface RecoveryWaits { reconnectWaitMs: number; unmanagedReconnectWaitMs: number; controlTimeoutMs: number; shutdownWaitMs: number; terminalIdleWaitMs: number }
139
+ const recoveryMs = (def: OperationalNumberSetting, raw: unknown): number => {
140
+ // A value outside the row's bounds, a non-integer or a non-number is the default, never an error inside a recovery: the file was validated when it was written, and a recovery must not fail on a later edit.
141
+ return typeof raw === "number" && Number.isSafeInteger(raw) && raw >= def.min && raw <= def.max ? raw * 1000 : def.defaultValue * 1000;
142
+ };
143
+ /** The one place the coordinator turns the five recovery settings into milliseconds. `config` is asked at each wait, so the next wait reads the file as it is then. */
144
+ export const recoveryWaits = (config: () => RecoveryOperationalConfig | undefined): RecoveryWaits => {
145
+ const leaf = config()?.recovery;
146
+ return {
147
+ reconnectWaitMs: recoveryMs(RECOVERY_RECONNECT_WAIT_SETTING, leaf?.reconnect_wait_s),
148
+ unmanagedReconnectWaitMs: recoveryMs(RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING, leaf?.unmanaged_reconnect_wait_s),
149
+ controlTimeoutMs: recoveryMs(RECOVERY_CONTROL_TIMEOUT_SETTING, leaf?.control_timeout_s),
150
+ shutdownWaitMs: recoveryMs(RECOVERY_SHUTDOWN_WAIT_SETTING, leaf?.shutdown_wait_s),
151
+ terminalIdleWaitMs: recoveryMs(RECOVERY_TERMINAL_IDLE_WAIT_SETTING, leaf?.terminal_idle_wait_s),
152
+ };
153
+ };
76
154
  export const operationalNumber = (key: string): OperationalNumberSetting | undefined => OPERATIONAL_NUMBERS.find(def => def.key === key);
@@ -28,7 +28,9 @@ export interface SettingDef {
28
28
  key: string;
29
29
  group: "Permissions" | "Start" | "Routing" | "Switches" | "Roster" | "Roles";
30
30
  label: string;
31
- type: "enum" | "boolean" | "peers" | "roles" | "integer" | "number";
31
+ type: "enum" | "boolean" | "peers" | "roles" | "integer" | "number" | "lines";
32
+ /** Written only by the named hub command, never by `settings set`, a dashboard session or a conductor (#514b). */
33
+ managed?: "learn";
32
34
  min?: number; max?: number;
33
35
  /** The closed values of an enum; a route setting takes the project's own route ids instead. */
34
36
  values?: readonly string[];
@@ -87,6 +89,15 @@ export const SETTINGS: readonly SettingDef[] = [
87
89
  */
88
90
  export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "schedules", "folder_triggers", "corpus", "rooms", "instances", "adapters", "adapter_modules", "local.doc_read", "local.web_fetch"] as const;
89
91
 
92
+ /**
93
+ * The one key `ahub learn accept` writes (#514b): the active profile's review checklist, which replaces as a unit. It is NOT a row of `SETTINGS` and `settingDef` does NOT resolve it, so
94
+ * `settings set|get|list`, the dashboard, a settings session and a conductor all see an unknown setting and nothing about them changed. Only the daemon's learn operation passes this
95
+ * definition to `settingsSet` itself (a function argument no request field can reach), so the write still goes through the same writer, loader check, reload and undo as every other setting.
96
+ */
97
+ export const LEARN_CHECKLIST: SettingDef = Object.freeze({ key: "profile_overrides.review_checklist", group: "Routing", label: "Profile review checklist (written by ahub learn accept)", type: "lines", managed: "learn",
98
+ store: "config", applies: "restart", restartReason: "Session tool schemas and profile contracts are bound at startup", risk: "raises", path: Object.freeze(["profile_overrides", "review_checklist"]) });
99
+ const LEARN_LINES_MAX = 16, LEARN_LINE_MAX = 500;
100
+
90
101
  /** Derive project-bound rows from the same builtin templates used by lookup, without registering aliases globally. */
91
102
  export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): readonly SettingDef[] {
92
103
  const resolve = adapterCatalog?.definitionOf ?? definitionOf;
@@ -143,6 +154,11 @@ export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAda
143
154
  return extra.length ? Object.freeze([...settings, ...extra]) : settings;
144
155
  }
145
156
 
157
+ /** What undo may put back: a registry key, or the one managed key `ahub learn accept` writes. Never used to set anything. The CLI passes its project scope; the daemon has its own wrapper over its project-scoped lookup. */
158
+ export function undoableDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
159
+ return key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key, instances, adapterCatalog);
160
+ }
161
+
146
162
  /** The definition of a key, or why there is none. */
147
163
  export function settingDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
148
164
  if (typeof key !== "string" || key.length > 80) return "unknown setting";
@@ -163,6 +179,10 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
163
179
  if (typeof raw !== "number" || !(def.type === "integer" ? Number.isSafeInteger(raw) : Number.isFinite(raw)) || raw < (def.min ?? 0) || raw > (def.max ?? Number.MAX_SAFE_INTEGER)) throw new Error(`${def.key} takes ${def.type === "integer" ? "an integer" : "a number"} from ${def.min ?? 0} to ${def.max ?? Number.MAX_SAFE_INTEGER}`);
164
180
  return raw;
165
181
  }
182
+ if (def.type === "lines") {
183
+ if (!Array.isArray(raw) || !raw.length || raw.length > LEARN_LINES_MAX || raw.some(line => typeof line !== "string" || !line.trim() || line.length > LEARN_LINE_MAX || /[\x00-\x1f\x7f]/.test(line))) throw new Error(`${def.key} takes one to ${LEARN_LINES_MAX} lines of plain text, each at most ${LEARN_LINE_MAX} characters`);
184
+ return raw.map(line => line.trim());
185
+ }
166
186
  if (def.type === "boolean") {
167
187
  if (typeof raw !== "boolean") throw new Error(`${def.key} takes true or false`);
168
188
  return raw;
package/src/hub/tasks.ts CHANGED
@@ -35,7 +35,16 @@ import type { MemoryScreen } from "./read-tools.ts";
35
35
  import { withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
36
36
  import { profileClass, profileData, profileTaskLabel, projectProfile, type ProfileName, type ProfileSelection, type ProjectProfile } from "./profiles.ts";
37
37
  import { criteriaView, unmetCriteriaIds, workflowHash } from "./workflow.ts";
38
- import { candidateCard, criterionKey, groupUnmetCriteria, LEARNING_DEFAULTS, selectGroups, type CriterionGroup } from "./learning.ts";
38
+ import { candidateCard, criterionKey, groupUnmetCriteria, LEARNING_DEFAULTS, overridesDiff, parseCandidateCard, selectGroups, type CriterionGroup } from "./learning.ts";
39
+ /** #514b: how a closed candidate card says what the person decided; `learnCandidates` reads them back. */
40
+ const LEARN_ACCEPTED = "candidate accepted", LEARN_REJECTED = "candidate rejected";
41
+ /** What the person decided on a candidate card, read from its cancellation: only an entry the person wrote counts, so no other note can pass for a verdict. Undefined while it is open or closed for another reason. */
42
+ const learnVerdict = (card: Pick<Task, "state" | "history">): "accepted" | "rejected" | undefined => {
43
+ if (card.state !== "cancelled") return undefined;
44
+ const closed = card.history.findLast(entry => entry.event === "cancelled");
45
+ if (!closed || closed.by !== USER) return undefined;
46
+ return closed.note?.startsWith(LEARN_ACCEPTED) ? "accepted" : closed.note?.startsWith(LEARN_REJECTED) ? "rejected" : undefined;
47
+ };
39
48
  import { approvedDeliverables, latestDeliverables, parseDeliverableClaims, publicDeliverables, type DeliverableRecord, type DeliverableStatus } from "./deliverables.ts";
40
49
  import { checkDeliverables, DeliverableRefusal, hashDeliverables, vetDeliverables, type DeliverablePorts } from "./deliverable-files.ts";
41
50
  import { isAbsolute, relative } from "node:path";
@@ -706,6 +715,74 @@ export class Tasks {
706
715
  }
707
716
  return new Set(rows.map(row => find(row.id))).size;
708
717
  }
718
+ /**
719
+ * #514b: the person's view of the recurring-criterion candidates (`ahub learn list`). The person only: a conductor or an agent gets a refusal, never a list. A card that does not parse as the
720
+ * hub's own is not listed. Ids, counts and the key parts only: the card's text is never echoed, and a PII row is never reached because the cards are already screened when filed.
721
+ */
722
+ learnCandidates(by: PeerId, opts: { all?: boolean } = {}): Record<string, unknown>[] {
723
+ if (by !== USER) throw new PreEffectToolRefusal("ahub learn is a person's command");
724
+ const rows: Record<string, unknown>[] = [];
725
+ for (const card of this.d.board.list()) {
726
+ const parsed = parseCandidateCard(card);
727
+ if ("refused" in parsed) continue;
728
+ const open = card.state === "backlog" || card.state === "proposed";
729
+ const closed = card.history.findLast(entry => entry.event === "cancelled");
730
+ const verdict = learnVerdict(card) ?? (closed ? "closed" : undefined);
731
+ if (!open && !opts.all) continue;
732
+ if (!open && !closed) continue;
733
+ rows.push({ id: card.id, state: card.state, ...parsed, status: open ? "open" : verdict, tasks: this.learnEvidence(parsed).tasks, line: overridesDiff({ ...parsed, tasks: this.learnEvidence(parsed).tasks }).profile_overrides.review_checklist[0] });
734
+ }
735
+ return rows;
736
+ }
737
+ /** The distinct tasks that name this criterion unmet on this profile and class in the window, from the board as it is now: the count the written line states is recomputed, never read from the card. */
738
+ private learnEvidence(card: { profile: string; class: string; criterion: string }): { tasks: number[] } {
739
+ const eligible = this.d.board.list().filter(row => this.candidateTextAllowed(`${row.profile ?? ""} ${row.profileClass ?? ""} ${row.class}`, row));
740
+ const group = groupUnmetCriteria(eligible, { windowMs: LEARNING_DEFAULTS.windowMs, now: Date.now() }).find(g => g.profile === card.profile && g.class === card.class && g.criterion === card.criterion);
741
+ return { tasks: group?.tasks ?? [] };
742
+ }
743
+ /**
744
+ * #514b: what `ahub learn accept` would write for card `id`, or why not. Person only. It reads ONLY the card's key line (`parseCandidateCard`), requires the card to be open, and builds the
745
+ * line from the board's own evidence with `overridesDiff`: nothing of the card's text is parsed as a value. The daemon adds the line to the stored checklist and writes it through the settings path.
746
+ */
747
+ learnAcceptPlan(by: PeerId, id: unknown): { id: number; profile: string; class: string; criterion: string; line: string; tasks: number } {
748
+ if (by !== USER) throw new PreEffectToolRefusal("ahub learn accept is a person's command");
749
+ const card = this.learnCard(id);
750
+ const evidence = this.learnEvidence(card);
751
+ if (!evidence.tasks.length) throw new PreEffectToolRefusal(`no task in the window still names ${card.criterion} unmet on ${card.profile}/${card.class}: nothing to accept; reject the card to close it`);
752
+ const line = overridesDiff({ ...card, tasks: evidence.tasks }).profile_overrides.review_checklist[0]!;
753
+ return { id: card.id, profile: card.profile, class: card.class, criterion: card.criterion, line, tasks: evidence.tasks.length };
754
+ }
755
+ /**
756
+ * #514b: whether `ahub learn accept` already decided this card. Person only. A card the person accepted answers `"accepted"`, so a repeat accept is a no-write success (the plan's idempotency);
757
+ * a rejected card, a card closed some other way and an open one answer undefined and take the normal path, which refuses the closed ones.
758
+ */
759
+ learnAccepted(by: PeerId, id: unknown): { id: number; profile: string; class: string; criterion: string } | undefined {
760
+ if (by !== USER) throw new PreEffectToolRefusal("ahub learn accept is a person's command");
761
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) return undefined;
762
+ const task = this.d.board.get(id);
763
+ if (!task || learnVerdict(task) !== "accepted") return undefined;
764
+ const parsed = parseCandidateCard(task);
765
+ return "refused" in parsed ? undefined : { id, ...parsed };
766
+ }
767
+ /** The card, which must be an open candidate the hub filed. */
768
+ private learnCard(id: unknown): { id: number; profile: string; class: string; criterion: string } {
769
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || id <= 0) throw new PreEffectToolRefusal("id must be a positive task number");
770
+ const task = this.d.board.get(id);
771
+ if (!task) throw new PreEffectToolRefusal(`no task #${id}`);
772
+ const parsed = parseCandidateCard(task);
773
+ if ("refused" in parsed) throw new PreEffectToolRefusal(`task #${id}: ${parsed.refused}`);
774
+ if (task.state !== "backlog" && task.state !== "proposed") throw new PreEffectToolRefusal(`task #${id} is already ${task.state === "cancelled" ? "closed" : task.state}`);
775
+ return { id, ...parsed };
776
+ }
777
+ /**
778
+ * #514b: closes a candidate card after the person's verdict, through `cancel` (never a direct board write): the row stays, the verdict is in its history, and because the dedupe key is the card's
779
+ * detail the same criterion is never filed again. `accepted` is recorded only after the write succeeded (the daemon calls this last); a repeat of either verdict is refused by `learnCard`.
780
+ */
781
+ async learnClose(by: PeerId, id: unknown, verdict: "accepted" | "rejected"): Promise<Task> {
782
+ if (by !== USER) throw new PreEffectToolRefusal("ahub learn is a person's command");
783
+ const card = this.learnCard(id);
784
+ return this.cancel(card.id, USER, { reason: verdict === "accepted" ? `${LEARN_ACCEPTED} by the person: its review checklist line was written to config.local.json` : `${LEARN_REJECTED} by the person` });
785
+ }
709
786
  /**
710
787
  * #514a: the same recurrence path for recurring UNMET CRITERIA. A criterion id that reviews keep naming unmet on the same profile and class, on at least
711
788
  * `threshold` tasks that are at least two separate pieces of work (`workUnits`), files one backlog card for a person to review, with the `profile_overrides`