@staix/agent-hub 0.24.0 → 0.25.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 (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +27 -5
  3. package/docs/agent-notes/adapters.md +1 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +15 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +2 -1
  8. package/docs/dogfood.md +12 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +137 -4
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +10 -3
  13. package/docs/security.md +155 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  15. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  16. package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
  17. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  18. package/docs/verified.json +31 -15
  19. package/package.json +4 -3
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +5118 -4708
  22. package/src/adapters/claude-channel.ts +29 -6
  23. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  24. package/src/adapters/jcode.ts +3 -0
  25. package/src/adapters/registry.ts +1 -1
  26. package/src/cli/acon-register.ts +29 -0
  27. package/src/cli/handoff-args.ts +27 -0
  28. package/src/cli/handoff-output.ts +25 -0
  29. package/src/cli/help.ts +6 -3
  30. package/src/cli/identity.ts +7 -1
  31. package/src/cli/main.ts +76 -7
  32. package/src/cli/output.ts +8 -5
  33. package/src/cli/schedule-output.ts +8 -4
  34. package/src/cli/study-output.ts +71 -0
  35. package/src/cli/study.ts +58 -0
  36. package/src/cli/task-output.ts +1 -0
  37. package/src/hub/board.ts +871 -2
  38. package/src/hub/channel-project.ts +61 -0
  39. package/src/hub/control-client.ts +10 -1
  40. package/src/hub/daemon.ts +247 -11
  41. package/src/hub/envelope.ts +2 -0
  42. package/src/hub/events.ts +4 -1
  43. package/src/hub/execution-budget.ts +12 -0
  44. package/src/hub/handoff.ts +126 -0
  45. package/src/hub/hub-tools.ts +12 -2
  46. package/src/hub/manager.ts +64 -57
  47. package/src/hub/no-writes.ts +1 -1
  48. package/src/hub/path-policy.ts +12 -8
  49. package/src/hub/project.ts +14 -5
  50. package/src/hub/provenance.ts +30 -0
  51. package/src/hub/research-campaign.ts +265 -0
  52. package/src/hub/research-experiments.ts +445 -0
  53. package/src/hub/restart.ts +26 -0
  54. package/src/hub/schedules.ts +210 -9
  55. package/src/hub/session-identity.ts +49 -0
  56. package/src/hub/tasks.ts +894 -22
  57. package/src/hub/ui.ts +15 -6
  58. package/src/memory/capture.ts +2 -2
  59. package/src/ui/index.html +5 -3
  60. package/src/ui/office-space.ts +118 -0
  61. package/src/ui/plugin.ts +293 -0
  62. package/templates/AGENTS.block.md +1 -0
  63. package/templates/profiles/general.json +1 -1
  64. package/templates/profiles/research.json +4 -4
  65. package/templates/profiles/writing.json +1 -1
  66. package/templates/roles/research/analyst.md +1 -0
  67. package/templates/roles/research/fact-checker.md +1 -0
  68. package/templates/roles/research/research-lead.md +1 -0
  69. package/templates/roles/research/researcher.md +1 -0
  70. package/templates/roles/research/statistician.md +1 -0
@@ -0,0 +1,71 @@
1
+ // Human output for `ahub study draft|revise|approve|approve-protocol|close|show`. Pure: the hub's parsed JSON in, spans out, no I/O. `--json` is the caller's branch and prints the same text.
2
+ // A campaign is named by ids, revisions, states and counts. The hub already stubs private campaigns, tasks and sources; this file never asks for a question, scope, path or excerpt.
3
+ import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
4
+ import type { Span, Tone } from "./console-state.ts";
5
+
6
+ const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
7
+ const count = (value: unknown): number => typeof value === "number" && Number.isFinite(value) ? value : 0;
8
+ const STATUS_TONE: Record<string, Tone | undefined> = { approved: undefined, draft: "attention", superseded: "muted", closed: "muted" };
9
+
10
+ /** `ahub study show` with no id: one page of campaigns of this project, with the command that continues it when the hub says there is more. */
11
+ export function renderStudyList(page: unknown, columns?: number): Span[][] {
12
+ const campaigns = list((page as any)?.campaigns), next = typeof (page as any)?.next === "string" ? (page as any).next as string : undefined;
13
+ if (!campaigns.length) return [[s("no research campaigns in this project; a planner or you draft one with `ahub study draft <parent> <contract.json>`", "muted")]];
14
+ const out: Span[][] = [...heading(`Research campaigns (${campaigns.length}${next ? ", more follow" : ""})`, columns)];
15
+ out.push(...grid(["ID", "REV", "STATUS", "PARENT", "UNITS"], campaigns.map((row: any) => [
16
+ s(row.id, "issueRef"), s(`r${count(row.revision)}`), s(row.status, STATUS_TONE[row.status]), s(`#${count(row.parentTaskId)}`, "taskRef"), s(count(row.unitCount)),
17
+ ]), columns));
18
+ if (next) out.push(blank, [s(`more campaigns: ahub study show --after ${next}`, "muted")]);
19
+ return out;
20
+ }
21
+
22
+ const SUPPORT_TONE: Record<string, Tone | undefined> = { enforceable: undefined, supported: undefined, advisory: "muted", unavailable: "attention", unsupported: "attention", unknown: "attention" };
23
+
24
+ /** `ahub study show <id>`: the coverage and stop projection the hub computed. Held work is said first, with every reason. */
25
+ export function renderStudyShow(projection: unknown, columns?: number): Span[][] {
26
+ const p = projection as any, units = list(p?.units), rounds = list(p?.discoveryRounds), reasons = list(p?.reasons);
27
+ const out: Span[][] = [...heading(`Research campaign ${p?.id ?? "?"}, revision ${count(p?.revision)}${p?.held ? ", dispatch HELD" : ""}`, columns)];
28
+ for (const reason of reasons) out.push(...pair("held", reason, columns, 8, "attention"));
29
+ if (p?.sharedPeerLedger) out.push(...pair("budget", "one run ledger is shared by the peers", columns, 8, "muted"));
30
+ if (rounds.length) out.push(...pair("rounds", rounds.map((round: any) => `${count(round.round)}: ${round.complete ? "discovery complete" : "discovery open"}`).join(", "), columns, 8));
31
+ out.push(blank);
32
+ if (!units.length) return [...out, [s("no units", "muted")]];
33
+ out.push(...grid(["UNIT", "ROUND", "TASK", "STATE", "PURPOSE", "HANDOFF", "SOURCES"], units.map((unit: any) => {
34
+ const handoff = unit.handoff ?? {};
35
+ const evidence = handoff.insufficientEvidence ? "insufficient evidence" : handoff.complete ? "complete" : handoff.present ? "incomplete" : "none";
36
+ const covered = list(unit.sourceIds).length;
37
+ const note = [unit.intentionalIndependentCheck ? "independent check" : "", list(unit.duplicateSourceIds).length ? `${list(unit.duplicateSourceIds).length} duplicated` : ""].filter(Boolean).join(", ");
38
+ return [s(unit.unitId, "issueRef"), s(count(unit.round)), s(unit.taskId == null ? "-" : `#${unit.taskId}`, "taskRef"), s(unit.state), s(unit.purpose), s(evidence, handoff.complete || handoff.insufficientEvidence ? undefined : "attention"),
39
+ s(covered ? `${list(handoff.coveredSourceIds).length}/${covered} covered${note ? `, ${note}` : ""}` : note || "-", "muted")];
40
+ }), columns));
41
+ // Every resource of a unit is shown whenever the campaign names a budget, with its support state: a limit the ledger does not carry (`null`) is a placeholder, not a reason to hide a row that says
42
+ // `unavailable` or `advisory`. A campaign that names no budget has nothing to say (its `tokens` row reads `unavailable` whatever the ledger is), so the hub's `budgetNamed` decides, not the values.
43
+ const budgeted = units.flatMap((unit: any) => Object.entries(unit.budget ?? {}).filter(([, value]: [string, any]) => value && typeof value === "object").map(([name, value]: [string, any]) => ({ unit: unit.unitId, name, value })));
44
+ if (budgeted.length && p?.budgetNamed === true) {
45
+ out.push(blank, ...heading("Budget", columns));
46
+ out.push(...grid(["UNIT", "RESOURCE", "SUPPORT", "USED", "LIMIT"], budgeted.map(({ unit, name, value }) => [
47
+ s(unit, "issueRef"), s(name), s(value.support, SUPPORT_TONE[value.support]), s(value.used === null || value.used === undefined ? "-" : value.used), s(value.limit === null || value.limit === undefined ? "-" : value.limit),
48
+ ]), columns));
49
+ }
50
+ return out;
51
+ }
52
+
53
+ const VERB: Record<string, string> = { draft: "drafted", revise: "drafted as the next revision", approve: "approved", close: "closed" };
54
+
55
+ /** The receipt of a draft, revision, approval or close. */
56
+ export function renderStudyReceipt(op: string, receipt: unknown, columns?: number): Span[][] {
57
+ const r = receipt as any, units = list(r?.units);
58
+ if (op === "approve_protocol") {
59
+ const hash = (value: unknown) => typeof value === "string" && value.length === 64 && /^[a-f0-9]{64}$/.test(value) ? value : "unknown";
60
+ const by = typeof r?.approvedBy === "string" && /^[a-z][a-z0-9_-]{0,63}$/.test(r.approvedBy) ? r.approvedBy : "unknown";
61
+ const at = typeof r?.approvedAt === "number" && Number.isFinite(r.approvedAt) && r.approvedAt >= 0 && r.approvedAt <= 8_640_000_000_000_000
62
+ ? new Date(r.approvedAt).toISOString().slice(0, 19).replace("T", " ") + " UTC" : "unknown";
63
+ return [...heading("Research protocol approval", columns), ...pair("protocol hash", hash(r?.protocolHash), columns, 16),
64
+ ...pair("evaluator hash", hash(r?.evaluatorHash), columns, 16), ...pair("approved by", by, columns, 16), ...pair("approved at", at, columns, 16)];
65
+ }
66
+ const out: Span[][] = [...heading(`Research campaign ${r?.id ?? "?"} revision ${count(r?.revision)} ${VERB[op] ?? op}`, columns)];
67
+ out.push(...pair("status", r?.status, columns, 8, STATUS_TONE[r?.status]), ...pair("parent", `#${count(r?.parentTaskId)}`, columns, 8, "taskRef"), ...pair("units", count(r?.unitCount), columns, 8));
68
+ if (units.length) out.push(blank, ...grid(["UNIT", "TASK", "ROUND"], units.map((unit: any) => [s(unit.unitId, "issueRef"), s(`#${count(unit.taskId)}`, "taskRef"), s(count(unit.round))]), columns));
69
+ if (op === "draft" || op === "revise") out.push(blank, [s(`nothing is dispatched until you approve: ahub study approve ${r?.id} ${count(r?.revision)}`, "muted")]);
70
+ return out;
71
+ }
@@ -0,0 +1,58 @@
1
+ // Argument parsing for `ahub study` (#601). Pure: argv in, one request description or a usage line out, so the I/O in main.ts stays a thin dispatcher and every form is testable without a daemon.
2
+ export const STUDY_USAGE = "usage: ahub study draft <parent> <contract.json> | revise <id> <revision> <contract.json> | approve <id> <revision> | approve-protocol <hash> | close <id> <revision> --reason <text> | show [<id> | --after <id>] [--json] [--color=auto|always|never]";
3
+
4
+ export type StudyRequest =
5
+ | { op: "draft"; parentTaskId: number; file: string }
6
+ | { op: "revise"; id: string; revision: number; file: string }
7
+ | { op: "approve"; id: string; revision: number }
8
+ | { op: "approve_protocol"; hash: string }
9
+ | { op: "close"; id: string; revision: number; reason: string }
10
+ | { op: "show"; id?: string; after?: string };
11
+
12
+ const ID = /^rc-[a-f0-9]{12}$/, INT = /^[1-9]\d{0,8}$/;
13
+
14
+ /** `operands` is argv with the presentation flags (`--json`, `--color=`) already removed by `splitPresentation`. `--reason` takes the rest of the words, as `task cancel --reason` does. */
15
+ export function parseStudyArgs(operands: readonly string[]): StudyRequest | { usage: string } {
16
+ const usage = (detail?: string) => ({ usage: detail ? `${detail}\n${STUDY_USAGE}` : STUDY_USAGE });
17
+ const [sub, ...rest] = operands;
18
+ const id = (value: string | undefined) => value !== undefined && ID.test(value) ? value : undefined;
19
+ const int = (value: string | undefined) => value !== undefined && INT.test(value) ? Number(value) : undefined;
20
+ if (sub === "approve-protocol") {
21
+ if (rest.length !== 1) return usage();
22
+ const hash = rest[0];
23
+ return hash?.length === 64 && /^[a-f0-9]{64}$/.test(hash) ? { op: "approve_protocol", hash } : usage("a protocol hash is 64 lowercase hexadecimal characters");
24
+ }
25
+ if (sub === "show") {
26
+ // `--after <id>` continues a list from the cursor a previous page printed; it has no meaning with an id, which shows one campaign.
27
+ if (rest[0] === "--after") {
28
+ if (rest.length !== 2) return usage();
29
+ return id(rest[1]) ? { op: "show", after: rest[1]! } : usage("a campaign id looks like rc-0123456789ab");
30
+ }
31
+ if (rest.length > 1) return usage();
32
+ if (rest[0] === undefined) return { op: "show" };
33
+ return id(rest[0]) ? { op: "show", id: rest[0] } : usage("a campaign id looks like rc-0123456789ab");
34
+ }
35
+ if (sub === "draft") {
36
+ const parent = int(rest[0]);
37
+ if (parent === undefined || !rest[1] || rest.length !== 2) return usage();
38
+ return { op: "draft", parentTaskId: parent, file: rest[1] };
39
+ }
40
+ if (sub === "revise") {
41
+ const revision = int(rest[1]);
42
+ if (!id(rest[0]) || revision === undefined || !rest[2] || rest.length !== 3) return usage();
43
+ return { op: "revise", id: rest[0]!, revision, file: rest[2] };
44
+ }
45
+ if (sub === "approve") {
46
+ const revision = int(rest[1]);
47
+ if (!id(rest[0]) || revision === undefined || rest.length !== 2) return usage();
48
+ return { op: "approve", id: rest[0]!, revision };
49
+ }
50
+ if (sub === "close") {
51
+ const revision = int(rest[1]);
52
+ const at = rest.indexOf("--reason");
53
+ if (!id(rest[0]) || revision === undefined || at !== 2) return usage();
54
+ const reason = rest.slice(at + 1).join(" ").trim();
55
+ return reason ? { op: "close", id: rest[0]!, revision, reason } : usage("study close needs a reason");
56
+ }
57
+ return usage();
58
+ }
@@ -104,6 +104,7 @@ function taskBlock(task: any, columns: number | undefined, now: number, detail:
104
104
  ...pair("class", dash(task?.class), columns, w, undefined, " "), ...pair("owner", taskHolderText(task, "owner"), columns, w, undefined, " "), ...pair("reviewer", taskHolderText(task, "reviewer"), columns, w, undefined, " "));
105
105
  if (task?.planner !== undefined) out.push(...pair("planner", taskHolderText(task, "planner"), columns, w, undefined, " "));
106
106
  if (task?.reserved) out.push(...pair("reserved", task.reserved, columns, w, undefined, " "));
107
+ if (task?.handoffOrigin) out.push(...pair("from", `project ${task.handoffOrigin.projectId}, handed off by ${task.handoffOrigin.peer}${typeof task.handoffOrigin.task === "number" ? ` (their task #${task.handoffOrigin.task})` : ""}`, columns, w, "attention", " "));
107
108
  out.push(...pair("after", ids(task?.deps), columns, w, undefined, " "));
108
109
  if (typeof task?.release === "number") out.push(...pair("release", `#${task.release}`, columns, w, undefined, " "));
109
110
  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, " "));