@staix/agent-hub 0.12.44 → 0.13.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 +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
package/src/hub/hub-tools.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redFirstSchema } from "./test-integrity.ts";
|
|
1
2
|
import { SDLC_STAGES } from "./sdlc.ts";
|
|
2
3
|
import { projectProfile, profileData, PROFILE_CLASS_NAMES, type ProfileName, type ProfileSelection } from "./profiles.ts";
|
|
3
4
|
import { catalogSection, type CatalogRole } from "./roles.ts";
|
|
@@ -17,10 +18,26 @@ const refs = {
|
|
|
17
18
|
additionalProperties: false,
|
|
18
19
|
} as const;
|
|
19
20
|
const list = (description: string) => ({ type: "array", items: str, description }) as const;
|
|
21
|
+
const reviewFindings = {
|
|
22
|
+
type: "array", maxItems: 64,
|
|
23
|
+
description: "Head-bound findings; open Important findings carry across heads until explicitly fixed, false_positive or person-accepted; omission never settles them. Nits stay on their head. accepted is person-only and requires rationale. Nit count is capped by sdlc.max_nits (default 5).",
|
|
24
|
+
items: { type: "object", properties: {
|
|
25
|
+
id: { type: "string", minLength: 1, maxLength: 96, pattern: "^[A-Za-z0-9._:-]+$" },
|
|
26
|
+
pass: { type: "string", enum: ["bugs", "security", "compliance"] },
|
|
27
|
+
severity: { type: "string", enum: ["important", "nit"] },
|
|
28
|
+
path: { type: "string", minLength: 1, maxLength: 512 },
|
|
29
|
+
disposition: { type: "string", enum: ["open", "fixed", "false_positive", "accepted"] },
|
|
30
|
+
rationale: { type: "string", minLength: 1, maxLength: 2048 },
|
|
31
|
+
}, required: ["id", "pass", "severity", "disposition"], additionalProperties: false },
|
|
32
|
+
} as const;
|
|
20
33
|
const costPlanProperties = {
|
|
21
34
|
review: { type: "object", properties: { scope: { type: "string", enum: ["none", "delta", "full"] }, reviewers: { type: "integer", minimum: 0, maximum: 8 } }, required: ["scope", "reviewers"], additionalProperties: false },
|
|
22
35
|
ci: { type: "string", enum: ["focused", "full", "reuse"] },
|
|
23
36
|
} as const;
|
|
37
|
+
const evidencePlanProperties = {
|
|
38
|
+
proof: { type: "array", maxItems: 32, items: { type: "string", maxLength: 512 }, description: "test paths or check names planned to prove the change" },
|
|
39
|
+
risks: { type: "array", maxItems: 32, items: { type: "string", maxLength: 512 }, description: "short risks to check during implementation and review" },
|
|
40
|
+
} as const;
|
|
24
41
|
const plan = {
|
|
25
42
|
type: "object",
|
|
26
43
|
description: "what you will change, before you start. Owners of open tasks on the same files or symbols see it, and get a notice when your task is done.",
|
|
@@ -75,7 +92,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
75
92
|
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 }, ["id", "summary"]),
|
|
76
93
|
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: { 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"]),
|
|
77
94
|
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" } }),
|
|
78
|
-
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" }, 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" }, 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"]),
|
|
95
|
+
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" }, 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"]),
|
|
79
96
|
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"]),
|
|
80
97
|
tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
|
|
81
98
|
tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
|
|
@@ -175,25 +192,46 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
|
|
|
175
192
|
if (criteria) {
|
|
176
193
|
const { phase, ...current } = fields as { phase: { properties: Record<string, unknown> } } & Record<string, unknown>;
|
|
177
194
|
const { tasks: _retiredImport, ...checklist } = phase.properties;
|
|
178
|
-
fields = { ...current, criteria: { ...phase, properties: checklist, required: ["id", "criteria"] } };
|
|
195
|
+
fields = { ...current, redFirst: redFirstSchema, criteria: { ...phase, properties: checklist, required: ["id", "criteria"] } };
|
|
179
196
|
}
|
|
180
197
|
properties.receipt = { ...receipt, properties: fields };
|
|
181
198
|
}
|
|
182
199
|
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])])] };
|
|
183
|
-
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")])) };
|
|
200
|
+
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")])) } };
|
|
201
|
+
if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
|
|
202
|
+
const summaryProp = properties.summary as { type: string; description: string };
|
|
203
|
+
properties.summary = {
|
|
204
|
+
...summaryProp,
|
|
205
|
+
description: data.name === "research" || data.name === "writing"
|
|
206
|
+
? "changed deliverables, provenance, checks and unknowns"
|
|
207
|
+
: "what changed, why, and the check or verification you ran with its result",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
184
210
|
if (customProfile && tool.name === "hub_backlog_split") {
|
|
185
211
|
const parts = properties.parts as { items: { properties: Record<string, unknown> } };
|
|
186
|
-
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")])) };
|
|
212
|
+
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")])) } };
|
|
187
213
|
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
|
|
188
214
|
}
|
|
189
215
|
if (criteria && properties.plan) {
|
|
190
216
|
const current = properties.plan as { properties: Record<string, unknown> };
|
|
191
|
-
properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties } };
|
|
217
|
+
properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties, ...evidencePlanProperties } };
|
|
192
218
|
}
|
|
193
219
|
if (criteria && tool.name === "hub_backlog_split") {
|
|
194
220
|
const parts = properties.parts as { items: { properties: { plan: { properties: Record<string, unknown> } } } };
|
|
195
|
-
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties } } } } };
|
|
221
|
+
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties, ...evidencePlanProperties } } } } };
|
|
222
|
+
}
|
|
223
|
+
let description = tool.description;
|
|
224
|
+
if (customProfile && tool.name === "hub_task_propose") {
|
|
225
|
+
description = tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.`;
|
|
226
|
+
} else if (customProfile && tool.name === "hub_review" && data.name !== "dev") {
|
|
227
|
+
const reviewAction = data.name === "research" || data.name === "writing"
|
|
228
|
+
? "assess scope, provenance and unsupported claims against the task's plan or detail"
|
|
229
|
+
: "assess the deliverables against the task's plan or detail";
|
|
230
|
+
description = `Give your verdict on a task you were asked to review: ${reviewAction}, 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.`;
|
|
231
|
+
} else if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
|
|
232
|
+
const summaryLabel = data.name === "research" || data.name === "writing" ? "deliverables, provenance and checks" : "deliverables and checks";
|
|
233
|
+
description = `Mark your task finished. It goes to its reviewer with your summary (${summaryLabel}) 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.`;
|
|
196
234
|
}
|
|
197
|
-
return { ...tool,
|
|
235
|
+
return { ...tool, description, inputSchema: { ...tool.inputSchema, properties } };
|
|
198
236
|
});
|
|
199
237
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Advisory only: reading an inbox is never refused. */
|
|
2
|
+
export class InboxPolling {
|
|
3
|
+
// ponytail: counters reset on restart; persist only if restarts hide real polling.
|
|
4
|
+
private readonly streaks = new Map<string, { reads: number[]; noticed: boolean }>();
|
|
5
|
+
|
|
6
|
+
reset(peer: string): void { this.streaks.delete(peer); }
|
|
7
|
+
|
|
8
|
+
read(peer: string, empty: boolean, assigned: boolean, now: number): { count: number; minutes: number; notice: boolean } | undefined {
|
|
9
|
+
if (!empty || assigned) { this.reset(peer); return; }
|
|
10
|
+
let streak = this.streaks.get(peer);
|
|
11
|
+
const last = streak?.reads.at(-1);
|
|
12
|
+
if (!streak || last === undefined || now < last || now - last > 10 * 60_000) {
|
|
13
|
+
streak = { reads: [], noticed: false };
|
|
14
|
+
this.streaks.set(peer, streak);
|
|
15
|
+
}
|
|
16
|
+
streak.reads = streak.reads.filter(at => now - at <= 10 * 60_000);
|
|
17
|
+
// ponytail: advice counts at most 64 recent reads; use time buckets if full high-rate counts become useful.
|
|
18
|
+
if (streak.reads.length === 64) streak.reads.shift();
|
|
19
|
+
streak.reads.push(now);
|
|
20
|
+
if (streak.reads.length < 3) return;
|
|
21
|
+
const notice = !streak.noticed;
|
|
22
|
+
streak.noticed = true;
|
|
23
|
+
return { count: streak.reads.length, minutes: Math.floor((now - streak.reads[0]!) / 60_000), notice };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { createProjectAdapterCatalog, defaultPeerRoles, definitionOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
3
|
+
import { createProjectAdapterCatalog, defaultPeerRoles, definitionOf, type ProjectAdapterCatalog, type AdapterDefinition } from "../adapters/registry.ts";
|
|
4
|
+
import { readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
|
|
5
|
+
import { externalMetadataDefinitions } from "../adapters/external-metadata.ts";
|
|
6
|
+
import { readControl, stateDirFor } from "./control-client.ts";
|
|
4
7
|
import { conductorPeer } from "./conductor.ts";
|
|
5
8
|
import { configRefusal } from "./config-trust.ts";
|
|
6
9
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
@@ -18,13 +21,15 @@ export function readInstanceConfigLayers(cwd: string): InstanceConfigLayer[] {
|
|
|
18
21
|
|
|
19
22
|
/** Declaration authority is file provenance, never a suffix or a claim made by a native child. */
|
|
20
23
|
export interface InstanceRoleConfig {
|
|
24
|
+
adapter_modules?: readonly AdapterModuleDeclaration[];
|
|
21
25
|
adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
22
26
|
instances?: InstancesConfig;
|
|
23
27
|
catalog: ProjectAdapterCatalog;
|
|
24
28
|
ignored: string[];
|
|
25
29
|
}
|
|
26
|
-
export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd)): InstanceRoleConfig {
|
|
27
|
-
const
|
|
30
|
+
export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigLayer[] = readInstanceConfigLayers(cwd), externalDefinitions?: readonly AdapterDefinition[]): InstanceRoleConfig {
|
|
31
|
+
const moduleConfig = readAdapterModuleDeclarations(cwd, layers);
|
|
32
|
+
const ignored: string[] = [...moduleConfig.ignored];
|
|
28
33
|
let adapters: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>> | undefined, rawInstances: unknown;
|
|
29
34
|
for (const { name, raw } of layers) {
|
|
30
35
|
const keys = ["adapters", "instances"].filter(key => raw[key] !== undefined);
|
|
@@ -34,9 +39,16 @@ export function readInstanceConfig(cwd: string, layers: readonly InstanceConfigL
|
|
|
34
39
|
if (raw.adapters !== undefined) adapters = raw.adapters as Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
|
|
35
40
|
if (raw.instances !== undefined) rawInstances = raw.instances;
|
|
36
41
|
}
|
|
37
|
-
|
|
42
|
+
let external = externalDefinitions;
|
|
43
|
+
if (external === undefined) {
|
|
44
|
+
// Metadata discovery is optional: retained recovery receipts may name a project that no longer exists.
|
|
45
|
+
let metadata;
|
|
46
|
+
try { metadata = readControl(stateDirFor(cwd))?.externalAdapters; } catch { /* no running catalog available */ }
|
|
47
|
+
external = externalMetadataDefinitions(metadata);
|
|
48
|
+
}
|
|
49
|
+
const catalog = createProjectAdapterCatalog(adapters, external);
|
|
38
50
|
const instances = validateInstances(rawInstances, catalog);
|
|
39
|
-
return { ...(adapters !== undefined ? { adapters } : {}), ...(Object.keys(instances).length ? { instances } : {}), catalog, ignored };
|
|
51
|
+
return { ...(moduleConfig.modules.length ? { adapter_modules: moduleConfig.modules } : {}), ...(adapters !== undefined ? { adapters } : {}), ...(Object.keys(instances).length ? { instances } : {}), catalog, ignored };
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
/** Capture ordinary role policy and trusted instance declarations together, without loading daemon configuration. */
|
|
@@ -6,17 +6,29 @@ import { realPath } from "./project.ts";
|
|
|
6
6
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
7
7
|
|
|
8
8
|
const refuse = (reason: string): never => { throw new Error(`instance execution workspace refused: ${reason}`); };
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
// #225: this runs synchronously on the daemon's event loop (an extra-instance start, turn_revert), and a resolution makes up to four git
|
|
10
|
+
// calls. One allowance covers all of them, so the loop is held for at most GIT_BUDGET_MS in git however many calls remain; a call that
|
|
11
|
+
// finds the allowance spent refuses without starting git. ponytail: the calls stay synchronous because the CLI and recovery callers are
|
|
12
|
+
// synchronous by contract; an async twin for the two daemon call sites is the upgrade if a typical resolution ever costs more than a few ms
|
|
13
|
+
// (measured about 15 ms for four calls on a warm local repository).
|
|
14
|
+
const GIT_BUDGET_MS = 5000, GIT_OUTPUT_BYTES = 1024 * 1024;
|
|
15
|
+
type Gitter = (cwd: string, args: string[]) => string;
|
|
16
|
+
function gitWithin(budgetMs: number, clock: () => number, run: typeof hubGitSync = hubGitSync): Gitter {
|
|
17
|
+
const deadline = clock() + budgetMs;
|
|
18
|
+
return (cwd, args) => {
|
|
19
|
+
const left = Math.floor(deadline - clock());
|
|
20
|
+
if (left <= 0) return refuse("Git workspace evidence unavailable");
|
|
21
|
+
const result = run(["-C", cwd, ...args], { encoding: "utf8", timeout: left, maxBuffer: GIT_OUTPUT_BYTES });
|
|
22
|
+
if (result.error || result.status !== 0) return refuse("Git workspace evidence unavailable");
|
|
23
|
+
return result.stdout;
|
|
24
|
+
};
|
|
14
25
|
}
|
|
15
|
-
const commonDirectory = (cwd: string): string => realPath(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).replace(/\n$/, ""));
|
|
26
|
+
const commonDirectory = (git: Gitter, cwd: string): string => realPath(git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).replace(/\n$/, ""));
|
|
16
27
|
|
|
17
28
|
/** Point-in-time read only. No worktree is created, repaired or chosen implicitly. */
|
|
18
|
-
export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[], adapterCatalog: ProjectAdapterCatalog): string {
|
|
29
|
+
export function resolveInstanceExecutionCwd(projectRoot: string, id: string, declarations: InstancesConfig | undefined, roles: readonly string[], adapterCatalog: ProjectAdapterCatalog, budgetMs = GIT_BUDGET_MS, clock: () => number = () => performance.now(), run: typeof hubGitSync = hubGitSync): string {
|
|
19
30
|
if (adapterCatalog.definitionOf(id)) return projectRoot;
|
|
31
|
+
const git = gitWithin(budgetMs, clock, run);
|
|
20
32
|
const declaration = validateInstances(declarations, adapterCatalog)[id];
|
|
21
33
|
if (!declaration) return refuse("extra instance is not declared");
|
|
22
34
|
const implementer = roles.includes("implementer");
|
|
@@ -41,7 +53,7 @@ export function resolveInstanceExecutionCwd(projectRoot: string, id: string, dec
|
|
|
41
53
|
try { return realPath(path) === cwd; } catch { return false; }
|
|
42
54
|
});
|
|
43
55
|
if (matches.length !== 1 || matches[0]!.some(field => field === "prunable" || field.startsWith("prunable "))) return refuse("directory is not an available registered project worktree");
|
|
44
|
-
if (commonDirectory(root) !== commonDirectory(cwd)) return refuse("worktree belongs to a different Git repository");
|
|
56
|
+
if (commonDirectory(git, root) !== commonDirectory(git, cwd)) return refuse("worktree belongs to a different Git repository");
|
|
45
57
|
const top = git(cwd, ["rev-parse", "--show-toplevel"]).replace(/\n$/, "");
|
|
46
58
|
if (realPath(top) !== cwd) return refuse("directory is not the registered worktree root");
|
|
47
59
|
if (implementer && !matches[0]!.some(field => field.startsWith("branch refs/heads/") && field.length > "branch refs/heads/".length)) return refuse("an implementer worktree requires an attached branch");
|
package/src/hub/instances.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { types } from "node:util";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
4
|
+
import { canonicalPath, realPath } from "./project.ts";
|
|
2
5
|
import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
3
6
|
import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
|
|
4
7
|
|
|
@@ -10,7 +13,17 @@ export interface InstanceConfig {
|
|
|
10
13
|
review_mode?: "fresh";
|
|
11
14
|
worktree?: string;
|
|
12
15
|
readOnly?: boolean;
|
|
16
|
+
/** Jcode instances only (#685): their own native data home, and optional model selection. Machine-local, never editable from settings. */
|
|
17
|
+
jcode_home?: string;
|
|
18
|
+
provider?: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
provider_profile?: string;
|
|
13
21
|
}
|
|
22
|
+
const JCODE_FIELDS = ["jcode_home", "provider", "model", "provider_profile"] as const;
|
|
23
|
+
/** At most this many extra Jcode instances per project (#685). */
|
|
24
|
+
export const MAX_JCODE_INSTANCES = 8;
|
|
25
|
+
/** A flag value jcode takes as one argv word: bounded, printable, no spaces. */
|
|
26
|
+
const NATIVE_WORD = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/;
|
|
14
27
|
export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
|
|
15
28
|
export interface InstanceCatalogEntry { id: string; kind: string; definition: AdapterDefinition; config?: InstanceConfig }
|
|
16
29
|
const MAX_INSTANCES = 64;
|
|
@@ -41,6 +54,58 @@ function roles(value: unknown, field: string): string[] {
|
|
|
41
54
|
return out;
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
/** The Jcode-only fields of one declaration (#685). Only the validated values are copied; nothing is read twice. */
|
|
58
|
+
function jcodeInstanceFields(entry: Record<string, PropertyDescriptor>, path: string): Partial<InstanceConfig> {
|
|
59
|
+
const home: unknown = entry.jcode_home?.value;
|
|
60
|
+
if (typeof home !== "string" || !isAbsolute(home) || !home.trim() || Buffer.byteLength(home) > 4096 || /\p{Cc}/u.test(home)) return fail(`${path}.jcode_home`, "is required: a nonempty control-free absolute path of at most 4096 UTF-8 bytes, other than the base Jcode's home");
|
|
61
|
+
const out: Partial<InstanceConfig> = { jcode_home: home };
|
|
62
|
+
for (const key of ["provider", "model", "provider_profile"] as const) {
|
|
63
|
+
const present = entry[key];
|
|
64
|
+
if (!present) continue;
|
|
65
|
+
const word: unknown = present.value;
|
|
66
|
+
if (typeof word !== "string" || !NATIVE_WORD.test(word)) return fail(`${path}.${key}`, "must be one bounded printable word without spaces");
|
|
67
|
+
out[key] = word;
|
|
68
|
+
}
|
|
69
|
+
if (entry.start_mode && entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "a Jcode instance starts headless");
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The directory a home names, as the disk sees it (#685 review): symlinks and system aliases (`/tmp` and `/private/tmp`) resolved with
|
|
75
|
+
* `realPath` (never `realpathSync`, which Bun fails on a backslash path), and for a home that does not exist yet the nearest existing
|
|
76
|
+
* ancestor resolved the same way. Compared case-folded when the volume folds case, so a case-only alias is the same home.
|
|
77
|
+
*/
|
|
78
|
+
function canonicalHome(home: string): { path: string; key: string; identity?: string } {
|
|
79
|
+
let path: string;
|
|
80
|
+
try { path = realPath(home); } catch { path = canonicalPath(home); }
|
|
81
|
+
const stat = (target: string) => { try { return statSync(target); } catch { return undefined; } };
|
|
82
|
+
let existing = path;
|
|
83
|
+
while (!stat(existing) && dirname(existing) !== existing) existing = dirname(existing);
|
|
84
|
+
const info = stat(path), anchor = stat(existing);
|
|
85
|
+
const swapped = existing.replace(/[A-Za-z]/g, letter => letter === letter.toLowerCase() ? letter.toUpperCase() : letter.toLowerCase());
|
|
86
|
+
const alias = swapped !== existing ? stat(swapped) : undefined;
|
|
87
|
+
const folds = !!anchor && !!alias && alias.ino === anchor.ino && alias.dev === anchor.dev;
|
|
88
|
+
return { path, key: folds ? path.toLowerCase() : path, ...(info ? { identity: `${info.dev}:${info.ino}` } : {}) };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One native data home per Jcode (#685): an instance may not use the base Jcode's home, another instance's, or one nested
|
|
92
|
+
* inside either (their stores would overlap), however the path is spelled: a symlink, a system alias or a case-only alias of one
|
|
93
|
+
* is the same home. Run once with the loaded base `jcode_home`.
|
|
94
|
+
*/
|
|
95
|
+
export function checkJcodeInstanceHomes(declarations: InstancesConfig | undefined, baseHome: string): void {
|
|
96
|
+
const homes: { id: string; key: string; identity?: string }[] = [{ id: "the base Jcode", ...canonicalHome(baseHome) }];
|
|
97
|
+
for (const [id, config] of Object.entries(declarations ?? {})) {
|
|
98
|
+
if (config.jcode_home === undefined) continue; // only a native-home kind carries one
|
|
99
|
+
const home = canonicalHome(config.jcode_home);
|
|
100
|
+
for (const other of homes) {
|
|
101
|
+
const label = other.id === "the base Jcode" ? "base" : other.id;
|
|
102
|
+
if (home.key === other.key || (home.identity !== undefined && home.identity === other.identity)) return fail(`instances.${id}.jcode_home`, other.id === "the base Jcode" ? "must differ from the base Jcode's home" : `is the same home as ${label}`);
|
|
103
|
+
if (home.key.startsWith(`${other.key}${sep}`) || other.key.startsWith(`${home.key}${sep}`)) return fail(`instances.${id}.jcode_home`, `is nested inside or around ${other.id === "the base Jcode" ? "the base Jcode's home" : `${label}'s home`}`);
|
|
104
|
+
}
|
|
105
|
+
homes.push({ id, ...home });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
44
109
|
/** Declaration support is explicit policy, independent of the native MCP binding mechanism. */
|
|
45
110
|
export function supportsHeadlessInstances(definition: AdapterDefinition): boolean {
|
|
46
111
|
return definition.instances?.mode === "headless" && !!definition.start.create && definition.start.forms.includes("headless");
|
|
@@ -57,15 +122,19 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
57
122
|
const result: Record<string, InstanceConfig> = Object.create(null);
|
|
58
123
|
if (raw === undefined) return Object.freeze(result);
|
|
59
124
|
const declarations = fields(raw, "instances");
|
|
125
|
+
let jcodeCount = 0;
|
|
60
126
|
if (Object.keys(declarations).length > MAX_INSTANCES) return fail("instances", "at most 64 extra instances are supported");
|
|
61
127
|
for (const [id, descriptor] of Object.entries(declarations)) {
|
|
62
128
|
if (!PEER_ID.test(id) || RESERVED.has(id) || resolve(id)) return fail("instances", "ids must be nonreserved extra instance ids, not builtin ids");
|
|
63
129
|
const path = `instances.${id}`, entry = fields(descriptor.value, path);
|
|
64
|
-
|
|
130
|
+
const declaredKind: unknown = entry.kind?.value;
|
|
131
|
+
const jcodeFields = typeof declaredKind === "string" && resolve(declaredKind)?.instances?.nativeHome === true;
|
|
132
|
+
if (Object.keys(entry).some(key => !["kind", "roles", "start_mode", "permission_mode", "worktree", "review_mode", "readOnly", ...(jcodeFields ? JCODE_FIELDS : [])].includes(key))) return fail(path, "unsupported field");
|
|
65
133
|
const kind: unknown = entry.kind?.value;
|
|
66
134
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
67
135
|
const definition = resolve(kind), prefix = `${kind}-`;
|
|
68
|
-
if (!definition) return fail(`${path}.kind`, "unknown native kind");
|
|
136
|
+
if (!definition) return fail(`${path}.kind`, "unknown native kind (external adapter instances are unsupported in A4)");
|
|
137
|
+
if (catalog.externalDefinitions.some(item => item.kind === kind)) return fail(`${path}.kind`, "external adapter instances are unsupported in A4");
|
|
69
138
|
if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
|
|
70
139
|
const declaredRoles = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
71
140
|
const reviewer = (definition.reviewerVariant ?? definition).reviewer;
|
|
@@ -101,6 +170,10 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
101
170
|
if (entry.readOnly.value && entry.permission_mode) return fail(`${path}.permission_mode`, "read-only instances cannot select a permission mode");
|
|
102
171
|
}
|
|
103
172
|
const value: InstanceConfig = { kind, ...(entry.review_mode ? { review_mode: "fresh" as const } : {}) };
|
|
173
|
+
if (jcodeFields) {
|
|
174
|
+
if (++jcodeCount > MAX_JCODE_INSTANCES) return fail("instances", `at most ${MAX_JCODE_INSTANCES} extra Jcode instances are supported per project`);
|
|
175
|
+
Object.assign(value, jcodeInstanceFields(entry, path));
|
|
176
|
+
}
|
|
104
177
|
if (entry.readOnly) value.readOnly = entry.readOnly.value as boolean;
|
|
105
178
|
if (entry.roles) { value.roles = roles(entry.roles.value, `${path}.roles`); Object.freeze(value.roles); }
|
|
106
179
|
if (entry.start_mode) {
|
package/src/hub/peers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
|
|
2
|
+
import type { PermissionMode } from "./permission-mode.ts";
|
|
2
3
|
|
|
3
4
|
export interface DeliveryReceipt {
|
|
4
5
|
id: string;
|
|
@@ -11,6 +12,14 @@ export interface DeliveryReceipt {
|
|
|
11
12
|
export interface PeerAdapter {
|
|
12
13
|
readonly id: PeerId;
|
|
13
14
|
readonly state: PeerState;
|
|
15
|
+
/** Host-observed native session identity. An absent port permits owner-token fallback; undefined means unknown. */
|
|
16
|
+
readonly sessionGeneration?: string | number;
|
|
17
|
+
/** Defaults to fencing checkpoints when the generation port exists; native legacy exceptions opt out explicitly. */
|
|
18
|
+
readonly checkpointUsesSessionGeneration?: boolean;
|
|
19
|
+
/** Effective native approval state; absent peers retain the hub-managed choice. */
|
|
20
|
+
readonly permissionModeState?: PermissionMode | "unknown" | "unmanaged";
|
|
21
|
+
/** Resolve only after native acknowledgement. This changes approval frequency, never the sandbox. */
|
|
22
|
+
setPermissionMode?(mode: PermissionMode): Promise<void>;
|
|
14
23
|
/** A peer the hub drives itself (Pi, the local worker). It cannot be trusted to rate its own urgency: the bus caps it. */
|
|
15
24
|
readonly hubNative?: boolean;
|
|
16
25
|
/** Never handed a delivery: its queue waits for `Bus.pull` (a Claude session that cannot show channel pushes, issue #205). */
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
|
|
4
|
+
export interface PlanConformance {
|
|
5
|
+
state: "covered" | "departure" | "unknown";
|
|
6
|
+
head?: string;
|
|
7
|
+
outside: string[];
|
|
8
|
+
untouched: string[];
|
|
9
|
+
}
|
|
10
|
+
/** Repository-relative literal names only. A star is a filename character, never a glob operator. */
|
|
11
|
+
function pathIdentity(raw: unknown): string | undefined {
|
|
12
|
+
if (typeof raw !== "string" || !raw || raw.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(raw)
|
|
13
|
+
|| raw.startsWith("/") || raw.startsWith("\\") || /^[A-Za-z]:/.test(raw)) return undefined;
|
|
14
|
+
const path = raw.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
|
|
15
|
+
if (path === "." || path.split("/").some(part => part === "." || part === "..")) return undefined;
|
|
16
|
+
return path;
|
|
17
|
+
}
|
|
18
|
+
const covers = (planned: string, changed: string) => changed === planned || changed.startsWith(`${planned}/`);
|
|
19
|
+
|
|
20
|
+
/** A complete file inventory is independent of any plan or coverage claim. */
|
|
21
|
+
export function completePlanInventory(evidence: { head: string; paths: readonly string[]; pathsComplete?: boolean; typesComplete?: boolean } | undefined): string[] | undefined {
|
|
22
|
+
if (!evidence || typeof evidence.head !== "string" || !/^[a-f0-9]{40}$/.test(evidence.head) || evidence.pathsComplete !== true || evidence.typesComplete !== true || !Array.isArray(evidence.paths) || evidence.paths.length > 1000) return undefined;
|
|
23
|
+
const paths: string[] = [], seen = new Set<string>();
|
|
24
|
+
for (const raw of evidence.paths) {
|
|
25
|
+
const path = pathIdentity(raw); if (path === undefined || seen.has(path)) return undefined;
|
|
26
|
+
seen.add(path); paths.push(path);
|
|
27
|
+
}
|
|
28
|
+
return paths;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Compare current complete PR file metadata with the current plan; never inspect patches or the filesystem. */
|
|
32
|
+
export function planConformance(planPaths: readonly string[] | undefined, evidence: { head: string; paths: readonly string[]; pathsComplete?: boolean; typesComplete?: boolean } | undefined): PlanConformance {
|
|
33
|
+
const head = typeof evidence?.head === "string" && /^[a-f0-9]{40}$/.test(evidence.head) ? evidence.head : undefined;
|
|
34
|
+
const unknown: PlanConformance = { state: "unknown", ...(head ? { head } : {}), outside: [], untouched: [] };
|
|
35
|
+
const changed = completePlanInventory(evidence);
|
|
36
|
+
if (!head || changed === undefined
|
|
37
|
+
|| !Array.isArray(planPaths) || !planPaths.length || planPaths.length > 1000) return unknown;
|
|
38
|
+
const planned: string[] = [], seenPlan = new Set<string>();
|
|
39
|
+
for (const raw of planPaths) {
|
|
40
|
+
const path = pathIdentity(raw); if (path === undefined) return unknown;
|
|
41
|
+
if (!seenPlan.has(path)) { seenPlan.add(path); planned.push(path); }
|
|
42
|
+
}
|
|
43
|
+
const outside = changed.filter(path => !planned.some(plan => covers(plan, path)));
|
|
44
|
+
const untouched = planned.filter(plan => !changed.some(path => covers(plan, path)));
|
|
45
|
+
return { state: outside.length ? "departure" : "covered", head, outside, untouched };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Hash plain plan data without invoking accessors/toJSON; object key order is immaterial, list order is not. */
|
|
49
|
+
export function planFingerprint(plan: unknown): string {
|
|
50
|
+
const seen = new Set<object>();
|
|
51
|
+
const fail = (): never => { throw new Error("invalid plan fingerprint data"); };
|
|
52
|
+
const encode = (value: unknown, depth: number): string => {
|
|
53
|
+
if (depth > 64) return fail();
|
|
54
|
+
if (value === undefined) return "undefined";
|
|
55
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
56
|
+
if (typeof value === "number") return Number.isFinite(value) ? JSON.stringify(value) : fail();
|
|
57
|
+
if (typeof value !== "object" || types.isProxy(value) || seen.has(value)) return fail();
|
|
58
|
+
seen.add(value);
|
|
59
|
+
try {
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
62
|
+
if (Reflect.ownKeys(descriptors).length !== value.length + 1) return fail();
|
|
63
|
+
return "[" + Array.from({ length: value.length }, (_, index) => {
|
|
64
|
+
const descriptor = descriptors[String(index)];
|
|
65
|
+
return descriptor && Object.hasOwn(descriptor, "value") ? encode(descriptor.value, depth + 1) : fail();
|
|
66
|
+
}).join(",") + "]";
|
|
67
|
+
}
|
|
68
|
+
if (![Object.prototype, null].includes(Object.getPrototypeOf(value))) return fail();
|
|
69
|
+
const keys = Reflect.ownKeys(value); if (keys.some(key => typeof key !== "string")) return fail();
|
|
70
|
+
return "{" + (keys as string[]).sort().map(key => {
|
|
71
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
|
72
|
+
if (!Object.hasOwn(descriptor, "value") || !descriptor.enumerable) return fail();
|
|
73
|
+
return JSON.stringify(key) + ":" + encode(descriptor.value, depth + 1);
|
|
74
|
+
}).join(",") + "}";
|
|
75
|
+
} finally { seen.delete(value); }
|
|
76
|
+
};
|
|
77
|
+
return createHash("sha256").update(encode(plan, 0)).digest("hex");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Approval blockers only; untouched planned files are advisory. */
|
|
81
|
+
export function planConformanceBlockers(plan: (PlanConformance & { accepted?: boolean }) | undefined): string[] {
|
|
82
|
+
return !plan ? [] : plan.state === "unknown" ? ["plan conformance unknown"] : plan.state === "departure" && !plan.accepted ? ["plan conformance: changed files outside the plan"] : [];
|
|
83
|
+
}
|
package/src/hub/profiles.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type ProfileName = (typeof PROFILE_NAMES)[number];
|
|
|
10
10
|
export interface ProjectProfile {
|
|
11
11
|
name: string; classes: Record<string, string>; plan_keys: string[]; role_text: Record<string, string>;
|
|
12
12
|
catalog_roles: Record<string, string[]>; agents: string; example_checks: string[];
|
|
13
|
+
review_checklist?: string[];
|
|
13
14
|
}
|
|
14
15
|
const PROFILES: Record<ProfileName, ProjectProfile> = { dev, research, writing, general };
|
|
15
16
|
export function profileName(raw: unknown): ProfileName {
|
|
@@ -34,10 +35,10 @@ export function resolveProfile(raw?: unknown, overrides?: unknown): ProjectProfi
|
|
|
34
35
|
const original = projectProfile(raw);
|
|
35
36
|
if (overrides === undefined) return original;
|
|
36
37
|
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) throw new Error("profile_overrides must be an object");
|
|
37
|
-
const data = overrides as Record<string, unknown>, allowed = new Set(["classes", "plan_keys", "role_text", "catalog_roles", "agents", "example_checks"]);
|
|
38
|
+
const data = overrides as Record<string, unknown>, allowed = new Set(["classes", "plan_keys", "role_text", "catalog_roles", "agents", "example_checks", "review_checklist"]);
|
|
38
39
|
if (Object.keys(data).some(key => !allowed.has(key))) throw new Error("unknown profile override field");
|
|
39
40
|
if (!Object.keys(data).length) return original;
|
|
40
|
-
const result: ProjectProfile = { ...original, classes: { ...original.classes }, plan_keys: [...original.plan_keys], role_text: { ...original.role_text }, catalog_roles: Object.fromEntries(Object.entries(original.catalog_roles).map(([peer, ids]) => [peer, [...ids]])), example_checks: [...original.example_checks] };
|
|
41
|
+
const result: ProjectProfile = { ...original, classes: { ...original.classes }, plan_keys: [...original.plan_keys], role_text: { ...original.role_text }, catalog_roles: Object.fromEntries(Object.entries(original.catalog_roles).map(([peer, ids]) => [peer, [...ids]])), example_checks: [...original.example_checks], ...(original.review_checklist ? { review_checklist: [...original.review_checklist] } : {}) };
|
|
41
42
|
const text = (value: unknown, cap: number): string => { if (typeof value !== "string" || value.length > cap) throw new Error("profile text is missing or oversized"); return sanitize(value); };
|
|
42
43
|
if (data.classes !== undefined) {
|
|
43
44
|
if (!data.classes || typeof data.classes !== "object" || Array.isArray(data.classes) || Object.keys(data.classes).length > 32) throw new Error("profile classes must be a bounded base-class map");
|
|
@@ -63,6 +64,10 @@ export function resolveProfile(raw?: unknown, overrides?: unknown): ProjectProfi
|
|
|
63
64
|
if (!Array.isArray(data.example_checks) || data.example_checks.length > 16) throw new Error("profile example_checks must be a bounded display list");
|
|
64
65
|
result.example_checks = data.example_checks.map(value => text(value, 2000));
|
|
65
66
|
}
|
|
67
|
+
if (data.review_checklist !== undefined) {
|
|
68
|
+
if (!Array.isArray(data.review_checklist) || !data.review_checklist.length || data.review_checklist.length > 16 || data.review_checklist.some(value => typeof value !== "string" || !value.trim() || value.length > 500)) throw new Error("profile review_checklist must be bounded checklist lines");
|
|
69
|
+
result.review_checklist = data.review_checklist.map(value => sanitize(value.trim()));
|
|
70
|
+
}
|
|
66
71
|
return result;
|
|
67
72
|
}
|
|
68
73
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
3
|
+
import { dirname, relative, resolve } from "node:path";
|
|
4
|
+
import type { ReleaseGit } from "./sdlc-release.ts";
|
|
5
|
+
|
|
6
|
+
/** Filter-free tracked checkout comparison. Unknown or transformed content refuses. */
|
|
7
|
+
// ponytail: 4096 entries, 4 MiB/file, 32 MiB total and 250 ms; larger/gitlink/transformed checkouts require a future bounded artifact attestation.
|
|
8
|
+
export function releaseTrackedClean(root: string, git: ReleaseGit): boolean {
|
|
9
|
+
const start = performance.now(), deadline = () => performance.now() - start <= 250;
|
|
10
|
+
const run = (args: string[]) => {
|
|
11
|
+
if (!deadline()) return;
|
|
12
|
+
const result = git(args);
|
|
13
|
+
return deadline() && result.code === 0 && Buffer.byteLength(result.stdout) <= 2 * 1024 * 1024 ? result.stdout : undefined;
|
|
14
|
+
};
|
|
15
|
+
try {
|
|
16
|
+
const base = resolve(root);
|
|
17
|
+
if (!lstatSync(base).isDirectory() || lstatSync(base).isSymbolicLink()) return false;
|
|
18
|
+
if (run(["diff-index", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "HEAD", "--"]) === undefined) return false;
|
|
19
|
+
const inventory = run(["ls-files", "--stage", "-z"]);
|
|
20
|
+
if (inventory === undefined || inventory && !inventory.endsWith("\0")) return false;
|
|
21
|
+
const entries = inventory ? inventory.slice(0, -1).split("\0") : [];
|
|
22
|
+
if (entries.length > 4096) return false;
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (!deadline()) return false;
|
|
26
|
+
const match = /^([0-7]{6}) ([0-9a-f]{40}) ([0-3])\t([\s\S]+)$/.exec(entry);
|
|
27
|
+
if (!match) return false;
|
|
28
|
+
const [, mode, oid, stage, name] = match;
|
|
29
|
+
if (stage !== "0" || !["100644", "100755", "120000"].includes(mode!)) return false;
|
|
30
|
+
if (!name || name.length > 4096 || name.startsWith("/") || name.includes("\\") || name.split("/").some(part => !part || part === "." || part === "..") || /[\p{Cc}]/u.test(name)) return false;
|
|
31
|
+
const file = resolve(base, name), within = relative(base, file);
|
|
32
|
+
if (!within || within === ".." || within.startsWith("../")) return false;
|
|
33
|
+
for (let parent = dirname(file); parent !== base; parent = dirname(parent)) {
|
|
34
|
+
if (!deadline()) return false;
|
|
35
|
+
const info = lstatSync(parent);
|
|
36
|
+
if (!info.isDirectory() || info.isSymbolicLink()) return false;
|
|
37
|
+
}
|
|
38
|
+
const before = lstatSync(file);
|
|
39
|
+
if (before.size > 4 * 1024 * 1024 || total + before.size > 32 * 1024 * 1024) return false;
|
|
40
|
+
let bytes: Buffer;
|
|
41
|
+
if (mode === "120000") {
|
|
42
|
+
if (!before.isSymbolicLink()) return false;
|
|
43
|
+
bytes = readlinkSync(file, { encoding: "buffer" });
|
|
44
|
+
} else {
|
|
45
|
+
if (!before.isFile() || before.isSymbolicLink() || !!(before.mode & 0o111) !== (mode === "100755")) return false;
|
|
46
|
+
bytes = readFileSync(file);
|
|
47
|
+
}
|
|
48
|
+
total += bytes.length;
|
|
49
|
+
if (bytes.length > 4 * 1024 * 1024 || total > 32 * 1024 * 1024 || !deadline()) return false;
|
|
50
|
+
const after = lstatSync(file);
|
|
51
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) return false;
|
|
52
|
+
if (createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex") !== oid) return false;
|
|
53
|
+
}
|
|
54
|
+
return deadline();
|
|
55
|
+
} catch { return false; }
|
|
56
|
+
}
|
package/src/hub/review-brief.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { CodeFactResult, CodeSymbol } from "./code-facts.ts";
|
|
1
2
|
import { childEnv } from "./child-process.ts";
|
|
2
3
|
import { hubGitSync } from "./git.ts";
|
|
4
|
+
import { symbolsOverlap } from "./symbol-normalizer.ts";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* #520: the review brief ahub builds itself, in place of `ocr delegate preview` and `rule`. Every reviewer gets the same
|
|
@@ -10,6 +12,7 @@ import { hubGitSync } from "./git.ts";
|
|
|
10
12
|
export interface BriefFile { path: string; status: string; oldPath?: string; insertions?: number; deletions?: number }
|
|
11
13
|
export interface BriefExcluded { path: string; status: string; oldPath?: string; reason: string }
|
|
12
14
|
export interface ReviewBrief {
|
|
15
|
+
symbols?: { state: "known" | "unknown"; complete?: boolean; files: {path:string; symbols:CodeSymbol[]}[]; planned: string[]; unplanned: string[]; unseen: string[] };
|
|
13
16
|
head: string;
|
|
14
17
|
/** The default branch the head is compared with: origin/HEAD, origin/main or origin/master. */
|
|
15
18
|
base: string;
|
|
@@ -149,6 +152,38 @@ function attributes(cwd: string, source: string, paths: string[]): Map<string, {
|
|
|
149
152
|
return out;
|
|
150
153
|
}
|
|
151
154
|
|
|
155
|
+
/** One plan name against one changed name: a qualified pair must be equal, otherwise the last segments are. Shared with the console's task detail (#684 2b). */
|
|
156
|
+
export const planSymbolMatches = (left: string, right: string): boolean => symbolsOverlap(left, right);
|
|
157
|
+
/** Project derived names through the host metadata screen before comparing or rendering them. */
|
|
158
|
+
export function withCodeSymbols(brief:ReviewBrief,result:CodeFactResult<CodeSymbol[]>,plan:readonly string[]|undefined,publicMetadataText:(text:string)=>boolean):ReviewBrief {
|
|
159
|
+
const unknown={...brief,symbols:{state:"unknown" as const,files:[],planned:[],unplanned:[],unseen:[]}};
|
|
160
|
+
if(result.state!=="known")return unknown;
|
|
161
|
+
try {
|
|
162
|
+
const paths=new Set(brief.reviewable.map(file=>file.path));
|
|
163
|
+
const changed=result.value.filter(s=>!s.ambiguous && paths.has(s.path) && publicMetadataText(s.path) && publicMetadataText(s.name) && publicMetadataText(s.kind));
|
|
164
|
+
const planned=(plan??[]).filter(name=>publicMetadataText(name));
|
|
165
|
+
const matches=planSymbolMatches;
|
|
166
|
+
const names=[...new Set(changed.map(s=>s.name))];
|
|
167
|
+
return {...brief,symbols:{state:"known",complete:changed.length===result.value.length && brief.withheld===0,files:[...paths].map(path=>({path,symbols:changed.filter(s=>s.path===path)})),
|
|
168
|
+
planned:names.filter(name=>planned.some(p=>matches(p,name))),unplanned:names.filter(name=>!planned.some(p=>matches(p,name))),unseen:planned.filter(p=>!names.some(name=>matches(p,name)))}};
|
|
169
|
+
} catch { return unknown; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderCodeSymbols(brief:ReviewBrief):string[] {
|
|
173
|
+
const symbols=brief.symbols;
|
|
174
|
+
if(!symbols)return [];
|
|
175
|
+
if(symbols.state === "unknown")return ["Changed symbols: unknown (optional code facts unavailable)."];
|
|
176
|
+
const lines=[
|
|
177
|
+
"Changed symbols (advisory; change: unknown):",
|
|
178
|
+
...symbols.files.flatMap(file=>file.symbols.length ? file.symbols.map(symbol=>`- ${file.path}: ${symbol.name} (${symbol.kind}, lines ${symbol.line}${symbol.endLine === undefined ? "..unknown" : `..${symbol.endLine}`}; change: unknown)`) : [`- ${file.path}: no changed symbol observed`]),
|
|
179
|
+
`Changed and planned: ${symbols.planned.join(", ") || "none observed"}`,
|
|
180
|
+
`Changed, not planned: ${symbols.unplanned.join(", ") || "none observed"}`,
|
|
181
|
+
`Planned, not seen as changed: ${symbols.unseen.join(", ") || "none observed"}`,
|
|
182
|
+
"Ceiling: ripwire 0.6.7 does not report deleted symbols; these lists never establish plan conformance.",
|
|
183
|
+
];
|
|
184
|
+
return Buffer.byteLength(lines.join("\n"))<=16000 ? lines : ["Changed symbols: unknown (advice exceeds 16000 bytes)."];
|
|
185
|
+
}
|
|
186
|
+
|
|
152
187
|
/** The brief as review text: what every reviewer reads and how it accounts for its coverage. */
|
|
153
188
|
export function renderReviewBrief(brief: ReviewBrief): string {
|
|
154
189
|
const file = (f: BriefFile) => `- ${f.oldPath ? `${f.oldPath} -> ` : ""}${f.path} (${f.status}${f.insertions !== undefined ? `, +${f.insertions} -${f.deletions}` : ""})`;
|
|
@@ -156,6 +191,7 @@ export function renderReviewBrief(brief: ReviewBrief): string {
|
|
|
156
191
|
`Review brief: head ${brief.head} against ${brief.base} (merge base ${brief.mergeBase.slice(0, 12)}); ${brief.reviewable.length} reviewable, ${brief.excluded.length} excluded${brief.withheld ? `, ${brief.withheld} withheld by the privacy policy` : ""}.`,
|
|
157
192
|
...(brief.reviewable.length ? ["Reviewable:", ...brief.reviewable.map(file)] : ["Reviewable: none (record why in the note)."]),
|
|
158
193
|
...(brief.excluded.length ? ["Excluded (still read their diff when it matters):", ...brief.excluded.map(e => `- ${e.oldPath ? `${e.oldPath} -> ` : ""}${e.path} (${e.status}): ${e.reason}`)] : []),
|
|
194
|
+
...renderCodeSymbols(brief),
|
|
159
195
|
...(brief.unlisted.length ? ["Not in the brief's list, read directly:", ...brief.unlisted.map(path => `- ${path}`)] : []),
|
|
160
196
|
...brief.ruleGroups.filter(g => g.sources.length).map(g => `Rules ${g.sources.join(" + ")} apply to: ${brief.ruleGroups.length === 1 ? "every reviewable file" : g.paths.join(", ")}`),
|
|
161
197
|
...(brief.rulesMissing.length ? ["Configured rules not loaded (read them in the repository):", ...brief.rulesMissing.map(m => `- ${m.source}: ${m.reason}`)] : []),
|