@staix/agent-hub 0.16.0 → 0.18.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 (96) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +5 -0
  4. package/docs/agent-notes/adapters.md +20 -5
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +5 -3
  7. package/docs/agent-notes/local-worker.md +1 -0
  8. package/docs/agent-notes/tasks.md +9 -3
  9. package/docs/agent-notes/tests.md +6 -3
  10. package/docs/dogfood.md +21 -4
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +150 -34
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +14 -4
  15. package/docs/roles.md +1 -0
  16. package/docs/sdlc.md +39 -13
  17. package/docs/security.md +41 -9
  18. package/docs/smoke.md +51 -3
  19. package/docs/specs/2026-09-19-agent-hub-design.md +56 -10
  20. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  21. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  22. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  23. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  24. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  25. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  26. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  27. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  28. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  29. package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
  30. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  31. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  32. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  33. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  34. package/docs/verified.json +28 -19
  35. package/package.json +2 -2
  36. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  37. package/plugins/agent-hub/server.js +1115 -192
  38. package/src/adapters/codex-review-isolation.ts +10 -1
  39. package/src/adapters/compat.ts +7 -2
  40. package/src/adapters/conformance/enrollment.ts +18 -4
  41. package/src/adapters/conformance/factories.ts +4 -1
  42. package/src/adapters/conformance/kit.ts +11 -0
  43. package/src/adapters/definitions/builtin-metadata.ts +1 -1
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  45. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  46. package/src/adapters/definitions/native-factories.ts +8 -0
  47. package/src/adapters/definitions/native-recovery.ts +8 -0
  48. package/src/adapters/definitions/qwen.ts +7 -4
  49. package/src/adapters/local-worker.ts +5 -3
  50. package/src/adapters/qwen-dual.ts +578 -0
  51. package/src/adapters/registry-types.ts +5 -1
  52. package/src/adapters/registry.ts +9 -1
  53. package/src/adapters/reviewer-mcp.ts +12 -6
  54. package/src/adapters/reviewer-print.ts +100 -17
  55. package/src/cli/console-state.ts +9 -2
  56. package/src/cli/help.ts +29 -28
  57. package/src/cli/identity.ts +87 -6
  58. package/src/cli/main.ts +190 -69
  59. package/src/cli/output.ts +7 -2
  60. package/src/cli/sdlc-output.ts +242 -0
  61. package/src/cli/task-output.ts +212 -0
  62. package/src/cli/upgrade-output.ts +57 -0
  63. package/src/cli/upgrade.ts +4 -3
  64. package/src/hub/board.ts +17 -0
  65. package/src/hub/check-runner.ts +252 -22
  66. package/src/hub/conductor.ts +5 -0
  67. package/src/hub/config-apply.ts +16 -7
  68. package/src/hub/config-edit-host.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -1
  70. package/src/hub/daemon.ts +487 -94
  71. package/src/hub/dogfood-profile.ts +248 -0
  72. package/src/hub/dogfood-read.ts +77 -14
  73. package/src/hub/dogfood.ts +5 -1
  74. package/src/hub/events.ts +1 -1
  75. package/src/hub/host-executable.ts +2 -1
  76. package/src/hub/hub-tools.ts +2 -2
  77. package/src/hub/memory-plan-guard.ts +8 -33
  78. package/src/hub/operational-authority.ts +60 -0
  79. package/src/hub/operational-view.ts +141 -0
  80. package/src/hub/orphan-process.ts +69 -0
  81. package/src/hub/permission-mode.ts +1 -1
  82. package/src/hub/read-only-shell-proof.ts +80 -0
  83. package/src/hub/read-only-shell.ts +162 -0
  84. package/src/hub/reviewer-pool-config.ts +3 -2
  85. package/src/hub/reviewer-pool.ts +3 -0
  86. package/src/hub/routing.ts +23 -6
  87. package/src/hub/sdlc-merge.ts +299 -0
  88. package/src/hub/sdlc-mirror.ts +129 -0
  89. package/src/hub/sdlc.ts +58 -12
  90. package/src/hub/settings.ts +11 -5
  91. package/src/hub/snapshots.ts +60 -0
  92. package/src/hub/task-repository.ts +31 -5
  93. package/src/hub/tasks.ts +266 -28
  94. package/src/local/tools.ts +17 -4
  95. package/src/memory/brief.ts +45 -5
  96. package/src/ui/index.html +2 -1
@@ -0,0 +1,242 @@
1
+ // Human output for the `ahub sdlc` read commands (#814 T1). Pure: data in, spans out, no I/O and no clock but `now`.
2
+ // Layout follows #284: aligned columns from the shared `table`, long text wrapped under its label (never cut),
3
+ // semantic tones, readable times (`2026-10-25 14:20 UTC (3h05m ago)`, never a raw ISO string). `--json` is the caller's
4
+ // branch and prints the hub's reply untouched, so nothing here may be the only copy of a field.
5
+ import { duration, plural, relative, table, terminalText, wrap, type Span, type Tone } from "./console-state.ts";
6
+
7
+ type Line = Span[];
8
+ const s = (value: unknown, tone?: Tone): Span => ({ text: terminalText(value).replace(/[\n\t]/g, " "), ...(tone ? { tone } : {}) });
9
+ const room = (columns?: number) => columns ?? Infinity;
10
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
11
+ const yes = (value: unknown) => value === true ? "yes" : value === false ? "no" : "unknown";
12
+ const short = (sha: unknown) => typeof sha === "string" && sha ? sha.slice(0, 8) : "-";
13
+ const count = (n: unknown) => typeof n === "number" && Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(1)) : "unknown";
14
+ const percent = (rate: unknown) => typeof rate === "number" && Number.isFinite(rate) ? `${Math.round(rate * 100)}%` : "unknown";
15
+ /** `2026-10-25 14:20 UTC (3h05m ago)`: an absolute minute and how long ago, never the raw ISO string. */
16
+ export function moment(at: unknown, now: number): string {
17
+ if (typeof at !== "number" || !Number.isFinite(at)) return "unknown";
18
+ const iso = new Date(at).toISOString();
19
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC (${relative(at, now)})`;
20
+ }
21
+ const span = (ms: unknown) => typeof ms === "number" && Number.isFinite(ms) ? duration(ms) : "unknown";
22
+
23
+ /** Text wrapped to the width, continuation lines hanging under `indent`. */
24
+ function para(text: string, columns: number | undefined, tone?: Tone, indent = "", hang = 2): Line[] {
25
+ return wrap(indent + text, room(columns), hang).map(line => [s(line, tone)]);
26
+ }
27
+ /**
28
+ * `label value`, the value wrapped beside its own label so narrow terminals never separate them. When the longest word of
29
+ * the value does not fit in the room left of the label column, the value moves under the label instead, so a path or id
30
+ * is broken only when it is wider than the whole terminal.
31
+ */
32
+ function pair(label: string, value: unknown, columns: number | undefined, labelWidth: number, tone?: Tone, indent = ""): Line[] {
33
+ const text = terminalText(value).replace(/[\n\t]/g, " ");
34
+ const head = indent + label.padEnd(labelWidth) + " ";
35
+ const longest = Math.max(0, ...text.split(/\s+/).map(textWidth));
36
+ if (columns !== undefined && head.length + longest > columns && text) {
37
+ const under = indent.length + 1 + longest <= columns ? indent + " " : "";
38
+ return [[s(indent + label, "muted")], ...wrap(under + text, columns, under.length).map((line): Line => [s(line, tone)])];
39
+ }
40
+ const lines = wrap(head + text, room(columns), head.length);
41
+ return lines.map((line, i) => i === 0 ? [s(line.slice(0, head.length), "muted"), s(line.slice(head.length), tone)] : [s(line, tone)]);
42
+ }
43
+ /** Items under a label, one per line, wrapped under the dash. */
44
+ function bullets(label: string, items: readonly unknown[], columns: number | undefined, tone?: Tone, indent = ""): Line[] {
45
+ if (!items.length) return [];
46
+ return [[s(indent + label, "muted")], ...items.flatMap(item => para(`- ${terminalText(item)}`, columns, tone, indent + (columns !== undefined && indent.length + 2 + 2 + Math.max(0, ...terminalText(item).split(/\s+/).map(textWidth)) > columns ? "" : " "), 2))];
47
+ }
48
+ const GAP = 2;
49
+ const textWidth = (text: string) => Bun.stringWidth(text);
50
+ /**
51
+ * A table that fits the terminal and never cuts a character. At its natural widths when they fit; otherwise the
52
+ * widest cells wrap inside their column (a PR linked to thirty tasks wraps its TASKS cell, it does not push the
53
+ * table to 134 columns); and when even each column's longest unbreakable word cannot fit, one labelled block per row.
54
+ */
55
+ function grid(head: string[], data: Span[][], columns: number | undefined): Line[] {
56
+ const natural = head.map((name, i) => Math.max(textWidth(name), ...data.map(row => textWidth(row[i]?.text ?? ""))));
57
+ const gaps = GAP * (head.length - 1);
58
+ if (columns === undefined || natural.reduce((sum, n) => sum + n, 0) + gaps <= columns) return table(head, data, room(columns), natural);
59
+ const floor = head.map((name, i) => Math.max(textWidth(name), ...data.flatMap(row => (row[i]?.text ?? "").split(/\s+/).map(textWidth))));
60
+ if (floor.reduce((sum, n) => sum + n, 0) + gaps > columns) return blocks(head, data, columns);
61
+ // Hand the spare width to the column that is cut most, one cell at a time, so the wide list cell wraps and the short ones do not.
62
+ const widths = [...floor];
63
+ for (let spare = columns - widths.reduce((sum, n) => sum + n, 0) - gaps; spare > 0; spare--) {
64
+ let at = -1;
65
+ for (let i = 0; i < widths.length; i++) if (widths[i]! < natural[i]! && (at < 0 || natural[i]! - widths[i]! > natural[at]! - widths[at]!)) at = i;
66
+ if (at < 0) break;
67
+ widths[at]!++;
68
+ }
69
+ const physical = data.flatMap(row => {
70
+ const cells = row.map((cell, i) => wrap(cell.text, widths[i]!, 0));
71
+ return Array.from({ length: Math.max(1, ...cells.map(lines => lines.length)) }, (_, n) => head.map((_, i): Span => ({ ...(row[i] ?? s("")), text: cells[i]?.[n] ?? "" })));
72
+ });
73
+ return table(head, physical, columns, widths);
74
+ }
75
+ /** Narrow terminal: each row becomes its own block, the first column as its heading and every other column beside its label. */
76
+ function blocks(head: string[], data: Span[][], columns: number): Line[] {
77
+ const label = Math.max(...head.slice(1).map(textWidth));
78
+ return data.flatMap((row, n) => [...(n ? [blank] : []), ...wrap(row[0]?.text ?? "", columns, 2).map((line): Line => [{ ...(row[0] ?? s("")), text: line }]),
79
+ ...head.slice(1).flatMap((name, i) => pair(name.toLowerCase(), row[i + 1]?.text ?? "", columns, label, row[i + 1]?.tone, " "))]);
80
+ }
81
+ /** A heading wraps to the width like any paragraph; a long repository name never pushes it past the terminal. */
82
+ const heading = (text: string, columns?: number): Line[] => wrap(text, room(columns), 2).map(line => [s(line, "strong")]);
83
+ const blank: Line = [s("")];
84
+ const readyTone = (ready: unknown): Tone => ready === true ? "success" : "attention";
85
+
86
+ // ---- sdlc status / explain ----------------------------------------------------------------------------------------
87
+
88
+ function receiptLine(receipt: any): string {
89
+ const parts = [receipt?.repo && receipt?.issue ? `${receipt.repo}#${receipt.issue}` : receipt?.issue ? `issue #${receipt.issue}` : undefined,
90
+ receipt?.pr ? `PR #${receipt.pr}` : undefined, receipt?.head ? `head ${short(receipt.head)}` : undefined].filter(Boolean);
91
+ return parts.length ? parts.join(", ") : "no issue or PR recorded";
92
+ }
93
+ function taskBlock(row: any, columns: number | undefined, detail: boolean): Line[] {
94
+ const id = `#${row.id}`;
95
+ const out: Line[] = para(`${id} ${row.title ?? ""}`, columns, undefined, "", 6)
96
+ .map((line, i) => i === 0 ? [s(id, "taskRef"), s(line[0]!.text.slice(id.length), "strong")] : line);
97
+ out.push(...pair("lifecycle", `stage ${row.stage ?? "unknown"}, mode ${row.mode ?? "unknown"}, ready ${yes(row.ready)}, actor ${row.actor ?? "unknown"}`, columns, 11, readyTone(row.ready), " "));
98
+ out.push(...pair("next", row.next ?? "unknown", columns, 11, undefined, " "));
99
+ out.push(...pair("receipt", receiptLine(row.receipt), columns, 11, undefined, " "));
100
+ out.push(...bullets("blockers", list(row.blockers), columns, "attention", " "));
101
+ if (!detail) return out;
102
+ if (row.spec) out.push(...pair("spec", `issue #${row.spec.issue}, ${row.spec.state}`, columns, 11, undefined, " "));
103
+ if (row.playbook) out.push(...pair("playbook", row.playbook, columns, 11, undefined, " "));
104
+ if (row.definition?.name || row.definition?.id) out.push(...pair("stage", row.definition.name ?? row.definition.id, columns, 11, undefined, " "));
105
+ out.push(...bullets("entry", list(row.entry), columns, undefined, " "), ...bullets("exit", list(row.exit), columns, undefined, " "));
106
+ const f = row.findings;
107
+ if (f) out.push(...pair("findings", `${plural(f.important ?? 0, "important")} (${f.openImportant ?? 0} open), ${plural(f.nit ?? 0, "nit")} (${f.openNit ?? 0} open), ${f.fixed ?? 0} fixed, ${f.falsePositive ?? 0} false positive, ${f.accepted ?? 0} accepted`, columns, 11, f.openImportant ? "attention" : undefined, " "));
108
+ const p = row.planConformance;
109
+ if (p) {
110
+ out.push(...pair("plan files", `${p.state}${p.accepted ? ", departure accepted" : ""}, ${list(p.outside).length} outside the plan, ${list(p.untouched).length} planned and untouched`, columns, 11, p.state === "departure" && !p.accepted ? "attention" : undefined, " "));
111
+ out.push(...bullets("outside the plan", list(p.outside), columns, "attention", " "), ...bullets("untouched", list(p.untouched), columns, "muted", " "));
112
+ }
113
+ const t = row.testIntegrity;
114
+ if (t) {
115
+ out.push(...pair("tests", `${t.applicability === "bug" ? "bug fix" : t.applicability === "not_bug" ? "not a bug fix" : "bug status unknown"}, red-first ${String(t.redFirst).replace("_", " ")}, files ${t.fileState}`, columns, 11, undefined, " "));
116
+ out.push(...bullets("modified tests", list(t.modifiedTests), columns, undefined, " "), ...bullets("test blockers", list(t.blockers), columns, "attention", " "));
117
+ }
118
+ const c = row.criteria;
119
+ if (c) {
120
+ out.push(...pair("criteria", `${c.ready ? "all met" : "not all met"}${c.id ? `, checklist ${c.id}` : ""}${c.note ? `, ${c.note}` : ""}`, columns, 11, readyTone(c.ready), " "));
121
+ out.push(...bullets("unmet", list(c.blockers), columns, "attention", " "));
122
+ }
123
+ return out;
124
+ }
125
+ function lifecycleView(reply: any, columns: number | undefined, detail: boolean): Line[] {
126
+ const rows = list(reply?.rows);
127
+ const out: Line[] = [...heading(`SDLC lifecycle, mode ${reply?.mode ?? "unknown"}`, columns)];
128
+ if (!rows.length) return [...out, [s("no task has lifecycle state yet", "muted")]];
129
+ if (!detail || rows.length > 1) {
130
+ out.push(...grid(["ID", "STAGE", "READY", "ACTOR", "PR"], rows.map(row => [s(`#${row.id}`, "taskRef"), s(row.stage ?? "unknown"), s(yes(row.ready), readyTone(row.ready)), s(row.actor ?? "-"), s(row.receipt?.pr ? `#${row.receipt.pr}` : "-")]), columns));
131
+ }
132
+ for (const row of rows) out.push(blank, ...taskBlock(row, columns, detail));
133
+ if (reply?.truncated) out.push(blank, ...para("showing the first 100 tasks; name a task to read one", columns, "muted"));
134
+ return out;
135
+ }
136
+ /** `ahub sdlc status [task]`: one row per task, then each task's next step and blockers. */
137
+ export const renderSdlcStatus = (reply: unknown, columns?: number, _now = Date.now()): Span[][] => lifecycleView(reply, columns, false);
138
+ /** `ahub sdlc explain <task>`: the same task with every gate the hub read. */
139
+ export const renderSdlcExplain = (reply: unknown, columns?: number, _now = Date.now()): Span[][] => lifecycleView(reply, columns, true);
140
+
141
+ // ---- sdlc queue / release -----------------------------------------------------------------------------------------
142
+
143
+ /** `ahub sdlc queue <repo>`: the merge order, then each open PR's gates. */
144
+ export function renderSdlcQueue(reply: unknown, columns?: number, _now = Date.now()): Span[][] {
145
+ const report = (reply as any)?.report ?? {};
146
+ const rows = list(report.rows);
147
+ const out: Line[] = [...heading(`Merge queue ${report.repo ?? "unknown repository"}`, columns)];
148
+ if (report.complete === false) out.push(...para("incomplete: a read failed or the board changed during it; do not merge from this order", columns, "attention"));
149
+ if (report.truncated) out.push(...para("truncated: more open PRs than one read covers", columns, "attention"));
150
+ if (!rows.length) return [...out, [s("no open PR is linked to a task", "muted")]];
151
+ out.push(...pair("order", list(report.order).length ? list(report.order).map((pr: number) => `#${pr}`).join(", ") : "none decided", columns, 9));
152
+ if (list(report.unordered).length) out.push(...pair("unordered", list(report.unordered).map((pr: number) => `#${pr}`).join(", "), columns, 9, "attention"));
153
+ out.push(blank, ...grid(["PR", "TASKS", "READY", "HEAD", "AFTER"], rows.map(row => [s(`#${row.pr}`, "issueRef"), s(list(row.tasks).map((id: number) => `#${id}`).join(" ") || "-", "taskRef"), s(yes(row.ready), readyTone(row.ready)), s(row.private ? "private" : short(row.head)), s(list(row.after).map((pr: number) => `#${pr}`).join(" ") || "-")]), columns));
154
+ for (const row of rows) {
155
+ if (!list(row.blockers).length && !row.findings?.openImportant) continue;
156
+ out.push(blank, [s(`#${row.pr}`, "issueRef")], ...bullets("blockers", list(row.blockers), columns, "attention", " "));
157
+ }
158
+ if (list(report.conflicts).length) out.push(blank, ...bullets("file conflicts", list(report.conflicts).map((c: any) => `#${c.left} and #${c.right} share ${plural(c.files ?? 0, "file")} (${c.order === "person_required" ? "the person orders them" : "dependency order"})`), columns, "attention"));
159
+ return out;
160
+ }
161
+ /** `ahub sdlc release <repo> [live-issue...]`: source, document, issue and authorization evidence, then what blocks the release. */
162
+ export function renderSdlcRelease(reply: unknown, columns?: number, now = Date.now()): Span[][] {
163
+ const r = (reply as any)?.report ?? {};
164
+ const out: Line[] = [...heading(`Release readiness ${r.repo ?? "unknown repository"}`, columns)];
165
+ const w = 12;
166
+ out.push(...pair("version", r.version ?? "unknown", columns, w), ...pair("head", short(r.head), columns, w), ...pair("tag", r.tag ? `${r.tag} at ${short(r.tagCommit)}` : "none", columns, w));
167
+ out.push(...pair("source ready", yes(r.sourceReady), columns, w, readyTone(r.sourceReady)), ...pair("native", String(r.native ?? "unverified"), columns, w, "attention"), ...pair("release ready", yes(r.ready), columns, w, readyTone(r.ready)));
168
+ out.push(...pair("commits", `${plural(list(r.commits).length, "commit")} since the last tag`, columns, w));
169
+ if (list(r.mergedPrs).length) out.push(...pair("merged PRs", list(r.mergedPrs).map((p: any) => `#${p.pr}`).join(", "), columns, w));
170
+ const docs = r.documents;
171
+ out.push(blank, ...pair("documents", docs ? `${docs.fresh ? "fresh" : "not fresh"}, ${plural(docs.errors ?? 0, "error")}, ${plural(list(docs.documents).length, "document")}` : "not read", columns, w, docs?.fresh ? "success" : "attention"));
172
+ for (const d of list(docs?.documents)) if (d.state !== "fresh") out.push(...pair(d.state, `${d.path ?? "(path withheld)"}${d.dirty ? " (uncommitted changes)" : ""}`, columns, w, "attention", " "));
173
+ if (list(r.liveIssues).length) out.push(blank, ...grid(["ISSUE", "STATE"], list(r.liveIssues).map((i: any) => [s(`#${i.id}`, "issueRef"), s(i.state, i.state === "closed" ? "success" : "attention")]), columns));
174
+ const a = r.authorization;
175
+ if (a) out.push(blank, ...pair("authorization", `${a.state}: version ${a.receipt?.version ?? "unknown"} at ${short(a.receipt?.commit)}, approved ${moment(a.receipt?.at, now)}`, columns, w, a.state === "matched" ? "success" : "attention"));
176
+ else out.push(blank, ...pair("authorization", "none recorded: run ahub sdlc authorize-release <version>", columns, w, "muted"));
177
+ for (const t of list(r.trains)) out.push(...pair(`train #${t.release?.id ?? "?"}`, `${t.readiness?.trigger ?? "unknown"}${list(t.readiness?.blockers).length ? `, ${list(t.readiness.blockers).join("; ")}` : ""}`, columns, w, list(t.readiness?.blockers).length ? "attention" : undefined));
178
+ out.push(...bullets("blockers", list(r.blockers), columns, "attention"));
179
+ return out;
180
+ }
181
+
182
+ // ---- sdlc metrics / costs -----------------------------------------------------------------------------------------
183
+
184
+ const SUMMARY_ROWS: [string, string, boolean][] = [
185
+ ["acceptance_to_plan_ms", "acceptance to plan", true], ["acceptance_to_first_review_ms", "acceptance to first review", true], ["acceptance_to_merge_ms", "acceptance to merge", true],
186
+ ["rework_rounds", "rework rounds", false], ["same_head_ci_reruns", "same-head CI reruns", false], ["stale_head_re_reviews", "stale-head re-reviews", false],
187
+ ];
188
+ const BAND_TONE: Record<string, Tone> = { ok: "success", log: "muted", diagnose: "attention", propose: "failure" };
189
+ /** `ahub sdlc metrics [--since ...]`: times and counts from board history, the two approval rates, stalls and band tiers. */
190
+ export function renderSdlcMetrics(result: unknown, columns?: number, now = Date.now()): Span[][] {
191
+ const m = result as any;
192
+ const out: Line[] = [...heading("SDLC metrics", columns)];
193
+ out.push(...pair("window", m?.since === null || m?.since === undefined ? "all recorded history" : `since ${moment(m.since, now)}`, columns, 10),
194
+ ...pair("tasks", `${m?.tasks ?? 0} counted${m?.withheldPrivate ? `, ${m.withheldPrivate} private withheld` : ""}`, columns, 10));
195
+ const metrics = m?.metrics ?? {};
196
+ const value = (n: unknown, time: boolean) => time ? span(n) : count(n);
197
+ out.push(blank, ...grid(["METRIC", "N", "UNKNOWN", "MEAN", "MEDIAN", "P90", "MAX"], SUMMARY_ROWS.map(([key, label, time]) => {
198
+ const x = metrics[key] ?? {};
199
+ return [s(label), s(count(x.n)), s(count(x.unknown), x.unknown ? "attention" : "muted"), s(value(x.mean, time)), s(value(x.median, time)), s(value(x.p90, time)), s(value(x.max, time))];
200
+ }), columns));
201
+ out.push(blank, ...grid(["RATE", "N", "UNKNOWN", "VALUE"], [["first_pass_approval", "first-pass approval"], ["first_pass_check", "first-pass check"]].map(([key, label]) => {
202
+ const x = metrics[key!] ?? {};
203
+ return [s(label), s(count(x.n)), s(count(x.unknown), x.unknown ? "attention" : "muted"), s(percent(x.rate), x.rate === null || x.rate === undefined ? "muted" : "number")];
204
+ }), columns));
205
+ const st = m?.stalls;
206
+ if (st) out.push(blank, ...pair("stalls", `${st.open ?? 0} open${Object.keys(st.byKind ?? {}).length ? ` (${Object.entries(st.byKind).map(([k, n]) => `${k} ${n}`).join(", ")})` : ""}, ${st.doneWithoutWrites ?? 0} done without writes`, columns, 10, st.open ? "attention" : undefined));
207
+ if (m?.bands === "off" || m?.bands === undefined) out.push(...pair("bands", "off", columns, 10, "muted"));
208
+ else {
209
+ out.push(blank, ...grid(["BAND", "TIER", "BASELINE", "LAST", "Z", "TASK"], list(m.bands).map((b: any) => [s(String(b.metric).replaceAll("_", " ")), s(b.tier, BAND_TONE[b.tier] ?? undefined), s(count(b.baseline)), s(count(b.last)), s(count(b.z)), s(b.lastTask ? `#${b.lastTask}` : "-", "taskRef")]), columns));
210
+ for (const b of list(m.bands)) if (b.reason && b.tier !== "ok") out.push(...para(`${String(b.metric).replaceAll("_", " ")}: ${b.reason}`, columns, "muted", " ", 2));
211
+ }
212
+ return out;
213
+ }
214
+ /** `ahub sdlc costs [repo]`: review and CI cost of each linked merged PR, grouped by repository. */
215
+ export function renderSdlcCosts(result: unknown, columns?: number, _now = Date.now()): Span[][] {
216
+ const c = result as any;
217
+ const rows = list(c?.rows);
218
+ const out: Line[] = [...heading("SDLC costs", columns)];
219
+ if (!rows.length) out.push([s("no linked merged PR has cost evidence", "muted")]);
220
+ const repos = [...new Set(rows.map(row => String(row.repo)))];
221
+ for (const repo of repos) {
222
+ out.push(...(out.length > 1 ? [blank] : []), ...wrap(repo, room(columns), 2).map((line): Line => [s(line, "info")]), ...grid(["PR", "ROUNDS", "CI", "RERUNS", "FULL", "DELTA", "REVIEW", "UNK", "OK"], rows.filter(row => String(row.repo) === repo).map(row => [
223
+ s(`#${row.pr}`, "issueRef"), s(count(row.reviewRounds)), s(count(row.ciRuns)), s(count(row.ciReruns)), s(count(row.fullReReviews)), s(count(row.deltaReviews)), s(span(row.reviewMs)),
224
+ s(count(row.unknownReviewScopes), row.unknownReviewScopes ? "attention" : "muted"), s(row.complete ? "yes" : "no", row.complete ? "success" : "attention")]), columns));
225
+ }
226
+ if (c?.truncated) out.push(blank, ...para("truncated: more PRs than one read covers, or a read was incomplete", columns, "attention"));
227
+ if (c?.privateTasks === "withheld") out.push(...para("private tasks are withheld", columns, "muted"));
228
+ return out;
229
+ }
230
+
231
+ // ---- sdlc authorize-release ---------------------------------------------------------------------------------------
232
+
233
+ /** `ahub sdlc authorize-release <version>`: what the person just approved. */
234
+ export function renderAuthorization(authorization: unknown, columns?: number, now = Date.now()): Span[][] {
235
+ const a = authorization as any;
236
+ const w = 8;
237
+ return [...heading("Release authorized", columns),
238
+ ...pair("version", a?.version ?? "unknown", columns, w), ...pair("commit", short(a?.commit), columns, w), ...pair("by", a?.by ?? "unknown", columns, w),
239
+ ...pair("at", moment(a?.at, now), columns, w), ...pair("docs", a?.docs ? `${a.docs.fresh ? "fresh" : "not fresh"}, ${plural(a.docs.documents ?? 0, "document")}, digest ${short(a.docs.digest)}` : "unknown", columns, w),
240
+ ...pair("id", a?.id ?? "unknown", columns, w, "muted"), blank,
241
+ ...para("A daemon restart revokes this authorization.", columns, "muted")];
242
+ }
@@ -0,0 +1,212 @@
1
+ // Human output for `ahub task show|cancel`, the backlog mutations and grooming, and the release commands (#814 T2).
2
+ // Pure: parsed hub-tool JSON in, spans out. The hub tools keep returning JSON to agents; the CLI parses the text and
3
+ // renders it, and `--json` prints that text untouched. Built from the same primitives as sdlc-output.ts (#814 T1), so
4
+ // every table fits the terminal and no identifier is cut. A private task arrives from the hub as a stub (`[pii]` title
5
+ // and detail, empty refs and plan): this module shows that stub and never asks for more.
6
+ import { type Span, type Tone, plural, table, terminalText, wrap } from "./console-state.ts";
7
+ import { moment } from "./sdlc-output.ts";
8
+
9
+ type Line = Span[];
10
+ // Local copies of the layout primitives of sdlc-output.ts (#814 T1), the source of these helpers: that file stays untouched by this task
11
+ // (its plan names no edit), so a change to a primitive there is made here too. `moment` is already exported there and imported.
12
+ const s = (value: unknown, tone?: Tone): Span => ({ text: terminalText(value).replace(/[\n\t]/g, " "), ...(tone ? { tone } : {}) });
13
+ const room = (columns?: number) => columns ?? Infinity;
14
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
15
+ const yes = (value: unknown) => value === true ? "yes" : value === false ? "no" : "unknown";
16
+ const short = (sha: unknown) => typeof sha === "string" && sha ? sha.slice(0, 8) : "-";
17
+ const count = (n: unknown) => typeof n === "number" && Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(1)) : "unknown";
18
+
19
+ /** Text wrapped to the width, continuation lines hanging under `indent`. */
20
+ function para(text: string, columns: number | undefined, tone?: Tone, indent = "", hang = 2): Line[] {
21
+ return wrap(indent + text, room(columns), hang).map(line => [s(line, tone)]);
22
+ }
23
+ /**
24
+ * `label value`, the value wrapped beside its own label so narrow terminals never separate them. When the longest word of
25
+ * the value does not fit in the room left of the label column, the value moves under the label instead, so a path or id
26
+ * is broken only when it is wider than the whole terminal.
27
+ */
28
+ function pair(label: string, value: unknown, columns: number | undefined, labelWidth: number, tone?: Tone, indent = ""): Line[] {
29
+ const text = terminalText(value).replace(/[\n\t]/g, " ");
30
+ const head = indent + label.padEnd(labelWidth) + " ";
31
+ const longest = Math.max(0, ...text.split(/\s+/).map(textWidth));
32
+ if (columns !== undefined && head.length + longest > columns && text) {
33
+ const under = indent.length + 1 + longest <= columns ? indent + " " : "";
34
+ return [[s(indent + label, "muted")], ...wrap(under + text, columns, under.length).map((line): Line => [s(line, tone)])];
35
+ }
36
+ const lines = wrap(head + text, room(columns), head.length);
37
+ return lines.map((line, i) => i === 0 ? [s(line.slice(0, head.length), "muted"), s(line.slice(head.length), tone)] : [s(line, tone)]);
38
+ }
39
+ /** Items under a label, one per line, wrapped under the dash. */
40
+ function bullets(label: string, items: readonly unknown[], columns: number | undefined, tone?: Tone, indent = ""): Line[] {
41
+ if (!items.length) return [];
42
+ return [[s(indent + label, "muted")], ...items.flatMap(item => para(`- ${terminalText(item)}`, columns, tone, indent + (columns !== undefined && indent.length + 2 + 2 + Math.max(0, ...terminalText(item).split(/\s+/).map(textWidth)) > columns ? "" : " "), 2))];
43
+ }
44
+ const GAP = 2;
45
+ const textWidth = (text: string) => Bun.stringWidth(text);
46
+ /**
47
+ * A table that fits the terminal and never cuts a character. At its natural widths when they fit; otherwise the
48
+ * widest cells wrap inside their column (a PR linked to thirty tasks wraps its TASKS cell, it does not push the
49
+ * table to 134 columns); and when even each column's longest unbreakable word cannot fit, one labelled block per row.
50
+ */
51
+ function grid(head: string[], data: Span[][], columns: number | undefined): Line[] {
52
+ const natural = head.map((name, i) => Math.max(textWidth(name), ...data.map(row => textWidth(row[i]?.text ?? ""))));
53
+ const gaps = GAP * (head.length - 1);
54
+ if (columns === undefined || natural.reduce((sum, n) => sum + n, 0) + gaps <= columns) return table(head, data, room(columns), natural);
55
+ const floor = head.map((name, i) => Math.max(textWidth(name), ...data.flatMap(row => (row[i]?.text ?? "").split(/\s+/).map(textWidth))));
56
+ if (floor.reduce((sum, n) => sum + n, 0) + gaps > columns) return blocks(head, data, columns);
57
+ // Hand the spare width to the column that is cut most, one cell at a time, so the wide list cell wraps and the short ones do not.
58
+ const widths = [...floor];
59
+ for (let spare = columns - widths.reduce((sum, n) => sum + n, 0) - gaps; spare > 0; spare--) {
60
+ let at = -1;
61
+ for (let i = 0; i < widths.length; i++) if (widths[i]! < natural[i]! && (at < 0 || natural[i]! - widths[i]! > natural[at]! - widths[at]!)) at = i;
62
+ if (at < 0) break;
63
+ widths[at]!++;
64
+ }
65
+ const physical = data.flatMap(row => {
66
+ const cells = row.map((cell, i) => wrap(cell.text, widths[i]!, 0));
67
+ return Array.from({ length: Math.max(1, ...cells.map(lines => lines.length)) }, (_, n) => head.map((_, i): Span => ({ ...(row[i] ?? s("")), text: cells[i]?.[n] ?? "" })));
68
+ });
69
+ return table(head, physical, columns, widths);
70
+ }
71
+ /** Narrow terminal: each row becomes its own block, the first column as its heading and every other column beside its label. */
72
+ function blocks(head: string[], data: Span[][], columns: number): Line[] {
73
+ const label = Math.max(...head.slice(1).map(textWidth));
74
+ return data.flatMap((row, n) => [...(n ? [blank] : []), ...wrap(row[0]?.text ?? "", columns, 2).map((line): Line => [{ ...(row[0] ?? s("")), text: line }]),
75
+ ...head.slice(1).flatMap((name, i) => pair(name.toLowerCase(), row[i + 1]?.text ?? "", columns, label, row[i + 1]?.tone, " "))]);
76
+ }
77
+ /** A heading wraps to the width like any paragraph; a long repository name never pushes it past the terminal. */
78
+ const heading = (text: string, columns?: number): Line[] => wrap(text, room(columns), 2).map(line => [s(line, "strong")]);
79
+ const blank: Line = [s("")];
80
+ const dash = (value: unknown) => value === undefined || value === null || value === "" ? "-" : String(value);
81
+ const ids = (values: unknown) => list(values).map(id => `#${id}`).join(" ") || "none";
82
+ const when = (at: unknown, now: number) => typeof at === "number" && Number.isFinite(at) ? moment(at, now) : "-";
83
+ const STATE_TONE: Record<string, Tone> = { approved: "success", in_review: "attention", in_progress: "info", proposed: "muted", backlog: "muted", cancelled: "muted", blocked: "failure" };
84
+ const stateTone = (state: unknown): Tone | undefined => typeof state === "string" ? STATE_TONE[state] : undefined;
85
+ const isStub = (task: any) => task?.title === "[pii]" && task?.detail === "[pii]";
86
+
87
+ /** The first task of a result: a stub when the hub withheld it, which says so in one line and points at the console. */
88
+ function stubNote(task: any, columns: number | undefined): Line[] {
89
+ return isStub(task) ? [blank, ...para(`private task: the title, detail, paths and plan are withheld here. Read it where it is allowed: \`ahub task show ${task.id}\` in a console terminal.`, columns, "muted")] : [];
90
+ }
91
+ const planBlock = (plan: any, columns: number | undefined): Line[] => {
92
+ const keys = ["paths", "symbols", "signatures", "insertion_points", "sections", "sources", "claims"];
93
+ return keys.flatMap(key => bullets(key.replace("_", " "), list(plan?.[key]), columns, undefined, " "));
94
+ };
95
+ const historyRows = (history: any[], now: number): Span[][] => history.map(entry => [s(when(entry.at, now), "muted"), s(entry.by ?? "-"), s(entry.event ?? "-", entry.event === "approved" ? "success" : entry.event === "changes_requested" ? "attention" : undefined), s(dash(entry.note))]);
96
+
97
+ /** The one task block: identity, who holds it, what it waits for, then plan, receipt and history. */
98
+ function taskBlock(task: any, columns: number | undefined, now: number, detail: boolean): Line[] {
99
+ const id = `#${task?.id ?? "?"}`;
100
+ const out: Line[] = [...para(`${id} ${dash(task?.title)}`, columns, undefined, "", 6).map((line, i): Line => i === 0 ? [s(id, "taskRef"), s(line[0]!.text.slice(id.length), "strong")] : line)];
101
+ const w = 10;
102
+ out.push(...pair("state", `${dash(task?.state)}${task?.cancelled ? " (cancelled)" : ""}`, columns, w, stateTone(task?.state), " "),
103
+ ...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner", dash(task?.owner), columns, w, undefined, " "), ...pair("reviewer", dash(task?.reviewer), columns, w, undefined, " "));
104
+ if (task?.planner) out.push(...pair("planner", task.planner, columns, w, undefined, " "));
105
+ if (task?.reserved) out.push(...pair("reserved", task.reserved, columns, w, undefined, " "));
106
+ out.push(...pair("after", ids(task?.deps), columns, w, undefined, " "));
107
+ if (typeof task?.release === "number") out.push(...pair("release", `#${task.release}`, columns, w, undefined, " "));
108
+ if (typeof task?.rank === "number" || typeof task?.priority === "number") out.push(...pair("planning", [typeof task.rank === "number" ? `rank ${task.rank}` : "", typeof task.priority === "number" ? `priority P${task.priority}` : "", task.pinned ? "pinned" : ""].filter(Boolean).join(", "), columns, w, undefined, " "));
109
+ // The operands the next command needs: backlog plan|pin|split|pull-ready|undo take the planning revision (0 on an unedited card),
110
+ // task cancel and assign --revision take the flow revision. A person must not have to switch to --json to read them.
111
+ if (task?.state === "backlog" || typeof task?.planningRevision === "number") out.push(...pair("planning rev", count(task?.planningRevision ?? 0), columns, w, undefined, " "));
112
+ if (typeof task?.flowRevision === "number") out.push(...pair("flow rev", count(task.flowRevision), columns, w, undefined, " "));
113
+ out.push(...pair("created", when(task?.created, now), columns, w, undefined, " "), ...pair("updated", when(task?.updated, now), columns, w, undefined, " "));
114
+ if (!detail) return out;
115
+ out.push(...stubNote(task, columns));
116
+ if (!isStub(task) && task?.detail) out.push(blank, [s("detail", "muted")], ...para(String(task.detail), columns, undefined, " ", 2));
117
+ const refs = task?.refs ?? {};
118
+ const refText = [refs.repo, refs.branch ? `branch ${refs.branch}` : "", refs.commit ? `commit ${short(refs.commit)}` : ""].filter(Boolean).join(", ");
119
+ if (refText) out.push(blank, ...pair("refs", refText, columns, w, undefined, " "));
120
+ out.push(...bullets("ref paths", list(refs.paths), columns, undefined, " "));
121
+ const plan = planBlock(task?.plan, columns);
122
+ if (plan.length) out.push(blank, [s("plan", "muted")], ...plan);
123
+ const sdlc = task?.sdlc;
124
+ if (sdlc) out.push(blank, ...pair("lifecycle", [sdlc.stage ? `stage ${sdlc.stage}` : "", sdlc.repo && sdlc.issue ? `${sdlc.repo}#${sdlc.issue}` : "", sdlc.pr ? `PR #${sdlc.pr}` : "", sdlc.head ? `head ${short(sdlc.head)}` : ""].filter(Boolean).join(", ") || "recorded", columns, w, undefined, " "));
125
+ if (task?.relatedMemory) out.push(...pair("memory", `${task.relatedMemory.state}${typeof task.relatedMemory.count === "number" ? `, ${plural(task.relatedMemory.count, "related note")}` : ""}`, columns, w, undefined, " "));
126
+ // Tasks.symbolOverlaps: { task, files, sameFileDifferentSymbols } per overlapping task, public file names only.
127
+ if (list(task?.symbolOverlaps).length) out.push(...bullets("overlaps", list(task.symbolOverlaps).map((o: any) => `#${o?.task ?? "?"} touches ${list(o?.files).join(", ") || "files withheld"}${o?.sameFileDifferentSymbols ? " (same file, different symbols)" : ""}`), columns, "attention", " "));
128
+ // Tasks.deliverableStatus: { state, changedSinceReview, files: [{ id, sha256, state, path?, format, version?, private? }] }.
129
+ const delivered = task?.deliverables;
130
+ if (delivered) {
131
+ out.push(blank, ...pair("deliverables", `${dash(delivered.state)}${delivered.changedSinceReview ? ", changed since review" : ""}`, columns, w, delivered.changedSinceReview ? "attention" : undefined, " "));
132
+ out.push(...grid(["FILE", "FORMAT", "STATE", "SHA256"], list(delivered.files).map((file: any) => [s(file.private ? "(private)" : dash(file.path ?? file.id)), s(`${dash(file.format)}${file.version ? ` ${file.version}` : ""}`), s(dash(file.state), file.state === "changed" || file.state === "missing" ? "attention" : undefined), s(short(file.sha256))]), columns));
133
+ }
134
+ const reviews = list(task?.reviews);
135
+ if (reviews.length) out.push(blank, [s("reviews", "muted")], ...grid(["KIND", "REVIEWER", "AT"], reviews.map((r: any) => [s(r.kind, r.kind === "approved" ? "success" : "attention"), s(r.reviewer), s(when(r.at, now), "muted")]), columns));
136
+ const history = list(task?.history);
137
+ if (history.length) out.push(blank, [s(`history (${plural(history.length, "entry")}${typeof task?.flowRevision === "number" && task.flowRevision > history.length ? `, last ${history.length} shown` : ""})`, "muted")], ...grid(["AT", "BY", "EVENT", "NOTE"], historyRows(history, now), columns));
138
+ return out;
139
+ }
140
+
141
+ /** `ahub task show <id>`: the full block. A task that does not exist arrives as a JSON string and prints as that sentence. */
142
+ export function renderTaskShow(task: unknown, columns?: number, now = Date.now()): Span[][] {
143
+ if (typeof task === "string") return para(task, columns);
144
+ return taskBlock(task, columns, now, true);
145
+ }
146
+
147
+ /** `ahub task cancel`: the cancelled task, with who superseded it and why. */
148
+ export function renderTaskCancel(task: unknown, columns?: number, now = Date.now()): Span[][] {
149
+ const t = task as any;
150
+ const out = [...heading(`Task #${t?.id ?? "?"} cancelled`, columns), ...taskBlock(t, columns, now, false)];
151
+ const c = t?.cancelled;
152
+ if (c) out.push(...pair("reason", dash(c.reason), columns, 10, undefined, " "), ...(c.supersededBy ? pair("superseded", String(c.supersededBy), columns, 10, undefined, " ") : []));
153
+ return out;
154
+ }
155
+
156
+ const BACKLOG_HEAD: Record<string, string> = { plan: "Backlog card planned", pin: "Backlog card pinned", "return-untouched": "Task returned to the backlog", "pull-ready": "Backlog card pulled and routed", undo: "Planning change undone", split: "Backlog card split" };
157
+ /** The backlog mutations: the card as the hub now holds it; a split shows its parent and each child. */
158
+ export function renderBacklogResult(op: keyof typeof BACKLOG_HEAD, result: unknown, columns?: number, now = Date.now()): Span[][] {
159
+ const r = result as any;
160
+ const out: Line[] = [...heading(BACKLOG_HEAD[op] ?? "Backlog", columns)];
161
+ if (op === "split") {
162
+ out.push(...taskBlock(r?.parent, columns, now, false));
163
+ const children = list(r?.children);
164
+ out.push(blank, [s(`${plural(children.length, "child card")}`, "muted")], ...grid(["ID", "STATE", "AFTER", "TITLE"], children.map((c: any) => [s(`#${c.id}`, "taskRef"), s(dash(c.state), stateTone(c.state)), s(ids(c.deps)), s(dash(c.title))]), columns));
165
+ return out;
166
+ }
167
+ out.push(...taskBlock(r, columns, now, false), ...stubNote(r, columns));
168
+ const last = list(r?.history).at(-1);
169
+ if (last?.note) out.push(...pair("note", String(last.note), columns, 10, "muted", " "));
170
+ return out;
171
+ }
172
+
173
+ const GROOM_TEXT: Record<string, string> = { "text-dependency": "text names a dependency the card does not declare", overlap: "overlaps other open work", duplicate: "may duplicate another card", "over-wip": "its owner is over the WIP limit", stale: "has not moved for a long time", superseded: "was superseded", blocked: "cannot be routed", "closed-issue": "its issue is closed" };
174
+ /** `ahub backlog groom`: what the planner would look at, one line per finding. */
175
+ export function renderGroom(digest: unknown, columns?: number, _now = Date.now()): Span[][] {
176
+ const d = digest as any;
177
+ const items = list(d?.items);
178
+ const out: Line[] = [...heading("Backlog grooming", columns)];
179
+ if (!items.length) out.push([s("nothing to groom", "muted")]);
180
+ else out.push(...grid(["TASK", "FINDING", "RELATED", "CODE"], items.map((i: any) => [s(`#${i.task}`, "taskRef"), s(GROOM_TEXT[i.kind] ?? String(i.kind)), s(i.related?.length ? ids(i.related) : "-"), s(dash(i.code), "muted")]), columns));
181
+ for (const item of items) if (item.personCommand) out.push(...pair(`#${item.task}`, String(item.personCommand), columns, 6, "info", ""));
182
+ if (list(d?.unknown).length) out.push(blank, ...pair("unknown", `${ids(d.unknown)}: not enough evidence to judge`, columns, 8, "attention"));
183
+ if (d?.truncated) out.push(...para("truncated: the board has more cards than one grooming pass reads", columns, "attention"));
184
+ if (d?.privateTasks === "withheld") out.push(...para("private tasks are withheld", columns, "muted"));
185
+ return out;
186
+ }
187
+
188
+ const RELEASE_TONE: Record<string, Tone> = { active: "success", frozen: "attention", shipped: "muted", dropped: "muted", planned: "info" };
189
+ function releaseBlock(row: any, columns: number | undefined, now: number): Line[] {
190
+ const w = 14, out: Line[] = [...para(`#${row?.id ?? "?"} ${dash(row?.name)}`, columns, undefined, "", 6).map((line, i): Line => i === 0 ? [s(`#${row?.id ?? "?"}`, "issueRef"), s(line[0]!.text.slice(`#${row?.id ?? "?"}`.length), "strong")] : line)];
191
+ out.push(...pair("state", dash(row?.state), columns, w, RELEASE_TONE[row?.state], " "), ...pair("goal", dash(row?.goal), columns, w, undefined, " "), ...pair("order", count(row?.order), columns, w, undefined, " "), ...pair("revision", count(row?.revision), columns, w, undefined, " "));
192
+ out.push(...pair("planned start", when(row?.plannedStart, now), columns, w, undefined, " "), ...pair("cut", when(row?.cut, now), columns, w, undefined, " "), ...pair("ship", when(row?.ship, now), columns, w, undefined, " "));
193
+ const tasks = list(row?.tasks);
194
+ out.push(...pair("scope", tasks.length ? `${plural(tasks.length, "task")}: ${tasks.map((t: any) => `#${t.id} ${t.state}`).join(", ")}` : "no task assigned", columns, w, undefined, " "));
195
+ if (row?.afterCut !== undefined && row?.afterCut !== null) out.push(...pair("after the cut", `${count(row.afterCutCount)} approved outside this release: ${ids(row.afterCut)}${row.afterCutCount > list(row.afterCut).length ? " and more" : ""}`, columns, w, row.afterCutCount ? "attention" : undefined, " "));
196
+ if (row?.afterCutUnknownCount) out.push(...pair("unknown date", `${count(row.afterCutUnknownCount)} approved without a recorded date: ${ids(row.afterCutUnknown)}`, columns, w, "attention", " "));
197
+ if (row?.train) out.push(...pair("train", `${yes(row.train.active)} active${row.train.hotfix ? `, hotfix ${row.train.hotfix}` : ""}`, columns, w, undefined, " "));
198
+ return out;
199
+ }
200
+ /** `ahub release list`: one block per release, in order. */
201
+ export function renderReleaseList(rows: unknown, columns?: number, now = Date.now()): Span[][] {
202
+ const releases = list(rows);
203
+ if (!releases.length) return [[s("no releases")]];
204
+ return releases.flatMap((row, i): Line[] => [...(i ? [blank] : []), ...releaseBlock(row, columns, now)]);
205
+ }
206
+ /** `ahub release show <name>` and the result of `release new|plan|activate|freeze|ship|drop`: the release as the hub holds it. */
207
+ export function renderReleaseShow(row: unknown, columns?: number, now = Date.now()): Span[][] {
208
+ if (typeof row === "string") return para(row, columns);
209
+ return releaseBlock(row, columns, now);
210
+ }
211
+ export const renderReleaseChange = (row: unknown, columns?: number, now = Date.now()): Span[][] => [...heading("Release updated", columns), ...renderReleaseShow(row, columns, now)];
212
+ export const renderReleaseNew = (row: unknown, columns?: number, now = Date.now()): Span[][] => [...heading("Release created", columns), ...renderReleaseShow(row, columns, now)];
@@ -0,0 +1,57 @@
1
+ // Human output for the commands #814 T3 converts: the non-interactive restart/upgrade plan, `recovery status`, and
2
+ // `budget execution configure|disable`. Pure: data in, lines out. It only composes the renderers the interactive
3
+ // screens already use (`planLines`, `renderPlan`, `renderOperation`, `renderBlocked`, `choiceInstruction`), so the
4
+ // non-interactive text never says something the screens would not. `--json` is the caller's branch and prints the
5
+ // document exactly as before.
6
+ import { planLines } from "./upgrade-interactive.ts";
7
+ import { choiceInstruction, renderBlocked, renderOperation, renderPlan, type RecoveryPresentationOptions } from "./upgrade-presentation.ts";
8
+ import { paint, terminalText, wrap, type Span, type Tone } from "./console-state.ts";
9
+ import { renderExecutionBudgetStatus } from "./output.ts";
10
+ import type { NextChoice, RecoveryOperation, UpgradePlan } from "./upgrade.ts";
11
+
12
+ const columnsOf = (options: RecoveryPresentationOptions) => Number.isFinite(options.columns) ? Math.max(2, Math.floor(options.columns)) : 80;
13
+ const text = (value: string, tone: Tone, options: RecoveryPresentationOptions): string[] =>
14
+ wrap(terminalText(value), columnsOf(options)).map(line => paint([{ text: line, tone }], options.color));
15
+
16
+ /**
17
+ * The plan `restart` or `upgrade` would apply, for a person reading a pipe or a `--dry-run`: the same rows the plan
18
+ * screen shows (a peer per line with how it runs and what happens to it), every blocker, and what the plan will not do.
19
+ * Blockers, reconnect-only sessions and fresh starts are part of the rows, so none of them is stderr-only any more.
20
+ */
21
+ export function renderUpgradePlan(plan: UpgradePlan, current: string, options: RecoveryPresentationOptions): string[] {
22
+ const out = renderPlan(plan, current, options, planLines(plan, current));
23
+ const blocked = plan.blockers.length > 0 || plan.projects.some(project => project.blockers.length > 0);
24
+ if (!plan.projects.length) out.push(...text(plan.kind === "upgrade"
25
+ ? `No hub is running, so nothing is carried over: install with \`bun add -g @staix/agent-hub@${plan.version}\`, then \`ahub setup\`.`
26
+ : "This project's hub is not running: `ahub up` starts it.", "muted", options));
27
+ out.push(...text(blocked
28
+ ? "Blocked: take the next action each blocker names, then run this again. Nothing was changed."
29
+ : "Nothing was changed. Apply with `--yes`.", blocked ? "failure" : "muted", options));
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * `ahub recovery status [<id>]`: the receipt's step and projects, then what the person can do about it. A blocked
35
+ * operation also says what happened, why and what to do, exactly as the coordinator's screen does; the wait and
36
+ * resume choices come from `nextChoices`, never from this module.
37
+ */
38
+ export function renderRecoveryStatus(op: RecoveryOperation, runner: number | "unknown" | undefined, choices: readonly NextChoice[], options: RecoveryPresentationOptions): string[] {
39
+ if (op.phase === "blocked") return [...renderOperation(op, runner, options), ...renderBlocked(op, choices, options)];
40
+ const out = renderOperation(op, runner, options);
41
+ if (op.phase !== "completed" && op.phase !== "cancelled") {
42
+ if (op.error) out.push(...text(`Recorded error: ${op.error.split("; next actions: ")[0]}`, "attention", options));
43
+ if (choices.length) out.push(...text("Next:", "strong", options));
44
+ for (const choice of choices) out.push(...text(` ${choiceInstruction(op, choice, options)}`, choice.kind === "stop" || choice.kind === "fresh" ? "failure" : "info", options));
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * `ahub budget execution configure|disable`: the stored budget, or whether one was disabled. A configured budget is shown
51
+ * by the same renderer `budget execution status` uses, so its fields read identically before and after a change.
52
+ */
53
+ export function renderBudgetResult(op: "configure" | "disable", result: unknown, columns?: number, now = Date.now()): Span[][] {
54
+ if (op === "configure") return [[{ text: "Execution budget configured", tone: "strong" }], ...renderExecutionBudgetStatus(result, columns, now).slice(1)];
55
+ const disabled = (result as { disabled?: unknown } | undefined)?.disabled === true;
56
+ return [[{ text: "Execution budget", tone: "strong" }], [{ text: disabled ? "Disabled: it no longer limits model calls." : "No matching execution budget; nothing was disabled.", tone: disabled ? "success" : "muted" }]];
57
+ }
@@ -143,8 +143,8 @@ export interface RecoveryOperation {
143
143
  /** Positive typed store observation; disposal rechecks it before accepting a repaired-session loss. */
144
144
  missingRecordedSession?: { projectId: string; peer: RecoveryNativePeer; sessionId: string; kind: "rollout" | "transcript"; checkedPath?: string };
145
145
  freshOverride?: { peer: RecoveryNativePeer; evidence: NonNullable<RecoveryOperation["missingRecordedSession"]>; checked: "missing"; at: number };
146
- /** #215: every human disposition, in order. Operator reasons only; never task or message text. */
147
- audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; projects: string[] }[];
146
+ /** #215: every human disposition, in order. Operator reasons only; never task or message text. #776: `cause` is the operation's own `error` at that moment, the text the receipt already held. */
147
+ audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; cause?: string; projects: string[] }[];
148
148
  disposition?: { choice: "stop-and-archive"; at: number; projects: Record<string, string>; continuity?: Record<string, "not-archived" | "unavailable"> };
149
149
  /** #215: a refusal no resume can get past was recorded (FinalRefusal); `next` no longer offers resume. */
150
150
  final?: boolean;
@@ -1070,7 +1070,8 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
1070
1070
  delete op.blockedReattachment;
1071
1071
  delete op.presentationCause;
1072
1072
  abandoned(op); // #227: in the same write as the disposition
1073
- (op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
1073
+ // #776: the error that blocked the operation is cleared on disposal, so the audit keeps it.
1074
+ (op.audit ??= []).push({ at, action: "stop-and-archive", reason, ...(op.error ? { cause: op.error } : {}), projects: acts.map((a) => a.planned.project.id) });
1074
1075
  // Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
1075
1076
  delete op.failure;
1076
1077
  op.updatedAt = driver.now(); writeOperation(id, op, home);
package/src/hub/board.ts CHANGED
@@ -339,6 +339,23 @@ export class Board {
339
339
  return row.n;
340
340
  }
341
341
 
342
+ /**
343
+ * #784 AC3: what `plannerWip` counts, split per peer in ONE grouped query (no per-peer or per-task reads on the status hot path). `active` is owned work that is not
344
+ * waiting for review, `reviewWait` is owned work in `in_review`, `reserved` is unowned proposed work a planner pull reserved. For every peer the three add up to
345
+ * `plannerWip(peer)`: this is a read-only view, admission keeps using `plannerWip` unchanged.
346
+ */
347
+ plannerWipBreakdown(): Record<string, { active: number; reviewWait: number; reserved: number }> {
348
+ const rows = this.db.query(`SELECT peer, kind, COUNT(*) AS n FROM (
349
+ SELECT owner AS peer, CASE WHEN state = 'in_review' THEN 'reviewWait' ELSE 'active' END AS kind FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND owner IS NOT NULL
350
+ UNION ALL
351
+ SELECT reserved AS peer, 'reserved' AS kind FROM tasks WHERE state = 'proposed' AND owner IS NULL AND reserved IS NOT NULL AND
352
+ json_extract((SELECT value FROM json_each(tasks.history) WHERE json_extract(value, '$.event') IN ('backlog pulled', 'backlog returned') ORDER BY CAST(key AS INTEGER) DESC LIMIT 1), '$.planning.pull') = 1
353
+ ) GROUP BY peer, kind`).all() as { peer: string; kind: "active" | "reviewWait" | "reserved"; n: number }[];
354
+ const result: Record<string, { active: number; reviewWait: number; reserved: number }> = {};
355
+ for (const row of rows) (result[row.peer] ??= { active: 0, reviewWait: 0, reserved: 0 })[row.kind] = row.n;
356
+ return result;
357
+ }
358
+
342
359
  recordTaskOffer(id: number, offer: TaskOffer, expectedFlowRevision: number): void {
343
360
  const task = this.get(id);
344
361
  if (!task || task.owner !== offer.owner || isClosedTaskState(task.state) || task.history.length !== expectedFlowRevision || offer.assignmentFlowRevision !== expectedFlowRevision) throw new Error("task offer changed");