@staix/agent-hub 0.26.0 → 0.28.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 +30 -0
- package/README.md +13 -4
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +8 -4
- package/docs/agent-notes/tasks.md +7 -2
- package/docs/agent-notes/tests.md +6 -5
- package/docs/design/adapters.md +76 -0
- package/docs/design/agent-hub-architecture.md +67 -0
- package/docs/design/agent-hub.md +52 -0
- package/docs/design/bus.md +48 -0
- package/docs/design/task-board.md +86 -0
- package/docs/dogfood.md +19 -2
- package/docs/operations.md +337 -15
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +15 -3
- package/docs/sdlc.md +4 -4
- package/docs/security.md +9 -4
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +3 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +12 -1
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
- package/docs/verification/2026-11-01-0.27.0.md +49 -0
- package/docs/verification/2026-11-02-0.28.0.md +54 -0
- package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
- package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
- package/docs/verification/2026-11-02-control-write-after.json +139 -0
- package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
- package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
- package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
- package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
- package/docs/verified.json +13 -13
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +344 -116
- package/src/adapters/claude-channel.ts +12 -2
- package/src/adapters/compat.ts +1 -1
- package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
- package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
- package/src/adapters/jcode.ts +37 -4
- package/src/adapters/qwen-dual.ts +3 -2
- package/src/cli/agents.ts +4 -2
- package/src/cli/bench.ts +4 -2
- package/src/cli/board-read.ts +25 -0
- package/src/cli/claude-homes.ts +2 -2
- package/src/cli/console-state.ts +8 -3
- package/src/cli/help.ts +8 -6
- package/src/cli/identity.ts +14 -6
- package/src/cli/init-prompt.ts +179 -0
- package/src/cli/init-settings.ts +229 -0
- package/src/cli/init.ts +102 -9
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +112 -36
- package/src/cli/output.ts +14 -0
- package/src/cli/setup-homes.ts +18 -3
- package/src/cli/specs.ts +150 -0
- package/src/cli/study-output.ts +56 -0
- package/src/cli/study.ts +26 -3
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/action-policy.ts +5 -3
- package/src/hub/board.ts +56 -3
- package/src/hub/bus.ts +54 -7
- package/src/hub/child-process.ts +6 -4
- package/src/hub/config-apply.ts +4 -1
- package/src/hub/config-edit.ts +1 -1
- package/src/hub/config-tier.ts +1 -0
- package/src/hub/control-client.ts +28 -4
- package/src/hub/cost-governance.ts +22 -6
- package/src/hub/crash.ts +20 -0
- package/src/hub/daemon.ts +324 -64
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/events.ts +2 -1
- package/src/hub/hub-tools.ts +15 -9
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/peers.ts +1 -1
- package/src/hub/project-runtime-state.ts +38 -13
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +7 -3
- package/src/hub/sdlc-release-authorization.ts +11 -5
- package/src/hub/session-identity.ts +1 -1
- package/src/hub/settings.ts +2 -0
- package/src/hub/specs-config.ts +43 -0
- package/src/hub/specs-task.ts +108 -0
- package/src/hub/specs.ts +1008 -0
- package/src/hub/task-list-page.ts +75 -0
- package/src/hub/task-sweep.ts +10 -1
- package/src/hub/tasks.ts +448 -40
- package/src/hub/terminal-open.ts +10 -5
- package/src/hub/tool-request.ts +131 -0
- package/src/ui/office-model.ts +17 -1
- package/src/ui/office-space.ts +1 -1
- package/templates/config.json +1 -0
- package/templates/presets/confirmation-matrix.json +5 -5
- package/templates/profiles/dev.json +1 -0
- package/templates/profiles/general.json +1 -0
- package/templates/profiles/research.json +1 -0
- package/templates/profiles/writing.json +1 -0
- package/templates/release-guard.block.md +2 -1
- package/templates/specs.block.md +5 -0
|
@@ -3,7 +3,7 @@ import { KINDS, sanitize, type Kind } from "./envelope.ts";
|
|
|
3
3
|
export const DELIVERY_RETRY_WARNING = sanitize("Hub redelivery warning: An earlier attempt of this delivery is uncertain; check for its effects before acting.");
|
|
4
4
|
export interface HeldDeliveryHints {
|
|
5
5
|
count: number;
|
|
6
|
-
rows: { id: string; senders: string[]; kinds: Kind[]; createdAt: number }[];
|
|
6
|
+
rows: { id: string; revision?: number; senders: string[]; kinds: Kind[]; createdAt: number }[];
|
|
7
7
|
truncated: boolean;
|
|
8
8
|
}
|
|
9
9
|
export function heldDeliveryHints(rows: readonly HeldDeliveryHints["rows"][number][], count = rows.length): HeldDeliveryHints {
|
|
@@ -23,8 +23,11 @@ export function renderHeldHint(value: unknown, now = Date.now(), recipientRetry
|
|
|
23
23
|
if (!Number.isSafeInteger(held.count) || held.count < 1 || !Array.isArray(held.rows) || held.rows.length > 32 || held.rows.length > held.count || typeof held.truncated !== "boolean" || Buffer.byteLength(JSON.stringify(held), "utf8") > 4096) return "";
|
|
24
24
|
if (held.rows.some(row => !row || typeof row !== "object" || typeof row.id !== "string" || !row.id.length || row.id.length > 256 || !Number.isFinite(row.createdAt) || !Array.isArray(row.senders) || !Array.isArray(row.kinds) || row.senders.some(sender => typeof sender !== "string" || sender.length > 256) || row.kinds.some(kind => !kinds.has(kind)))) return "";
|
|
25
25
|
} catch { return ""; }
|
|
26
|
-
const rows = held.rows.map(row => `${sanitize(row.id)} from ${row.senders.map(sanitize).join(",")} kind ${row.kinds.join(",")} age ${Math.max(0, Math.floor((now - row.createdAt) / 1000))}s`);
|
|
27
|
-
const
|
|
26
|
+
const rows = held.rows.map(row => `${sanitize(row.id)}${Number.isSafeInteger(row.revision) && row.revision! >= 0 ? ` revision ${row.revision}` : ""} from ${row.senders.map(sanitize).join(",")} kind ${row.kinds.join(",")} age ${Math.max(0, Math.floor((now - row.createdAt) / 1000))}s`);
|
|
27
|
+
const resolvable = held.rows.some(row => Number.isSafeInteger(row.revision) && row.revision! >= 0);
|
|
28
|
+
const action = resolvable
|
|
29
|
+
? `Use hub_delivery_resolve {delivery_id, revision, action, reason} to confirm completion or discard your own held row. ${recipientRetry ? "Use hub_delivery_retry only to request its one uncertain redelivery; check for earlier effects." : "Retry remains person-only for this delivery mode."}`
|
|
30
|
+
: `Ask the person to resolve these held deliveries because this hint has no usable revision. ${recipientRetry ? "A retry may be requested with hub_delivery_retry {delivery_id}; check for earlier effects." : "Retry remains person-only for this delivery mode."}`;
|
|
28
31
|
const out = `Held hub deliveries: ${held.count}${held.truncated ? " (metadata truncated)" : ""}\n${rows.join("\n")}\n${action}`;
|
|
29
32
|
return Buffer.byteLength(out, "utf8") <= 4096 ? out : `Held hub deliveries: ${held.count} (metadata truncated; ask the person to inspect the queue).`;
|
|
30
33
|
}
|
|
@@ -895,8 +895,8 @@ export class DeliveryJournal {
|
|
|
895
895
|
heldDeliveryHints(peer: string) {
|
|
896
896
|
this.ensureOpen();
|
|
897
897
|
const count = Number((this.db.query("SELECT COUNT(*) AS n FROM deliveries WHERE project_id = ? AND peer = ? AND state = 'needs_review'").get(this.projectId, peer) as { n: number }).n);
|
|
898
|
-
const rows = this.db.query("SELECT d.id, d.created_at, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.from')) FROM json_each(d.originals) env) AS senders, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.kind')) FROM json_each(d.originals) env) AS kinds FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state = 'needs_review' ORDER BY d.created_at, d.id LIMIT 33").all(this.projectId, peer) as { id: string; created_at: number; senders: string; kinds: string }[];
|
|
899
|
-
return heldDeliveryHints(rows.map(row => ({ id: row.id, createdAt: row.created_at, senders: JSON.parse(row.senders) as string[], kinds: JSON.parse(row.kinds) as Kind[] })), count);
|
|
898
|
+
const rows = this.db.query("SELECT d.id, d.revision, d.created_at, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.from')) FROM json_each(d.originals) env) AS senders, (SELECT json_group_array(DISTINCT json_extract(env.value, '$.kind')) FROM json_each(d.originals) env) AS kinds FROM deliveries d WHERE d.project_id = ? AND d.peer = ? AND d.state = 'needs_review' ORDER BY d.created_at, d.id LIMIT 33").all(this.projectId, peer) as { id: string; revision: number; created_at: number; senders: string; kinds: string }[];
|
|
899
|
+
return heldDeliveryHints(rows.map(row => ({ id: row.id, revision: row.revision, createdAt: row.created_at, senders: JSON.parse(row.senders) as string[], kinds: JSON.parse(row.kinds) as Kind[] })), count);
|
|
900
900
|
}
|
|
901
901
|
/** Explicit resolutions, not automatic safe failures, consume the immutable originals' retry lineage. */
|
|
902
902
|
retryEvidence(peer: string, originals: readonly Envelope[]): "unused" | "used" | "unknown" {
|
|
@@ -993,7 +993,12 @@ export class DeliveryJournal {
|
|
|
993
993
|
if (prior) return this.get(id)!;
|
|
994
994
|
const current = this.get(id);
|
|
995
995
|
if (!current) throw new Error(`unknown delivery ${id}`);
|
|
996
|
-
if (actor.startsWith("peer:")
|
|
996
|
+
if (actor.startsWith("peer:")) {
|
|
997
|
+
const peer = actor.slice(5);
|
|
998
|
+
const recipientRetry = action === "retry" && current.peer === peer && current.state === "needs_review" && this.retryEvidence(current.peer, current.originals) === "unused";
|
|
999
|
+
const ownerDisposition = (action === "completed" || action === "discard") && current.peer === peer && current.state === "needs_review";
|
|
1000
|
+
if (!recipientRetry && !ownerDisposition) throw new Error("recipient delivery resolution unavailable; inspect the held delivery or ask the person");
|
|
1001
|
+
}
|
|
997
1002
|
if (current.revision !== revision)
|
|
998
1003
|
throw new Error(`stale delivery revision for ${id}`);
|
|
999
1004
|
if (
|
|
@@ -1037,6 +1042,12 @@ export class DeliveryJournal {
|
|
|
1037
1042
|
return result;
|
|
1038
1043
|
})();
|
|
1039
1044
|
}
|
|
1045
|
+
/** #1096: the ids of every delivery an operator action (completed, retry, discard) already settled. One query, so a list of any length costs one read. */
|
|
1046
|
+
resolvedIds(): Set<string> {
|
|
1047
|
+
this.ensureOpen();
|
|
1048
|
+
const rows = this.db.query("SELECT DISTINCT delivery_id FROM resolution_history WHERE project_id=?").all(this.projectId) as { delivery_id: string }[];
|
|
1049
|
+
return new Set(rows.map(row => row.delivery_id));
|
|
1050
|
+
}
|
|
1040
1051
|
resolution(id: string): { action: JournalResolution; reason: string; observedRevision: number; actor: string } | undefined {
|
|
1041
1052
|
const row = this.db.query("SELECT action,reason,observed_revision,actor FROM resolution_history WHERE project_id=? AND delivery_id=? ORDER BY id DESC LIMIT 1").get(this.projectId, id) as { action: JournalResolution; reason: string; observed_revision: number; actor: string } | null;
|
|
1042
1053
|
return row ? { action: row.action, reason: row.reason, observedRevision: row.observed_revision, actor: row.actor } : undefined;
|
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", "hub_specs", "hub_delivery_resolve"]);
|
|
12
12
|
|
|
13
13
|
export const IMPLEMENTER_PERMISSION_TOOL = {
|
|
14
14
|
name: "hub_permission_request",
|
|
@@ -21,6 +21,7 @@ export const ACTIONABLE_MESSAGE_INSTRUCTION = "Do not use [FYI] for PR ready, do
|
|
|
21
21
|
|
|
22
22
|
const str = { type: "string" } as const;
|
|
23
23
|
const id = { type: "integer", description: "task id" } as const;
|
|
24
|
+
const toolWriteRequestId = { type: "string", minLength: 1, maxLength: 256, pattern: "^v1/[A-Za-z0-9._-]{1,128}/[1-9][0-9]{0,15}/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", description: "control-transport-only request_id returned by an outcome-unknown write; inspect first and retry with the same key and identical arguments only if absent. Direct in-process tool calls cannot use request_id" } as const;
|
|
24
25
|
const refs = {
|
|
25
26
|
type: "object",
|
|
26
27
|
description: "where the work lives",
|
|
@@ -28,6 +29,7 @@ const refs = {
|
|
|
28
29
|
additionalProperties: false,
|
|
29
30
|
} as const;
|
|
30
31
|
const list = (description: string) => ({ type: "array", items: str, description }) as const;
|
|
32
|
+
const specAck = { type: "array", maxItems: 20, items: { type: "object", properties: { id: { type: "string", minLength: 1, maxLength: 160 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, required: ["id", "reason"], additionalProperties: false } } as const;
|
|
31
33
|
/** #509a: files a task delivered. The hub hashes them itself; there is no hash field, and one is refused. */
|
|
32
34
|
const deliverablesSchema = {
|
|
33
35
|
type: "array", minItems: 1, maxItems: 16,
|
|
@@ -78,11 +80,11 @@ export interface HubTool {
|
|
|
78
80
|
const tool = (name: string, description: string, properties: Record<string, unknown>, required: string[] = []): HubTool => ({
|
|
79
81
|
name,
|
|
80
82
|
description,
|
|
81
|
-
inputSchema: { type: "object", properties, required, additionalProperties: false },
|
|
83
|
+
inputSchema: { type: "object", properties: name === "hub_review" || name === "hub_task_propose" ? { ...properties, request_id: toolWriteRequestId } : properties, required, additionalProperties: false },
|
|
82
84
|
});
|
|
83
85
|
|
|
84
86
|
/** #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"]);
|
|
87
|
+
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
88
|
/** #604: a handoff's arguments cross to another project, so neither tool's arguments nor result are captured to memory. */
|
|
87
89
|
export const HANDOFF_TOOLS = new Set(["hub_task_handoff", "hub_task_handoffs"]);
|
|
88
90
|
export const BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
|
|
@@ -95,6 +97,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
95
97
|
tool("hub_check_result", "Read your current task-bound isolated check receipt. Stale ownership/generation refuses; output is policy screened.", { run: str }, ["run"]),
|
|
96
98
|
tool("hub_check_cancel", "Cancel your task-bound isolated check; only its owner or the person may cancel.", { run: str }, ["run"]),
|
|
97
99
|
tool("hub_delivery_retry", "Request one redelivery of your own needs_review delivery. An earlier attempt may have effects: inspect those effects before acting. Refuses another peer's row or any lineage already explicitly retried; a repeated uncertain row needs the person. Never completes/discards a delivery or changes task state.", { delivery_id: { type: "string", minLength: 1, maxLength: 256 } }, ["delivery_id"]),
|
|
100
|
+
tool("hub_delivery_resolve", "Confirm completion or discard your own needs_review delivery using the revision shown by hub_inbox. Requires a reason. Never replays the delivery or changes task state; another peer's row and stale revisions are refused.", { delivery_id: { type: "string", minLength: 1, maxLength: 256 }, revision: { type: "integer", minimum: 0 }, action: { type: "string", enum: ["completed", "discard"] }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["delivery_id", "revision", "action", "reason"]),
|
|
98
101
|
tool("hub_backlog_list", "Read public backlog cards with planning revisions. Configured planners only; PII remains a stub.", {}),
|
|
99
102
|
tool("hub_backlog_plan", "Plan an unpinned backlog item outside frozen releases with the revision you read and a bounded reason. Omit fields or give {} for a note-only revision. fields.sdlc accepts only repo/issue identity on unstarted backlog work; relinking clears old spec acceptance. A person's rank is retained. Actual dates cannot be set.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", maxLength: 1000 }, fields: { type: "object", properties: { sdlc: { type: "object", minProperties: 1, properties: { repo: { type: ["string", "null"], maxLength: 400 }, issue: { type: ["integer", "null"], minimum: 1 } }, additionalProperties: false }, rank: { type: ["integer", "null"], minimum: 0 }, priority: { type: ["integer", "null"], minimum: 0, maximum: 3 }, release: { type: ["integer", "null"], minimum: 1 }, plannedStart: { type: ["integer", "null"], minimum: 1 }, due: { type: ["integer", "null"], minimum: 1 }, deps: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason"]),
|
|
100
103
|
tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
|
|
@@ -103,6 +106,8 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
103
106
|
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
107
|
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
108
|
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 }),
|
|
109
|
+
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"]),
|
|
110
|
+
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
111
|
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
112
|
tool("hub_task_handoffs", "List what this project handed to other projects, with the last state each target reported. Read-only.", {}),
|
|
108
113
|
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"]),
|
|
@@ -113,13 +118,14 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
113
118
|
tool("hub_release_cut", "Freeze an active release's approved scope after host resource admission and fresh peer quota checks. Unknown evidence defers; no publish, tag, upgrade or authorization.", { id, revision: { type: "integer", minimum: 0 } }, ["id", "revision"]),
|
|
114
119
|
tool("hub_release_list", "Read public release scope and planned/actual dates for phased planning. Configured planners only.", {}),
|
|
115
120
|
|
|
116
|
-
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, set refs.repo for work in another repository, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
|
|
121
|
+
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, set refs.repo for work in another repository, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, role: { type: "string", description: "catalog role id for this task class; guidance only, no routing or authority change" }, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it. With after, the owner is reserved: offered the task first once it is ready" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, backlog: { type: "boolean", description: "stage unoffered work; owner is only a reservation, even when naming yourself" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" }, needs_network: { type: "boolean", description: "the work needs the network (a package registry, git fetch or push, an API); it is never offered to a peer whose shell has none" }, needs_worktree: { type: "boolean", description: "the work needs its own git worktree on a task branch (the repository rule); it is never offered to a sandboxed peer, which cannot create one" }, goal: { type: "string", description: "id of this project's long-term task (ahub goal list) the work is part of" } }, ["title"]),
|
|
117
122
|
tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it. Set refs.repo for work in another repository; acceptance may correct only that repository metadata.", { id, plan: acceptPlan, refs: { ...refs, properties: { repo: refs.properties.repo } } }, ["id"]),
|
|
118
123
|
tool("hub_task_cancel", "Cancel proposed/backlog work you proposed as its configured planner, including work an owner accepted and then declined while it has no execution, branch, commit or PR evidence; work with any such evidence is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
|
|
119
124
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer. A plain decline is a lasting refusal: you are never offered this task again. Pass capacity: true when you only have no room right now: you are skipped for this move only, and a later reroute or planner pull may offer it to you again.", { id, reason: str, capacity: { type: "boolean" } }, ["id"]),
|
|
120
|
-
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema }, ["id", "summary"]),
|
|
125
|
+
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. If a covering spec is unchanged, update it or provide spec_ack with a public reason for each unchanged spec. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema, spec_ack: specAck }, ["id", "summary"]),
|
|
121
126
|
tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { deliverables: deliverablesSchema, stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
|
|
122
|
-
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
127
|
+
tool("hub_task_list", "The task board, newest updated first, 50 compact rows (id, state, owner, reviewer, title) per call. The reply is {tasks, total, next?}: pass next as cursor for the following page. Use ids for full rows of specific tasks, or detail: true for full rows of a page. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" }, limit: { type: "integer", minimum: 1, maximum: 200, description: "rows per page, default 50" }, cursor: { type: "string", description: "the next value from the previous reply, unchanged" }, ids: { type: "array", minItems: 1, maxItems: 200, items: { type: "integer", minimum: 1 }, description: "full rows for exactly these task numbers; no paging" }, detail: { type: "boolean", description: "full rows instead of compact rows" } }),
|
|
128
|
+
tool("hub_specs", "Read the project's design-spec checks or find specs covering repository-relative paths. Read-only; at most 20 result items are returned. Does not reveal private task data.", { action: { type: "string", enum: ["check", "covers"] }, paths: { type: "array", maxItems: 32, items: { type: "string", minLength: 1, maxLength: 2000 } } }, ["action"]),
|
|
123
129
|
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. The verdict notifies the task owner; no duplicate hub_send chat is needed. Consecutive changes_requested verdicts (three by default, `tasks.escalate_after`) move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, findings: reviewFindings, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet; when the review request lists acceptance criteria, start an item with its id (\"AC1: what is missing\") so the verdict names it" }, coverage: { type: "object", description: "the review brief's reviewable paths you read (reviewed) and any you did not (skipped, each with a reason); in enforce an approval must account for every one", properties: { reviewed: { type: "array", items: str }, skipped: { type: "array", items: { type: "object", properties: { path: str, reason: str }, required: ["path", "reason"] } } } } }, ["id", "verdict"]),
|
|
124
130
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
125
131
|
tool("hub_source_read", "Read a registered source snapshot. Unicode character range, at most 32 KiB of sanitized UTF-8 text with next character offset. Private sources return only id, kind and private unless called by the on-prem worker inside a PII task. Excluded from capture.", { id: { type: "string", pattern: "^src:[0-9a-f]{16}$" }, range: { type: "object", properties: { offset: { type: "integer", minimum: 0 }, length: { type: "integer", minimum: 1 } }, required: ["offset", "length"], additionalProperties: false } }, ["id"]),
|
|
@@ -194,7 +200,7 @@ export function configuredToolContext(status: unknown, peer: string): ProjectToo
|
|
|
194
200
|
return { startPeers, readOnly: readOnlyPeers.includes(peer) };
|
|
195
201
|
}
|
|
196
202
|
|
|
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"]);
|
|
203
|
+
const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_specs", "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
204
|
/** Publication restricts exposure only; server-side authority remains mandatory for every call. */
|
|
199
205
|
export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
|
|
200
206
|
return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
|
|
@@ -240,7 +246,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
|
|
|
240
246
|
properties.receipt = { ...receipt, properties: fields };
|
|
241
247
|
}
|
|
242
248
|
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")])) } };
|
|
249
|
+
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
250
|
if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
|
|
245
251
|
const summaryProp = properties.summary as { type: string; description: string };
|
|
246
252
|
properties.summary = {
|
|
@@ -255,7 +261,7 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
|
|
|
255
261
|
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
262
|
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
|
|
257
263
|
}
|
|
258
|
-
if (criteria && properties.plan) {
|
|
264
|
+
if (criteria && properties.plan && tool.name !== "hub_experiment_record") {
|
|
259
265
|
const current = properties.plan as { properties: Record<string, unknown> };
|
|
260
266
|
properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties, ...evidencePlanProperties } };
|
|
261
267
|
}
|
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;
|
package/src/hub/peers.ts
CHANGED
|
@@ -64,7 +64,7 @@ export interface PeerAdapter {
|
|
|
64
64
|
/** Fixed-builtin native proof only; present undefined is unknown, not an absent proxy. */
|
|
65
65
|
readonly frontendProxy?: FrontendProxyPort;
|
|
66
66
|
/** Fixed operator notices from builtin native adapters, never provider text or native ids. */
|
|
67
|
-
readonly statusNotice?: { code: "unidentified-cancellation" | "qwen-detached" | "qwen-stalled"; message: string };
|
|
67
|
+
readonly statusNotice?: { code: "unidentified-cancellation" | "qwen-detached" | "qwen-stalled" | "jcode-headless-after-recovery"; message: string };
|
|
68
68
|
/** Fixed builtins with session.nativeRevert only. Native conversation effects remain in the adapter. */
|
|
69
69
|
revertTurn?(nativeTurn: string): Promise<void>;
|
|
70
70
|
/** #445 C1: what this peer's native source lists right now (a probed version only), or an empty manifest. Never throws for an unprobed version; a transport failure rejects. */
|
|
@@ -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. */
|
package/src/hub/review-brief.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { CodeFactResult, CodeSymbol } from "./code-facts.ts";
|
|
2
|
+
import { sanitize } from "./envelope.ts";
|
|
2
3
|
import { childEnv } from "./child-process.ts";
|
|
3
4
|
import { hubGitSync } from "./git.ts";
|
|
4
5
|
import { symbolsOverlap } from "./symbol-normalizer.ts";
|
|
6
|
+
import type { SpecTaskReport } from "./board.ts";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* #520: the review brief ahub builds itself, in place of `ocr delegate preview` and `rule`. Every reviewer gets the same
|
|
@@ -30,6 +32,22 @@ export interface ReviewBrief {
|
|
|
30
32
|
/** Paths in the plain name list of the range that the brief does not list: read them directly. */
|
|
31
33
|
unlisted: string[];
|
|
32
34
|
}
|
|
35
|
+
|
|
36
|
+
/** A bounded, public summary of the design specs affected by one exact task head. */
|
|
37
|
+
export function renderSpecTaskReport(report: SpecTaskReport): string {
|
|
38
|
+
const head = /^[0-9a-f]{40}$/.test(report.head) ? report.head : "unknown";
|
|
39
|
+
const lines = [`Design specs at ${head}:`];
|
|
40
|
+
if (!report.complete) lines.push(`Spec inspection incomplete${report.reason ? `: ${report.reason}` : "; coverage is unknown"}.`);
|
|
41
|
+
if (report.errors > 0) lines.push(`spec errors: ${report.errors}; run ahub specs check.`);
|
|
42
|
+
for (const spec of report.unchanged.slice(0, 20)) {
|
|
43
|
+
const label = `${spec.id}${spec.path ? ` (${spec.path})` : ""}`;
|
|
44
|
+
lines.push(`spec not updated: ${label}${spec.reason ? `; acknowledged: ${spec.reason}` : ""}`);
|
|
45
|
+
}
|
|
46
|
+
for (const spec of report.changed.slice(0, 20)) lines.push(`spec updated: ${spec.id}${spec.path ? ` (${spec.path})` : ""}`);
|
|
47
|
+
if (report.truncated || report.unchanged.length > 20 || report.changed.length > 20) lines.push("Spec report truncated; run ahub specs check for the full report.");
|
|
48
|
+
if (report.complete && report.errors === 0 && report.unchanged.length === 0 && report.changed.length === 0) lines.push("No covering specs were found for the changed files.");
|
|
49
|
+
return sanitize(lines.join("\n"));
|
|
50
|
+
}
|
|
33
51
|
export interface BriefPolicy { privatePath(path: string): boolean; nameable(text: string): boolean }
|
|
34
52
|
|
|
35
53
|
const MAX_FILES = 500, RULE_BYTES = 32_000, MAX_RULE_FILES = 8;
|
package/src/hub/routing.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { join, resolve } from "node:path";
|
|
|
7
7
|
import { CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
8
8
|
import { isGitHubRepository, specRefs } from "./spec-read.ts";
|
|
9
9
|
|
|
10
|
-
export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; }
|
|
10
|
+
export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; /** #1081: the peer's shell can reach the network (through the egress proxy or directly). Absent means unknown, never refused. */ network?: boolean; networkReason?: string; /** #1081: the peer can create the task worktree (`git worktree add` runs outside a hub sandbox, #270). Absent means unknown, never refused. */ worktree?: boolean; worktreeReason?: string; }
|
|
11
11
|
import type { PeerId, PeerState } from "./envelope.ts";
|
|
12
12
|
|
|
13
13
|
import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
|
|
@@ -289,7 +289,7 @@ export function currentRouting(cwd: string, log: (line: string) => void = () =>
|
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
export type Signal = "pii" | "pii_path" | "long_context" | "urgent";
|
|
292
|
+
export type Signal = "pii" | "pii_path" | "long_context" | "urgent" | "needs_network" | "needs_worktree";
|
|
293
293
|
|
|
294
294
|
/** What the policy can see in a task. Context length is estimated from the referenced files, 3 characters per token as elsewhere. */
|
|
295
295
|
export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, routing: Routing, cwd: string): Signal[] {
|
|
@@ -310,7 +310,7 @@ export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, rou
|
|
|
310
310
|
/** A peer's entry in a peer-indexed map: own keys only, so a peer named `constructor` is never an inherited Object member. */
|
|
311
311
|
export const peerEntry = <T>(map: Readonly<Record<string, T>> | undefined, peer: string): T | undefined => (map && Object.hasOwn(map, peer) ? map[peer] : undefined);
|
|
312
312
|
|
|
313
|
-
export type RoutingRefusalCode = QueueBackpressureCode | "context-headroom" | "instance" | "family" | "tests" | "spec" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
|
|
313
|
+
export type RoutingRefusalCode = QueueBackpressureCode | "context-headroom" | "instance" | "family" | "tests" | "spec" | "network" | "worktree" | "roster" | "pull-only" | "not-attached" | "excluded" | "owner-admission" | "offline" | "paused" | "failing" | "pii" | "policy";
|
|
314
314
|
export interface RoutingRefusal { peer: PeerId; role: "owner" | "reviewer"; code: RoutingRefusalCode; reason: string }
|
|
315
315
|
export interface Assignment {
|
|
316
316
|
reviewSelection?: ReviewerPoolSelection;
|
|
@@ -490,6 +490,10 @@ export function assign(
|
|
|
490
490
|
const caps = peerEntry(opts.capabilities, peer);
|
|
491
491
|
if (needsTests && !caps?.tests) return deny("tests", `missing tests: ${caps?.testsReason ?? "capability unavailable"}`);
|
|
492
492
|
if (needsSpec && !caps?.spec) return deny("spec", `missing spec: ${caps?.specReason ?? "capability unavailable"}`);
|
|
493
|
+
// #1081: work that declares it needs the network is never offered to a peer whose shell has none. Unknown (no `network` entry) is not a refusal.
|
|
494
|
+
if (task.signals.includes("needs_network") && caps?.network === false) return deny("network", `no network: ${caps.networkReason ?? "its shell cannot reach the network"}`);
|
|
495
|
+
// #1081: work that declares it needs a task worktree is never offered to a peer that cannot create one, so no branch ref is left behind by a refused accept.
|
|
496
|
+
if (task.signals.includes("needs_worktree") && caps?.worktree === false) return deny("worktree", `no worktree: ${caps.worktreeReason ?? "it cannot create the task worktree"}`);
|
|
493
497
|
}
|
|
494
498
|
if (opts.offRoster?.includes(peer)) return deny("roster", "off roster");
|
|
495
499
|
const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { releaseTrackedClean } from "./release-worktree.ts";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { USER } from "./envelope.ts";
|
|
3
|
+
import { USER, type PeerId } from "./envelope.ts";
|
|
4
4
|
import { hubGitEnv, hubGitSync } from "./git.ts";
|
|
5
5
|
import { readDocEvidence, type DocEvidence, type ReleaseGit, type ReleaseReadiness } from "./sdlc-release.ts";
|
|
6
6
|
import type { SdlcGh } from "./sdlc.ts";
|
|
@@ -13,13 +13,15 @@ const SHA = /^[0-9a-f]{40}$/;
|
|
|
13
13
|
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
14
14
|
export const validReleaseVersion = (value: unknown): value is string => typeof value === "string" && value.length <= 128 && VERSION.test(value);
|
|
15
15
|
export interface ReleaseDocsVerdict { fresh: true; errors: 0; documents: number; digest: string }
|
|
16
|
-
export interface ReleaseAuthorization { schemaVersion: 1; id: string; by:
|
|
16
|
+
export interface ReleaseAuthorization { schemaVersion: 1; id: string; by: PeerId; commit: string; version: string; at: number; docs: ReleaseDocsVerdict }
|
|
17
17
|
export interface ReleaseAuthorizationPorts {
|
|
18
18
|
git?: ReleaseGit;
|
|
19
19
|
docs?: (root: string) => Promise<DocEvidence | undefined>;
|
|
20
20
|
gh?: SdlcGh;
|
|
21
21
|
now?: () => number;
|
|
22
22
|
current?: () => boolean;
|
|
23
|
+
/** Host-only current eligible conductor; absent keeps non-person authorization refused. */
|
|
24
|
+
conductor?: () => PeerId | undefined;
|
|
23
25
|
record: (receipt: ReleaseAuthorization) => boolean;
|
|
24
26
|
}
|
|
25
27
|
export type ReleaseGuardVerdict = { ok: true; receipts: ReleaseAuthorization[] } | { ok: false; error: string };
|
|
@@ -44,6 +46,9 @@ export class ReleaseAuthorizations {
|
|
|
44
46
|
private latest?: Entry;
|
|
45
47
|
constructor(private readonly root: string, private readonly ports: ReleaseAuthorizationPorts) {}
|
|
46
48
|
private current(): void { if (this.ports.current?.() === false) throw new ReleaseAuthorizationError("release authorization unavailable during lifecycle transition"); }
|
|
49
|
+
private actor(by: PeerId): void {
|
|
50
|
+
if (by !== USER && (typeof by !== "string" || !by.trim() || this.ports.conductor?.() !== by)) throw new ReleaseAuthorizationError("only the person or the configured conductor may authorize a release");
|
|
51
|
+
}
|
|
47
52
|
private git(): ReleaseGit {
|
|
48
53
|
let remaining = 5000;
|
|
49
54
|
return args => {
|
|
@@ -85,16 +90,17 @@ export class ReleaseAuthorizations {
|
|
|
85
90
|
return value;
|
|
86
91
|
}
|
|
87
92
|
async authorize(by: string, version: unknown, expectedCommit: unknown): Promise<ReleaseAuthorization> {
|
|
88
|
-
|
|
93
|
+
this.actor(by);
|
|
89
94
|
if (!validReleaseVersion(version) || typeof expectedCommit !== "string" || !SHA.test(expectedCommit)) throw new ReleaseAuthorizationError("authorization needs an exact version and candidate commit");
|
|
90
95
|
this.current();
|
|
91
96
|
const git = this.git(), commit = this.commit(git, "HEAD"), origin = this.origin(git);
|
|
92
97
|
if (commit !== expectedCommit || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("candidate commit or version differs from the person's request");
|
|
93
98
|
const docs = docsVerdict(await (this.ports.docs ?? readDocEvidence)(this.root), commit, version);
|
|
94
99
|
if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence");
|
|
95
|
-
this.current();
|
|
100
|
+
this.current(); this.actor(by);
|
|
96
101
|
if (this.commit(git, "HEAD") !== commit || this.version(git, commit) !== version || this.origin(git).hash !== origin.hash) throw new ReleaseAuthorizationError("release candidate changed during authorization");
|
|
97
|
-
|
|
102
|
+
this.current(); this.actor(by);
|
|
103
|
+
const receipt: ReleaseAuthorization = { schemaVersion: 1, id: randomUUID(), by, commit, version, at: (this.ports.now ?? Date.now)(), docs };
|
|
98
104
|
if (!this.ports.record(structuredClone(receipt))) throw new ReleaseAuthorizationError("release approval audit could not be recorded");
|
|
99
105
|
const entry = { receipt, origin };
|
|
100
106
|
this.entries.set(`${version}:${commit}`, entry); this.latest = entry;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
/** The task ops a distinct session may run: reads that need no role. Everything else is refused with `sessionRefusal`. */
|
|
11
11
|
export const SESSION_READ_OPS: ReadonlySet<string> = new Set([
|
|
12
|
-
"hub_task_show", "hub_task_list", "hub_status", "hub_capabilities", "hub_study_show", "hub_backlog_list", "hub_release_list",
|
|
12
|
+
"hub_task_show", "hub_task_list", "hub_status", "hub_capabilities", "hub_study_show", "hub_experiment_show", "hub_backlog_list", "hub_release_list",
|
|
13
13
|
]);
|
|
14
14
|
|
|
15
15
|
/** The control messages a distinct session may send besides task ops. `task` is gated again by `SESSION_READ_OPS`. */
|
package/src/hub/settings.ts
CHANGED
|
@@ -81,6 +81,8 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
81
81
|
{ key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
|
|
82
82
|
...CLASSES.flatMap(perClass),
|
|
83
83
|
{ key: "research.enabled", group: "Switches", label: "Research records", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["research", "enabled"] },
|
|
84
|
+
{ key: "specs.enabled", group: "Switches", label: "Design specs and freshness", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["specs", "enabled"] },
|
|
85
|
+
{ key: "specs.review", group: "Switches", label: "Covering spec review", type: "enum", values: ["off", "report", "require"], store: "config", applies: "live", risk: "safe", path: ["specs", "review"] },
|
|
84
86
|
{ key: "approvals.notify", group: "Switches", label: "Desktop notice for a waiting approval", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["approvals", "notify"] },
|
|
85
87
|
{ key: "snapshots.enabled", group: "Switches", label: "Per-turn snapshots", type: "boolean", store: "config", applies: "live", risk: "safe", path: ["snapshots", "enabled"] },
|
|
86
88
|
{ key: "coordination", group: "Switches", label: "Coordination of overlapping owners", type: "enum", values: ["advisory", "turn-free"], store: "config", applies: "next use", risk: "safe", path: ["coordination"] },
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type SpecsReview = "off" | "report" | "require";
|
|
2
|
+
|
|
3
|
+
export interface SpecsConfig {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
review: SpecsReview;
|
|
6
|
+
roots: string[];
|
|
7
|
+
stamps_file: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_SPECS_CONFIG: Readonly<SpecsConfig> = Object.freeze({
|
|
11
|
+
enabled: true,
|
|
12
|
+
review: "report",
|
|
13
|
+
roots: [],
|
|
14
|
+
stamps_file: "",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
18
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeRelative(value: unknown): value is string {
|
|
22
|
+
if (typeof value !== "string" || !value.length || value.length > 1024 || value.startsWith("/") || value.startsWith("-") || /^[A-Za-z]:/.test(value) || value.includes("\\") || value.includes(":") || /[\u0000-\u001f\u007f]/.test(value)) return false;
|
|
23
|
+
const normalized = value.endsWith("/") ? value.slice(0, -1) : value;
|
|
24
|
+
return !!normalized && !normalized.split("/").some(part => part === ".." || part === "." || part === "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function invalid(message: string, nextSteps: string): never { throw new Error(`${message}; ${nextSteps}`); }
|
|
28
|
+
|
|
29
|
+
/** Filesystem-free parser shared by config loading and the specs engine. */
|
|
30
|
+
export function parseSpecsConfig(value: unknown): SpecsConfig {
|
|
31
|
+
if (value === undefined) return { ...DEFAULT_SPECS_CONFIG, roots: [] };
|
|
32
|
+
if (!object(value)) invalid("specs must be an object", "use an object with enabled, review, roots and stamps_file fields");
|
|
33
|
+
if (Object.keys(value).some(key => !["enabled", "review", "roots", "stamps_file"].includes(key))) invalid("specs has an unknown field", "use only enabled, review, roots and stamps_file");
|
|
34
|
+
const enabled = value.enabled === undefined ? DEFAULT_SPECS_CONFIG.enabled : value.enabled;
|
|
35
|
+
const review = value.review === undefined ? DEFAULT_SPECS_CONFIG.review : value.review;
|
|
36
|
+
const roots = value.roots === undefined ? DEFAULT_SPECS_CONFIG.roots : value.roots;
|
|
37
|
+
const stampsFile = value.stamps_file === undefined ? DEFAULT_SPECS_CONFIG.stamps_file : value.stamps_file;
|
|
38
|
+
if (typeof enabled !== "boolean") invalid("specs.enabled must be a boolean", "set it to true or false");
|
|
39
|
+
if (review !== "off" && review !== "report" && review !== "require") invalid("specs.review is invalid", "choose off, report or require");
|
|
40
|
+
if (!Array.isArray(roots) || roots.length > 1024 || roots.some(root => !safeRelative(root))) invalid("specs.roots is invalid", "use at most 1024 repository-relative directories without dot segments");
|
|
41
|
+
if (stampsFile !== "" && !safeRelative(stampsFile)) invalid("specs.stamps_file is invalid", "use an empty value or one repository-relative path without dot segments");
|
|
42
|
+
return { enabled, review, roots: [...roots] as string[], stamps_file: stampsFile as string };
|
|
43
|
+
}
|