@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.
- package/CHANGELOG.md +7 -0
- package/README.md +4 -3
- package/docs/agent-notes/daemon.md +1 -1
- package/docs/agent-notes/tasks.md +2 -2
- package/docs/dogfood.md +3 -1
- package/docs/operations.md +31 -5
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +5 -1
- package/docs/sdlc.md +3 -3
- package/docs/security.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +1 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +12 -1
- package/docs/verification/2026-11-01-0.27.0.md +49 -0
- package/docs/verified.json +12 -12
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +9 -7
- package/src/cli/help.ts +2 -1
- package/src/cli/identity.ts +5 -5
- package/src/cli/main.ts +6 -1
- package/src/cli/study-output.ts +56 -0
- package/src/cli/study.ts +26 -3
- package/src/hub/action-policy.ts +5 -3
- package/src/hub/daemon.ts +21 -9
- package/src/hub/events.ts +2 -1
- package/src/hub/hub-tools.ts +7 -5
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/project-runtime-state.ts +38 -13
- package/src/hub/sdlc-release-authorization.ts +11 -5
- package/src/hub/session-identity.ts +1 -1
- package/templates/presets/confirmation-matrix.json +5 -5
- package/templates/release-guard.block.md +2 -1
|
@@ -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.
|
|
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
|
|
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"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -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:
|
|
142
|
-
if (command === "sdlc" && args[0]
|
|
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) }
|
package/src/cli/study-output.ts
CHANGED
|
@@ -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") {
|
package/src/hub/action-policy.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
81
|
-
"release-publish": { action: "publish", asks: "gh release", guard:
|
|
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
|
-
|
|
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:
|
|
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. */
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -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
|
}
|
package/src/hub/no-writes.ts
CHANGED
|
@@ -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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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. */
|