@staix/agent-hub 0.26.0 → 0.27.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.
@@ -11931,7 +11931,7 @@ var package_default;
11931
11931
  var init_package = __esm(() => {
11932
11932
  package_default = {
11933
11933
  name: "@staix/agent-hub",
11934
- version: "0.26.0",
11934
+ version: "0.27.0",
11935
11935
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
11936
11936
  license: "MIT",
11937
11937
  type: "module",
@@ -12173,7 +12173,7 @@ function profileTaskTools(profile = "dev", stages, criteria = false) {
12173
12173
  }
12174
12174
  if (customProfile && tool.name === "hub_task_propose")
12175
12175
  properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter((v) => projectProfile("dev").classes[v])])] };
12176
- if (customProfile && properties.plan && tool.name !== "hub_task_accept")
12176
+ if (customProfile && properties.plan && tool.name !== "hub_task_accept" && tool.name !== "hub_experiment_record")
12177
12177
  properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map((key) => [key, plan.properties[key] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
12178
12178
  if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
12179
12179
  const summaryProp = properties.summary;
@@ -12187,7 +12187,7 @@ function profileTaskTools(profile = "dev", stages, criteria = false) {
12187
12187
  const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map((key) => [key, plan.properties[key] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
12188
12188
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
12189
12189
  }
12190
- if (criteria && properties.plan) {
12190
+ if (criteria && properties.plan && tool.name !== "hub_experiment_record") {
12191
12191
  const current = properties.plan;
12192
12192
  properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties, ...evidencePlanProperties } };
12193
12193
  }
@@ -12220,7 +12220,7 @@ var init_hub_tools = __esm(() => {
12220
12220
  init_roles();
12221
12221
  init_registry();
12222
12222
  init_envelope();
12223
- IMPLEMENTER_HUB_TOOL_NAMES = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_study_handoff"]);
12223
+ IMPLEMENTER_HUB_TOOL_NAMES = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_experiment_show", "hub_experiment_record", "hub_study_handoff"]);
12224
12224
  IMPLEMENTER_PERMISSION_TOOL = {
12225
12225
  name: "hub_permission_request",
12226
12226
  description: "Native permission prompt for this current owned child. The person allows once or denies; failure is deny.",
@@ -12274,7 +12274,7 @@ var init_hub_tools = __esm(() => {
12274
12274
  additionalProperties: false
12275
12275
  };
12276
12276
  acceptPlan = { ...plan, properties: { ...plan.properties, sections: list("document heading anchors, such as report.md#methods"), sources: list("citation ids, DOI or source URL ids"), claims: list("short claim ids to verify") } };
12277
- STUDY_TOOLS = new Set(["hub_study_draft", "hub_study_revise", "hub_study_show", "hub_study_handoff"]);
12277
+ STUDY_TOOLS = new Set(["hub_study_draft", "hub_study_revise", "hub_study_show", "hub_experiment_show", "hub_experiment_record", "hub_study_handoff"]);
12278
12278
  HANDOFF_TOOLS = new Set(["hub_task_handoff", "hub_task_handoffs"]);
12279
12279
  BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
12280
12280
  CAPABILITY_TOOLS = new Set(["hub_capabilities", "hub_capabilities_publish", "hub_invoke"]);
@@ -12292,6 +12292,8 @@ var init_hub_tools = __esm(() => {
12292
12292
  tool2("hub_study_draft", "Store a research campaign contract as a DRAFT revision 1 on an approved parent task. Configured planners only; it creates no task, dispatches nothing and never approves (only the person approves, with ahub study approve). The contract is data: question, scope, exclusions, sourceIds, units, deliverableSections, stopPolicy and an optional budget.", { parentTaskId: id2, contract: { type: "object" } }, ["parentTaskId", "contract"]),
12293
12293
  tool2("hub_study_revise", "Store the next DRAFT revision of a research campaign with the revision you read. Configured planners only; a stale revision refuses, and a draft holds older bound work until the person approves it. Never approves.", { id: str2, revision: { type: "integer", minimum: 1 }, contract: { type: "object" } }, ["id", "revision", "contract"]),
12294
12294
  tool2("hub_study_show", "Read a research campaign's coverage and stop projection: per unit round, state, handoff completeness and budget support, and why dispatch is held. Read-only. A private campaign, task or source shows ids, counts and states only, never its question, scope, path or excerpt. Give no id to list this project's campaigns, at most 50 per call: when the reply carries `next`, pass it as `after` for the following page.", { id: str2, after: str2 }),
12295
+ tool2("hub_experiment_record", "Record experiment metadata for your current in-progress campaign unit: protocol, attempt, event or comparison plan, or relay a signed runner receipt. The hub validates ownership, sources, campaign and signatures. It runs no command, starts no experiment and contacts no runner. Refused during a PII turn.", { kind: { type: "string", enum: ["protocol", "attempt", "event", "plan", "receipt"] }, taskId: id2, protocol: { type: "object" }, attempt: { type: "object" }, attemptId: str2, idempotencyKey: str2, event: { type: "object" }, plan: { type: "object" }, receipt: { type: "object" }, signature: str2 }, ["kind"]),
12296
+ tool2("hub_experiment_show", "Read the experiment attempts of a research campaign, one attempt, or one baseline/variant comparison under a recorded plan. Read-only: it runs no command and reads no file. Every metric, hash and setting says where it came from (declared by an agent, host-observed, runner-attested or person-attested); a declared value is never shown as observed, failed, cancelled and unknown attempts stay in the counts, and a repeat is never an independent reproduction. A private campaign, task or source shows ids, counts and states only. Give campaignId (with after to page, at most 50 rows), or attemptId, or planHash with baselineAttemptId and variantAttemptId.", { campaignId: str2, attemptId: str2, after: { type: "integer", minimum: 0 }, eventAfter: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 50 }, planHash: str2, baselineAttemptId: str2, variantAttemptId: str2 }),
12295
12297
  tool2("hub_task_handoff", "Hand work that belongs to ANOTHER project that runs agent-hub to that project's board. Use it instead of implementing it from this session: a feature or behavior change first needs a spec issue in that project (pass its number as issue); a mechanical chore (an exact version pin, a regenerated file) goes straight to a task. The target is a registered project (its root or id) whose hub is running; it is not queued. Planner, conductor or person only. Never for a [pii] task or during a private turn. You learn the task's state changes as kind task messages.", { project: str2, title: str2, class: { type: "string", description: "task class, default implement" }, detail: str2, paths: { type: "array", items: str2, description: "repository-relative paths in the TARGET project" }, issue: { type: "integer", minimum: 1, description: "the target repository's issue number" }, task: { type: "integer", minimum: 1, description: "your own task in THIS project that this work comes from; a [pii] task is refused" } }, ["project", "title"]),
12296
12298
  tool2("hub_task_handoffs", "List what this project handed to other projects, with the last state each target reported. Read-only.", {}),
12297
12299
  tool2("hub_study_handoff", "Record the handoff for the research unit you currently own and have in progress: evidence per source, unresolved questions, contradictions and limitations, or insufficientEvidence. Only the bound owner; every required field must be present. Not a task completion.", { handoff: { type: "object" } }, ["handoff"]),
@@ -12352,7 +12354,7 @@ var init_hub_tools = __esm(() => {
12352
12354
  tool2("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str2 }, ["peer"])
12353
12355
  ];
12354
12356
  CONDUCTOR_TOOL_NAMES = new Set(CONDUCTOR_TOOLS.map((t) => t.name));
12355
- READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
12357
+ READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
12356
12358
  ROLE_TEXT = {
12357
12359
  conductor: "conductor: plan and split work into tasks with owners, watch the team with hub_status, move stalled work, and ensure every task is reviewed (review it yourself only if you also hold reviewer). Report results and open decisions to the person. Do not implement tasks you handed out. Never ask a peer to answer an approval; ask the person for human-only actions. You may release only holds you placed, never human holds or budget pauses.",
12358
12360
  planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs, and after: [ids] for work that must wait for other tasks) instead of doing everything yourself.",
@@ -23435,7 +23437,7 @@ init_security_baseline();
23435
23437
  init_version();
23436
23438
 
23437
23439
  // src/hub/no-writes.ts
23438
- var OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff"]);
23440
+ var OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff", "hub_experiment_record"]);
23439
23441
 
23440
23442
  // src/hub/github-import.ts
23441
23443
  init_sdlc();
package/src/cli/help.ts CHANGED
@@ -27,7 +27,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
27
27
  ["ahub sdlc status [task] | explain <task> [--json] [--color=auto|always|never] | export <task> | record <task> <receipt-json>", "read head-bound lifecycle gates and the next actor/action; status and explain are open to any agent as a public view (private tasks stay stubs), export and record are person only; export stays JSON"],
28
28
  ["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...] [--json] [--color=auto|always|never]", "read-only merge and release evidence; open to any agent as public readback of the checkout's repositories, never an authorization"],
29
29
  ["ahub sdlc merge <task> [--dry-run] [--json] [--color=auto|always|never]", "guarded squash merge of an approved task at its approved head after every gate is re-read; follows sdlc.toml auto_merge (off, dry-run, on); --dry-run only reports (person or conductor)"],
30
- ["ahub sdlc authorize-release <version> [--json] [--color=auto|always|never]", "approve this daemon checkout commit, version and fresh docs (person only; restart revokes)"],
30
+ ["ahub sdlc authorize-release <version> [--json] [--color=auto|always|never]", "approve this daemon checkout commit, version and fresh docs (person or unique configured conductor on the person's explicit release instruction naming the release in its own session; hub/task/review/file/peer relays never qualify; restart revokes)"],
31
31
  ["ahub sdlc release-guard -- <literal git push|gh release create|npm publish argv>", "read-only authorization preflight; init --release-guard installs the tag pre-push hook"],
32
32
  ["ahub sdlc release-guard-hook <remote-name> <remote-url>", "internal pre-push readback; validates version tags and forwards exact stdin on success"],
33
33
  ["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
@@ -108,6 +108,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
108
108
  ["ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]", "person only, never a conductor or an agent shell: review a recurring-unmet-criterion candidate the hub filed (#514a); accept writes its one review checklist line, added to your current checklist, to .agenthub/config.local.json through the settings writer (this machine only, never a tracked file, ahub settings undo puts it back, applies after a restart) and closes the card; reject only closes it"],
109
109
  ["ahub study draft <parent> <contract.json> | revise <id> <revision> <contract.json> | approve <id> <revision> | close <id> <revision> --reason <text> | approve-protocol <hash> [--json] [--color=auto|always|never]", "person only: store a research campaign contract as a draft, revise it, approve it (the only step that creates the unit tasks and lets work start) or close it; approve-protocol binds approval to the exact protocol and evaluator hashes; a planner drafts through hub_study_draft and hub_study_revise and can never approve"],
110
110
  ["ahub study show [<id> | --after <id>] [--json] [--color=auto|always|never]", "read-only for anyone: list this project's research campaigns, 50 per page (the page names the --after cursor that continues it), or one campaign's coverage per unit, handoff state, budget support and why dispatch is held; a private campaign, task or source shows ids, counts and states only"],
111
+ ["ahub study experiments (--campaign <id> [--after <n>] | --attempt <ra-id> [--events-after <n>] | --plan <hash> --baseline <ra-id> --variant <ra-id>) [--json] [--color=auto|always|never]", "read-only for anyone, the same read as hub_experiment_show: a campaign's recorded attempts (50 a page, --after continues), one attempt with its events, or one comparison under a recorded plan; each value names its provenance and a declared value is never shown as observed"],
111
112
  ["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "what each task delivered and a reviewer approved, by id, hash and size (a path only where the PII screen allows); --check measures the files now and marks one changed since review; an agent sees only the tasks hub_task_show shows it"],
112
113
  ["ahub task show <id> [--json] [--full] [--color=auto|always|never] | escalate <id>", "full task with history (the person sees PII text, an agent the public view) / hand it to the next peer in escalate_to (person or configured conductor)"],
113
114
  ["ahub task role <id> <role|none>", "the person, or an agent on a task it owns: change bounded catalog guidance on an open task; routing and authority are unchanged"],
@@ -23,7 +23,7 @@ export function liveTestRefusal(command: string, args: readonly string[], contex
23
23
  || (command === "backlog" && sub !== "list") || (command === "release" && !["list", "show"].includes(sub ?? ""))
24
24
  || (command === "memory" && sub !== "graph")
25
25
  // #601: a campaign's draft, revise, approve and close are the person's verdicts; only `show` is a read. Without this an agent shell could run them as the person through `--live-test`.
26
- || (command === "study" && sub !== "show")
26
+ || (command === "study" && sub !== "show" && sub !== "experiments")
27
27
  || (command === "recovery" && (sub === "reattach" || sub === "dispose" && context.repairedFresh === true))
28
28
  || (command === "board" && args[0] === "import") || (command === "sdlc" && (args[0] === "record" || args[0] === "accept" || args[0] === "accept-departure"))
29
29
  || (command === "bench" && args.find(arg => arg !== "--json") === "run") || command === "pet"
@@ -85,7 +85,7 @@ const peerReadForms: Readonly<Record<string, Readonly<Record<string, PeerReadFor
85
85
  sdlc: { status: { positional: 1, displayFlags: sdlcReadDisplayFlags }, explain: { positional: 1, displayFlags: sdlcReadDisplayFlags }, queue: { positional: 1, minimum: 1, displayFlags: sdlcReadDisplayFlags }, release: { positional: 33, minimum: 1, displayFlags: sdlcReadDisplayFlags } },
86
86
  release: { show: { positional: 1, minimum: 1, displayFlags: [] } },
87
87
  // #601: reading a campaign is harmless for any agent (the hub stubs a private one); draft, revise, approve and close fall to `console`.
88
- study: { show: { positional: 1, valueFlags: ["--after"], displayFlags: sdlcReadDisplayFlags } },
88
+ study: { show: { positional: 1, valueFlags: ["--after"], displayFlags: sdlcReadDisplayFlags }, experiments: { valueFlags: ["--campaign", "--attempt", "--after", "--events-after", "--plan", "--baseline", "--variant"], displayFlags: sdlcReadDisplayFlags } },
89
89
  projects: { "": {} },
90
90
  models: { status: {} },
91
91
  recovery: { status: { positional: 1 } },
@@ -138,8 +138,8 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
138
138
  if (conductorOperation(command,args)) return "conductor";
139
139
  if (command === "stop") return "console";
140
140
  if (command === "sdlc" && ["release-guard", "release-guard-hook"].includes(args[0] ?? "")) return "allowed";
141
- // #786: the guarded merge is the person's or the configured conductor's; the daemon refuses any other peer by role.
142
- if (command === "sdlc" && args[0] === "merge") return "conductor";
141
+ // #786, #1063: guarded merge and recording explicitly instructed release consent admit the person or unique configured conductor; the daemon checks the role.
142
+ if (command === "sdlc" && ["merge", "authorize-release"].includes(args[0] ?? "")) return "conductor";
143
143
  if (command === "role") return ["list", "show"].includes(args[0] ?? "") ? "allowed" : "console";
144
144
  if (command === "task") {
145
145
  // #933: cancel, label and role act on the caller's own task only; the daemon refuses another peer's task by role (Tasks.cancel, task_catalog_role, task_label_check).
@@ -196,7 +196,7 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
196
196
  if (command === "learn") return ["list", "accept", "reject"].includes(args.find(arg => arg !== "--json") ?? "") ? `learn ${args.find(arg => arg !== "--json")}` : "learn";
197
197
  if (command === "source") return ["add", "list"].includes(args.find(arg => arg !== "--json") ?? "") ? `source ${args.find(arg => arg !== "--json")}` : "source";
198
198
  // #601: a campaign id and a contract path are text the person chose, so the audit names the subcommand only.
199
- if (command === "study") return ["draft", "revise", "approve", "close", "show"].includes(args[0] ?? "") ? `study ${args[0]}` : "study";
199
+ if (command === "study") return ["draft", "revise", "approve", "close", "show", "experiments"].includes(args[0] ?? "") ? `study ${args[0]}` : "study";
200
200
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
201
201
  if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn", "study"].includes(command)) return "unknown";
202
202
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
package/src/cli/main.ts CHANGED
@@ -30,7 +30,7 @@ import { renderIntake, renderIntakeChange } from "./intake-output.ts";
30
30
  import { renderLearnChange, renderLearnList } from "./learn-output.ts";
31
31
  import { renderCapabilities } from "./capabilities-output.ts";
32
32
  import { parseStudyArgs } from "./study.ts";
33
- import { renderStudyList, renderStudyReceipt, renderStudyShow } from "./study-output.ts";
33
+ import { renderExperiments, renderStudyList, renderStudyReceipt, renderStudyShow } from "./study-output.ts";
34
34
  import { renderHandoffReceipt, renderHandoffs } from "./handoff-output.ts";
35
35
  import { parseHandoffArgs } from "./handoff-args.ts";
36
36
  import { terminalText } from "./console-state.ts";
@@ -1525,6 +1525,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1525
1525
  catch { return fail("cannot read the contract file"); }
1526
1526
  try { return JSON.parse(text); } catch { return fail("the contract file is not valid JSON"); }
1527
1527
  };
1528
+ if (parsed.op === "experiments") {
1529
+ // A read of the records Tasks keeps (hub_experiment_show): it runs no command and reads no file, and the hub blanks a private record itself.
1530
+ const args = parsed.attempt ? { attemptId: parsed.attempt, ...(parsed.eventsAfter === undefined ? {} : { eventAfter: parsed.eventsAfter }) } : parsed.plan ? { planHash: parsed.plan, baselineAttemptId: parsed.baseline, variantAttemptId: parsed.variant } : { campaignId: parsed.campaign, ...(parsed.after === undefined ? {} : { after: parsed.after }) };
1531
+ return showToolText(data => renderExperiments(data, present.columns), await taskOp("hub_experiment_show", args), present);
1532
+ }
1528
1533
  if (parsed.op === "show") return showToolText(data => parsed.id ? renderStudyShow(data, present.columns) : renderStudyList(data, present.columns), await taskOp("hub_study_show", parsed.id ? { id: parsed.id } : parsed.after ? { after: parsed.after } : {}), present);
1529
1534
  const request = parsed.op === "draft" ? { parentTaskId: parsed.parentTaskId, contract: contract(parsed.file) }
1530
1535
  : parsed.op === "revise" ? { id: parsed.id, revision: parsed.revision, contract: contract(parsed.file) }
@@ -69,3 +69,59 @@ export function renderStudyReceipt(op: string, receipt: unknown, columns?: numbe
69
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
70
  return out;
71
71
  }
72
+
73
+ const ATTEMPT_TONE: Record<string, Tone | undefined> = { succeeded: undefined, running: undefined, declared: "muted", failed: "attention", cancelled: "attention", unknown: "attention" };
74
+ const PROVENANCE_TONE: Record<string, Tone | undefined> = { declared: "muted", unknown: "attention" };
75
+ const clip = (text: string, max = 48) => text.length > max ? `${text.slice(0, max - 1)}…` : text;
76
+ /** A value cell: the value and where it came from, so a declared number never reads as an observed one. Anything the hub withheld prints as the hub wrote it. */
77
+ const sourced = (cell: any): [string, string] => {
78
+ if (cell === "[pii]") return ["[pii]", "withheld"];
79
+ if (!cell || typeof cell !== "object" || !("provenance" in cell)) return ["-", "unknown"];
80
+ const value = cell.value === null || cell.value === undefined ? "-" : typeof cell.value === "object" ? "(structured)" : String(cell.value);
81
+ return [clip(value), String(cell.provenance)];
82
+ };
83
+
84
+ /**
85
+ * `ahub study experiments`: the attempts of one campaign, one attempt, or one baseline/variant comparison, as the hub's `Tasks.experimentShow` returned them. Pure: it reads no file, runs
86
+ * no command and asks for no protocol text. Every row says whether it is approved, post-hoc or predeclared and that a repeat is not an independent reproduction; every value says where it
87
+ * came from; failed, cancelled and unknown attempts are in the counts. A private record arrives from the hub already as ids, counts and states.
88
+ */
89
+ export function renderExperiments(view: unknown, columns?: number): Span[][] {
90
+ const v = view as any, attempts = list(v?.attempts), counts = v?.counts ?? {}, statuses = counts.statuses ?? {};
91
+ const out: Span[][] = [...heading(`Experiment attempts (${attempts.length} of ${count(counts.all)} in this campaign${v?.nextCursor !== undefined ? ", more follow" : ""})`, columns)];
92
+ const order = ["declared", "running", "succeeded", "failed", "cancelled", "unknown"];
93
+ out.push(...pair("all", order.map(name => `${name} ${count(statuses[name])}`).join(", "), columns, 8, "muted"));
94
+ if (v?.comparison) {
95
+ const c = v.comparison, compat = c.projection?.compatibility;
96
+ out.push(blank, ...heading("Comparison", columns));
97
+ if (c.projection === "[pii]") out.push(...pair("plan", `${String(c.planHash).slice(0, 12)}: withheld (a private source or record)`, columns, 8, "attention"));
98
+ else {
99
+ out.push(...pair("plan", String(c.planHash).slice(0, 12), columns, 8));
100
+ out.push(...pair("compatible", compat?.compatible ? "yes, under this plan" : `no: ${compat?.field ?? "unknown"} differs (${compat?.side ?? "pair"}); shown, not ranked together`, columns, 8, compat?.compatible ? undefined : "attention"));
101
+ }
102
+ out.push(...pair("approved", c.approved ? "both protocols bound to a person's approval" : "not both approved", columns, 8, c.approved ? undefined : "attention"));
103
+ out.push(...pair("pair", `${c.baselineAttemptId} (baseline) and ${c.variantAttemptId} (variant)`, columns, 8, "muted"));
104
+ }
105
+ if (!attempts.length) return [...out, blank, [s("no attempts recorded", "muted")]];
106
+ out.push(blank, ...grid(["ATTEMPT", "TASK", "PHASE", "STATUS", "APPROVAL", "POST-HOC", "NOTE"], attempts.map((row: any) => [
107
+ s(row.id, "issueRef"), s(`#${count(row.taskId)}`, "taskRef"), s(row.phase), s(row.status, ATTEMPT_TONE[row.status]), s(row.approval, row.approval === "approved" ? undefined : "attention"), s(row.postHoc, row.postHoc === "post-hoc" ? "attention" : "muted"),
108
+ s([row.private ? "private, details withheld" : "", row.retryOf ? `retry of ${row.retryOf}` : "", row.reproducesAttemptId ? `repeat of ${row.reproducesAttemptId}, not independent` : "", row.eventsComplete === false ? "events continue" : ""].filter(Boolean).join("; ") || "-", "muted"),
109
+ ]), columns));
110
+ for (const row of attempts) {
111
+ if (row.private) continue;
112
+ const declared = row.evidence?.declared, metrics = list(declared?.metrics), measured = list(row.evidence?.measured), runners = list(row.evidence?.runnerAttested);
113
+ const lines: [string, string, string][] = [];
114
+ for (const metric of metrics) { const [value, from] = sourced(metric.latest); lines.push([`${metric.name} (${metric.unit})`, value, from]); }
115
+ for (const field of ["exitStatus", "startedAt"]) { const [value, from] = sourced(declared?.[field]); if (value !== "-") lines.push([field, value, from]); }
116
+ for (const m of measured) for (const [field, cell] of Object.entries(m.values ?? {})) { const [value, from] = sourced(cell); lines.push([`${field} (${m.source})`, value, from]); }
117
+ for (const r of runners) { const [value, from] = sourced(r.sealing); lines.push([`sealed (runner ${clip(String(r.runner), 24)})`, value, from]); const [match, matchFrom] = sourced(r.codeMatch); lines.push(["code matches protocol", match, matchFrom]); }
118
+ const [sealed, sealedFrom] = sourced(declared?.sealed);
119
+ if (!runners.length) lines.push(["sealed", sealed, `${sealedFrom}; isolation unverified`]);
120
+ if (!lines.length) continue;
121
+ out.push(blank, ...heading(`Evidence of ${row.id}`, columns), ...grid(["FIELD", "VALUE", "SOURCE"], lines.map(([field, value, from]) => [s(field), s(value), s(from, PROVENANCE_TONE[from] ?? (from.startsWith("declared") ? "muted" : undefined))]), columns));
122
+ }
123
+ out.push(blank, [s(`declared values are an agent's own words; only host-observed, runner-attested or person-attested rows are evidence, and none of them proves the science. A repeat is never an independent reproduction.`, "muted")]);
124
+ if (v?.nextCursor !== undefined) out.push([s(`more attempts: ahub study experiments --campaign <id> --after ${v.nextCursor}`, "muted")]);
125
+ for (const row of attempts) if (row.eventsComplete === false && row.nextEventCursor !== undefined) out.push([s(`more events of ${row.id}: ahub study experiments --attempt ${row.id} --events-after ${row.nextEventCursor}; the values above come from the events shown so far`, "attention")]);
126
+ return out;
127
+ }
package/src/cli/study.ts CHANGED
@@ -1,5 +1,5 @@
1
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]";
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>] | experiments (--campaign <id> [--after <n>] | --attempt <ra-id> [--events-after <n>] | --plan <hash> --baseline <ra-id> --variant <ra-id>) [--json] [--color=auto|always|never]";
3
3
 
4
4
  export type StudyRequest =
5
5
  | { op: "draft"; parentTaskId: number; file: string }
@@ -7,9 +7,10 @@ export type StudyRequest =
7
7
  | { op: "approve"; id: string; revision: number }
8
8
  | { op: "approve_protocol"; hash: string }
9
9
  | { op: "close"; id: string; revision: number; reason: string }
10
- | { op: "show"; id?: string; after?: string };
10
+ | { op: "show"; id?: string; after?: string }
11
+ | { op: "experiments"; campaign?: string; attempt?: string; after?: number; eventsAfter?: number; plan?: string; baseline?: string; variant?: string };
11
12
 
12
- const ID = /^rc-[a-f0-9]{12}$/, INT = /^[1-9]\d{0,8}$/;
13
+ const ID = /^rc-[a-f0-9]{12}$/, INT = /^[1-9]\d{0,8}$/, ATTEMPT = /^ra-[a-f0-9]{12}$/, HASH = /^[a-f0-9]{64}$/, CURSOR = /^(0|[1-9]\d{0,8})$/;
13
14
 
14
15
  /** `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
16
  export function parseStudyArgs(operands: readonly string[]): StudyRequest | { usage: string } {
@@ -22,6 +23,28 @@ export function parseStudyArgs(operands: readonly string[]): StudyRequest | { us
22
23
  const hash = rest[0];
23
24
  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
  }
26
+ if (sub === "experiments") {
27
+ // Three closed forms, the ones Tasks.experimentShow takes: a campaign page, one attempt, or one comparison. A flag with no value, a repeat, a stray word or a mix is a usage error before anything is sent.
28
+ const names = ["--campaign", "--attempt", "--after", "--events-after", "--plan", "--baseline", "--variant"], seen = new Map<string, string>();
29
+ for (let at = 0; at < rest.length; at += 2) {
30
+ const name = rest[at], value = rest[at + 1];
31
+ if (name === undefined || !names.includes(name) || value === undefined || value.startsWith("--") || seen.has(name)) return usage();
32
+ seen.set(name, value);
33
+ }
34
+ const campaign = seen.get("--campaign"), attempt = seen.get("--attempt"), after = seen.get("--after"), eventsAfter = seen.get("--events-after"), plan = seen.get("--plan"), baseline = seen.get("--baseline"), variant = seen.get("--variant");
35
+ if (campaign !== undefined && !id(campaign)) return usage("a campaign id looks like rc-0123456789ab");
36
+ for (const [name, value] of [["--attempt", attempt], ["--baseline", baseline], ["--variant", variant]] as const) if (value !== undefined && !ATTEMPT.test(value)) return usage(`${name} takes an attempt id like ra-0123456789ab`);
37
+ if (plan !== undefined && !HASH.test(plan)) return usage("a plan hash is 64 lowercase hexadecimal characters");
38
+ if (after !== undefined && !CURSOR.test(after)) return usage("--after takes the cursor a previous page printed");
39
+ if (eventsAfter !== undefined && !CURSOR.test(eventsAfter)) return usage("--events-after takes the event cursor a previous page printed");
40
+ const comparison = plan !== undefined || baseline !== undefined || variant !== undefined;
41
+ if (comparison) return plan !== undefined && baseline !== undefined && variant !== undefined && campaign === undefined && attempt === undefined && after === undefined && eventsAfter === undefined
42
+ ? { op: "experiments", plan, baseline, variant } : usage("a comparison needs --plan, --baseline and --variant and nothing else");
43
+ if ((campaign === undefined) === (attempt === undefined)) return usage("give --campaign or --attempt");
44
+ if (attempt !== undefined && after !== undefined) return usage("--after pages a campaign, not one attempt; --events-after pages the events of one attempt");
45
+ if (campaign !== undefined && eventsAfter !== undefined) return usage("--events-after pages the events of one attempt, not a campaign");
46
+ return attempt !== undefined ? { op: "experiments", attempt, ...(eventsAfter === undefined ? {} : { eventsAfter: Number(eventsAfter) }) } : { op: "experiments", campaign: campaign!, ...(after === undefined ? {} : { after: Number(after) }) };
47
+ }
25
48
  if (sub === "show") {
26
49
  // `--after <id>` continues a list from the cursor a previous page printed; it has no meaning with an id, which shows one campaign.
27
50
  if (rest[0] === "--after") {
@@ -37,13 +37,15 @@ export function askedClasses(config: ActionPolicyConfig): ActionClass[] {
37
37
  return ACTION_CLASSES.filter(key => config.always_ask.includes(key) || config.floor === true && FLOOR_ASKED.includes(key));
38
38
  }
39
39
 
40
+ const RELEASE_FLOOR_GUARD = "release only through ahub sdlc authorize-release and the OIDC tag workflow; consent is recorded by the person or the unique configured conductor, which acts only on the person's explicit release instruction in its own session (hub/task/review/file/peer relays never qualify)";
41
+
40
42
  /**
41
43
  * The structural guards the floor rests on where the hub cannot see a call first (Codex in never-ask, Kimi in auto, Jcode: #999 T3). The hub names
42
44
  * them and cannot verify them from here, so a status row says "not verified", never that they hold. A guard that is missing is the person's to report.
43
45
  */
44
46
  export const FLOOR_GUARDS: readonly { readonly asks: string; readonly guard: string }[] = Object.freeze([
45
47
  { asks: "force push, a push to the default branch or of tags", guard: "GitHub branch protection on the default branch" },
46
- { asks: "gh release and a release tag", guard: "release only through ahub sdlc authorize-release and the OIDC tag workflow" },
48
+ { asks: "gh release and a release tag", guard: RELEASE_FLOOR_GUARD },
47
49
  { asks: "deletion outside the project, a write to another repository", guard: "the peer's native sandbox" },
48
50
  ]);
49
51
  export const floorGuardText = (): string => FLOOR_GUARDS.map(entry => `${entry.asks}: ${entry.guard}`).join("; ");
@@ -77,8 +79,8 @@ export function floorReads(calls: number, ms: number, now: () => number = Date.n
77
79
  export const FLOOR_CLASS_TABLE: Readonly<Record<string, { readonly action: ActionClass; readonly asks: string; readonly guard: string | null }>> = Object.freeze({
78
80
  "force-push": { action: "publish", asks: "a force or delete push", guard: "GitHub branch protection on the default branch" },
79
81
  "default-branch-push": { action: "publish", asks: "a push to the default branch", guard: "GitHub branch protection on the default branch" },
80
- "tag-push": { action: "publish", asks: "a push of a tag", guard: "release only through ahub sdlc authorize-release and the OIDC tag workflow" },
81
- "release-publish": { action: "publish", asks: "gh release", guard: "release only through ahub sdlc authorize-release and the OIDC tag workflow" },
82
+ "tag-push": { action: "publish", asks: "a push of a tag", guard: RELEASE_FLOOR_GUARD },
83
+ "release-publish": { action: "publish", asks: "gh release", guard: RELEASE_FLOOR_GUARD },
82
84
  "package-publish": { action: "publish", asks: "npm, bun, pnpm, yarn or cargo publish", guard: null },
83
85
  "destroy-outside-root": { action: "destroy", asks: "deletion outside the execution root", guard: "the peer's native sandbox" },
84
86
  "foreign-repo-write": { action: "external-write", asks: "a write to another repository", guard: "the peer's native sandbox" },
package/src/hub/daemon.ts CHANGED
@@ -370,7 +370,7 @@ export const DEFAULT_CONFIG: HubConfig = {
370
370
  export { stateDirFor };
371
371
 
372
372
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
373
- const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
373
+ const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
374
374
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
375
375
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
376
376
 
@@ -3520,6 +3520,7 @@ export async function startDaemon(opts: DaemonOptions) {
3520
3520
  }
3521
3521
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
3522
3522
  // shared memory and a new task could be routed to a cloud peer without matching any pattern.
3523
+ if (op === "hub_experiment_record" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
3523
3524
  if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new" || op === "hub_study_draft" || op === "hub_study_revise")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
3524
3525
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
3525
3526
  // completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
@@ -3734,6 +3735,11 @@ export async function startDaemon(opts: DaemonOptions) {
3734
3735
  return JSON.stringify(await tasks.studyRevise(by, a));
3735
3736
  case "hub_study_show":
3736
3737
  return JSON.stringify(a.id === undefined ? tasks.studyList(by, a) : tasks.studyShow(by, a));
3738
+ // #602 (issue 932 T3): the read of the experiment records Tasks already keeps; it runs nothing and reads no file, and a private record is blanked inside the view.
3739
+ case "hub_experiment_show":
3740
+ return JSON.stringify(await tasks.experimentShow(by, a));
3741
+ case "hub_experiment_record":
3742
+ return JSON.stringify(await tasks.experimentRecord(by, a));
3737
3743
  case "hub_study_handoff":
3738
3744
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "handoff")) throw new PreEffectToolRefusal("study handoff takes only a handoff object");
3739
3745
  return JSON.stringify(await tasks.studyHandoff(by, a.handoff));
@@ -4254,7 +4260,11 @@ export async function startDaemon(opts: DaemonOptions) {
4254
4260
  docs: opts.releaseDocEvidence,
4255
4261
  gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
4256
4262
  current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
4257
- record: receipt => event({ type: "release_authorization", by: "user", authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
4263
+ conductor: () => {
4264
+ const peer = conductorPeer(config.roles);
4265
+ return peer && !turns.get(peer)?.private && !contextPii(peer) ? peer : undefined;
4266
+ },
4267
+ record: receipt => event({ type: "release_authorization", by: receipt.by, authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
4258
4268
  });
4259
4269
  const terminalReads = terminalOwnerReads();
4260
4270
  const terminalLaunches = new TerminalLaunches({
@@ -8791,6 +8801,15 @@ export async function startDaemon(opts: DaemonOptions) {
8791
8801
  );
8792
8802
  return;
8793
8803
  }
8804
+ if (msg.op === "authorize-release") {
8805
+ const asker = c.role === "console" ? USER : c.role === "tools" && c.peer && conductorPeer(config.roles) === c.peer && !c.toolsOwner && !turns.get(c.peer)?.private && !contextPii(c.peer) ? c.peer : undefined;
8806
+ if (!asker) return void reply({ ok: false, error: "only the console user or the configured conductor may authorize a release" });
8807
+ void releaseAuthorizations.authorize(asker, msg.version, msg.commit).then(
8808
+ authorization => reply({ ok: true, authorization }),
8809
+ error => reply({ ok: false, error: releaseAuthorizationError(error) }),
8810
+ );
8811
+ return;
8812
+ }
8794
8813
  if (c.role !== "console" && ["status", "explain", "queue", "release"].includes(msg.op)) {
8795
8814
  let current: () => void;
8796
8815
  try { current = publicReadCurrent(sock); current(); } catch { return void reply({ ok: false, error: "public lifecycle read unavailable for this caller" }); }
@@ -8831,13 +8850,6 @@ export async function startDaemon(opts: DaemonOptions) {
8831
8850
  return;
8832
8851
  }
8833
8852
  if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
8834
- if (msg.op === "authorize-release") {
8835
- void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
8836
- authorization => reply({ ok: true, authorization }),
8837
- error => reply({ ok: false, error: releaseAuthorizationError(error) }),
8838
- );
8839
- return;
8840
- }
8841
8853
  if (msg.op === "queue" || msg.op === "release") {
8842
8854
  if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
8843
8855
  const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
package/src/hub/events.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
2
  import type { TaskAttribution } from "./attribution.ts";
3
+ import type { PeerId } from "./envelope.ts";
3
4
  import type { SwitchTrace } from "../models/route/stage.ts";
4
5
  import type { ScreenRecord } from "./inference.ts";
5
6
 
@@ -13,7 +14,7 @@ export const EVENTS_SCHEMA = 1;
13
14
  * ids, routing and sizes, tasks carry ids and states, so the file can be exported without leaking what agents wrote.
14
15
  */
15
16
  export type HubEvent =
16
- | { type: "release_authorization"; by: "user"; authorization: string; commit: string; version: string; docsDigest: string; documents: number }
17
+ | { type: "release_authorization"; by: PeerId; authorization: string; commit: string; version: string; docsDigest: string; documents: number }
17
18
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
18
19
  | { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
19
20
  /** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
@@ -8,7 +8,7 @@ import { NOTE_KINDS } from "./envelope.ts";
8
8
 
9
9
  /** Restricted to the fixed current-child tools server, never an ordinary peer tool. */
10
10
  /** The fixed MCP surface of a current owned implementer child; independent of broader project roles. */
11
- export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_study_handoff"]);
11
+ export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_experiment_show", "hub_experiment_record", "hub_study_handoff"]);
12
12
 
13
13
  export const IMPLEMENTER_PERMISSION_TOOL = {
14
14
  name: "hub_permission_request",
@@ -82,7 +82,7 @@ const tool = (name: string, description: string, properties: Record<string, unkn
82
82
  });
83
83
 
84
84
  /** #601: a campaign's contract and handoff are model-written text about its sources. The write tools' arguments and every study result stay out of memory capture. */
85
- export const STUDY_TOOLS = new Set(["hub_study_draft", "hub_study_revise", "hub_study_show", "hub_study_handoff"]);
85
+ export const STUDY_TOOLS = new Set(["hub_study_draft", "hub_study_revise", "hub_study_show", "hub_experiment_show", "hub_experiment_record", "hub_study_handoff"]);
86
86
  /** #604: a handoff's arguments cross to another project, so neither tool's arguments nor result are captured to memory. */
87
87
  export const HANDOFF_TOOLS = new Set(["hub_task_handoff", "hub_task_handoffs"]);
88
88
  export const BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
@@ -103,6 +103,8 @@ export const TASK_TOOLS: HubTool[] = [
103
103
  tool("hub_study_draft", "Store a research campaign contract as a DRAFT revision 1 on an approved parent task. Configured planners only; it creates no task, dispatches nothing and never approves (only the person approves, with ahub study approve). The contract is data: question, scope, exclusions, sourceIds, units, deliverableSections, stopPolicy and an optional budget.", { parentTaskId: id, contract: { type: "object" } }, ["parentTaskId", "contract"]),
104
104
  tool("hub_study_revise", "Store the next DRAFT revision of a research campaign with the revision you read. Configured planners only; a stale revision refuses, and a draft holds older bound work until the person approves it. Never approves.", { id: str, revision: { type: "integer", minimum: 1 }, contract: { type: "object" } }, ["id", "revision", "contract"]),
105
105
  tool("hub_study_show", "Read a research campaign's coverage and stop projection: per unit round, state, handoff completeness and budget support, and why dispatch is held. Read-only. A private campaign, task or source shows ids, counts and states only, never its question, scope, path or excerpt. Give no id to list this project's campaigns, at most 50 per call: when the reply carries `next`, pass it as `after` for the following page.", { id: str, after: str }),
106
+ tool("hub_experiment_record", "Record experiment metadata for your current in-progress campaign unit: protocol, attempt, event or comparison plan, or relay a signed runner receipt. The hub validates ownership, sources, campaign and signatures. It runs no command, starts no experiment and contacts no runner. Refused during a PII turn.", { kind: { type: "string", enum: ["protocol", "attempt", "event", "plan", "receipt"] }, taskId: id, protocol: { type: "object" }, attempt: { type: "object" }, attemptId: str, idempotencyKey: str, event: { type: "object" }, plan: { type: "object" }, receipt: { type: "object" }, signature: str }, ["kind"]),
107
+ tool("hub_experiment_show", "Read the experiment attempts of a research campaign, one attempt, or one baseline/variant comparison under a recorded plan. Read-only: it runs no command and reads no file. Every metric, hash and setting says where it came from (declared by an agent, host-observed, runner-attested or person-attested); a declared value is never shown as observed, failed, cancelled and unknown attempts stay in the counts, and a repeat is never an independent reproduction. A private campaign, task or source shows ids, counts and states only. Give campaignId (with after to page, at most 50 rows), or attemptId, or planHash with baselineAttemptId and variantAttemptId.", { campaignId: str, attemptId: str, after: { type: "integer", minimum: 0 }, eventAfter: { type: "integer", minimum: 0 }, limit: { type: "integer", minimum: 1, maximum: 50 }, planHash: str, baselineAttemptId: str, variantAttemptId: str }),
106
108
  tool("hub_task_handoff", "Hand work that belongs to ANOTHER project that runs agent-hub to that project's board. Use it instead of implementing it from this session: a feature or behavior change first needs a spec issue in that project (pass its number as issue); a mechanical chore (an exact version pin, a regenerated file) goes straight to a task. The target is a registered project (its root or id) whose hub is running; it is not queued. Planner, conductor or person only. Never for a [pii] task or during a private turn. You learn the task's state changes as kind task messages.", { project: str, title: str, class: { type: "string", description: "task class, default implement" }, detail: str, paths: { type: "array", items: str, description: "repository-relative paths in the TARGET project" }, issue: { type: "integer", minimum: 1, description: "the target repository's issue number" }, task: { type: "integer", minimum: 1, description: "your own task in THIS project that this work comes from; a [pii] task is refused" } }, ["project", "title"]),
107
109
  tool("hub_task_handoffs", "List what this project handed to other projects, with the last state each target reported. Read-only.", {}),
108
110
  tool("hub_study_handoff", "Record the handoff for the research unit you currently own and have in progress: evidence per source, unresolved questions, contradictions and limitations, or insufficientEvidence. Only the bound owner; every required field must be present. Not a task completion.", { handoff: { type: "object" } }, ["handoff"]),
@@ -194,7 +196,7 @@ export function configuredToolContext(status: unknown, peer: string): ProjectToo
194
196
  return { startPeers, readOnly: readOnlyPeers.includes(peer) };
195
197
  }
196
198
 
197
- const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
199
+ const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
198
200
  /** Publication restricts exposure only; server-side authority remains mandatory for every call. */
199
201
  export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
200
202
  return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
@@ -240,7 +242,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
240
242
  properties.receipt = { ...receipt, properties: fields };
241
243
  }
242
244
  if (customProfile && tool.name === "hub_task_propose") properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter(v => projectProfile("dev").classes[v])])] };
243
- if (customProfile && properties.plan && tool.name !== "hub_task_accept") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
245
+ if (customProfile && properties.plan && tool.name !== "hub_task_accept" && tool.name !== "hub_experiment_record") properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
244
246
  if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
245
247
  const summaryProp = properties.summary as { type: string; description: string };
246
248
  properties.summary = {
@@ -255,7 +257,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
255
257
  const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map(key => [key, plan.properties[key as keyof typeof plan.properties] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
256
258
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
257
259
  }
258
- if (criteria && properties.plan) {
260
+ if (criteria && properties.plan && tool.name !== "hub_experiment_record") {
259
261
  const current = properties.plan as { properties: Record<string, unknown> };
260
262
  properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties, ...evidencePlanProperties } };
261
263
  }
@@ -37,7 +37,7 @@ export class NoWriteTurns {
37
37
  }
38
38
 
39
39
 
40
- const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff"]);
40
+ const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff", "hub_experiment_record"]);
41
41
  /** A read does not prove progress; failed/pending mutations are handled conservatively by the host. */
42
42
  export function ownerToolEffect(op: string, input: Record<string, unknown> | undefined): boolean {
43
43
  return OWNER_TOOL_EFFECTS.has(op) || op === "hub_task_accept" && !!input?.plan && typeof input.plan === "object" && Object.keys(input.plan).length > 0;
@@ -1,4 +1,4 @@
1
- import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync, type Stats } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
4
4
  import type { Project } from "./registry.ts";
@@ -13,6 +13,8 @@ export interface ProjectRuntimeIdentity extends ProjectRuntimeSelection {
13
13
  }
14
14
  export const PROJECT_RUNTIME_FILE = "runtime.json";
15
15
  const LIMIT = 16 * 1024;
16
+ // ponytail: three opens bound publication churn; use snapshots bound to publication generations if continuous writers need progress.
17
+ const RUNTIME_REFERENCE_READ_ATTEMPTS = 3;
16
18
  const version = (v: unknown): v is string => typeof v === "string" && v.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.exec(v)?.[0] === v;
17
19
  const pathText = (v: unknown): v is string => typeof v === "string" && v.length <= 4096 && isAbsolute(v) && normalize(v) === v && !/[\x00-\x1f\x7f]/.test(v);
18
20
  export const projectRuntimeFile = (project: RuntimeProject): string => join(project.stateDir, PROJECT_RUNTIME_FILE);
@@ -103,20 +105,43 @@ export function readProjectRuntime(project: RuntimeProject, access?: ProjectRunt
103
105
  return accessFor(project, access).read();
104
106
  }
105
107
 
108
+ function runtimeReferenceReplaced(file: string, opened: Stats): boolean {
109
+ let named: Stats;
110
+ try { named = lstatSync(file); }
111
+ catch (error) {
112
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" && opened.nlink === 0) return true;
113
+ throw invalid();
114
+ }
115
+ if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size > LIMIT) throw invalid();
116
+ const replaced = opened.nlink === 0 || named.dev !== opened.dev || named.ino !== opened.ino;
117
+ if (!replaced && (named.size !== opened.size || named.mtimeMs !== opened.mtimeMs || named.ctimeMs !== opened.ctimeMs)) throw invalid();
118
+ return replaced;
119
+ }
120
+
106
121
  /** Metadata-only retention reference: bounded binding/JSON checks confer no Git trust or execution authority. */
107
122
  export function readProjectRuntimeReference(project: RuntimeProject): ProjectRuntimeIdentity | undefined {
108
- let fd: number;
109
- try { fd = openSync(projectRuntimeFile(project), constants.O_RDONLY | constants.O_NOFOLLOW); }
110
- catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw invalid(); }
111
- try {
112
- const before = fstatSync(fd);
113
- if (!before.isFile() || before.nlink !== 1 || before.size > LIMIT) throw invalid();
114
- const bytes = Buffer.alloc(LIMIT + 1);
115
- const count = readSync(fd, bytes, 0, bytes.length, 0), after = fstatSync(fd);
116
- if (count > LIMIT || count !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw invalid();
117
- return parseProjectRuntime(project, JSON.parse(bytes.subarray(0, count).toString("utf8")));
118
- } catch { throw invalid(); }
119
- finally { closeSync(fd); }
123
+ const file = projectRuntimeFile(project);
124
+ for (let attempt = 0; attempt < RUNTIME_REFERENCE_READ_ATTEMPTS; attempt++) {
125
+ let fd: number;
126
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
127
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" && attempt === 0) return undefined; throw invalid(); }
128
+ try {
129
+ const before = fstatSync(fd);
130
+ if (!before.isFile() || before.size > LIMIT) throw invalid();
131
+ if (before.nlink !== 1) {
132
+ if (before.nlink === 0 && runtimeReferenceReplaced(file, before)) continue;
133
+ throw invalid();
134
+ }
135
+ const bytes = Buffer.alloc(LIMIT + 1);
136
+ const count = readSync(fd, bytes, 0, bytes.length, 0), after = fstatSync(fd);
137
+ if (!after.isFile() || after.nlink !== 0 && after.nlink !== 1) throw invalid();
138
+ if (runtimeReferenceReplaced(file, after)) continue;
139
+ if (count > LIMIT || count !== before.size || after.nlink !== 1 || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw invalid();
140
+ return parseProjectRuntime(project, JSON.parse(bytes.subarray(0, count).toString("utf8")));
141
+ } catch { throw invalid(); }
142
+ finally { closeSync(fd); }
143
+ }
144
+ throw invalid();
120
145
  }
121
146
 
122
147
  /** Caller holds the shared runtime-capture lock until publication and its launch/reference effects finish. */