@staix/agent-hub 0.12.44 → 0.12.45
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 +22 -0
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +9 -0
- package/docs/agent-notes/adapters.md +58 -44
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/daemon.md +9 -7
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +8 -5
- package/docs/agent-notes/tests.md +7 -7
- package/docs/operations.md +99 -7
- package/docs/quickstart.md +5 -1
- package/docs/sdlc.md +26 -8
- package/docs/security.md +6 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
- 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/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/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 +9818 -9605
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/claude-channel.ts +14 -5
- package/src/adapters/codex-appserver.ts +1 -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 +23 -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-preview.ts +2 -11
- package/src/adapters/definitions/qwen.ts +4 -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/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +10 -0
- package/src/adapters/registry.ts +51 -15
- package/src/cli/help.ts +21 -16
- package/src/cli/identity.ts +1 -1
- package/src/cli/main.ts +23 -5
- 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/board.ts +30 -4
- package/src/hub/config-apply.ts +18 -10
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +95 -39
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +21 -5
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +2 -1
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +5 -1
- package/src/hub/snapshots.ts +8 -6
- package/src/hub/tasks.ts +275 -45
- package/src/hub/ui.ts +2 -2
- package/src/ui/index.html +559 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
package/src/hub/hub-tools.ts
CHANGED
|
@@ -17,10 +17,26 @@ const refs = {
|
|
|
17
17
|
additionalProperties: false,
|
|
18
18
|
} as const;
|
|
19
19
|
const list = (description: string) => ({ type: "array", items: str, description }) as const;
|
|
20
|
+
const reviewFindings = {
|
|
21
|
+
type: "array", maxItems: 64,
|
|
22
|
+
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).",
|
|
23
|
+
items: { type: "object", properties: {
|
|
24
|
+
id: { type: "string", minLength: 1, maxLength: 96, pattern: "^[A-Za-z0-9._:-]+$" },
|
|
25
|
+
pass: { type: "string", enum: ["bugs", "security", "compliance"] },
|
|
26
|
+
severity: { type: "string", enum: ["important", "nit"] },
|
|
27
|
+
path: { type: "string", minLength: 1, maxLength: 512 },
|
|
28
|
+
disposition: { type: "string", enum: ["open", "fixed", "false_positive", "accepted"] },
|
|
29
|
+
rationale: { type: "string", minLength: 1, maxLength: 2048 },
|
|
30
|
+
}, required: ["id", "pass", "severity", "disposition"], additionalProperties: false },
|
|
31
|
+
} as const;
|
|
20
32
|
const costPlanProperties = {
|
|
21
33
|
review: { type: "object", properties: { scope: { type: "string", enum: ["none", "delta", "full"] }, reviewers: { type: "integer", minimum: 0, maximum: 8 } }, required: ["scope", "reviewers"], additionalProperties: false },
|
|
22
34
|
ci: { type: "string", enum: ["focused", "full", "reuse"] },
|
|
23
35
|
} as const;
|
|
36
|
+
const evidencePlanProperties = {
|
|
37
|
+
proof: { type: "array", maxItems: 32, items: { type: "string", maxLength: 512 }, description: "test paths or check names planned to prove the change" },
|
|
38
|
+
risks: { type: "array", maxItems: 32, items: { type: "string", maxLength: 512 }, description: "short risks to check during implementation and review" },
|
|
39
|
+
} as const;
|
|
24
40
|
const plan = {
|
|
25
41
|
type: "object",
|
|
26
42
|
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 +91,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
75
91
|
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
92
|
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
93
|
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"]),
|
|
94
|
+
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
95
|
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
96
|
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
97
|
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"]),
|
|
@@ -180,19 +196,19 @@ export function profileTaskTools(profile: ProfileSelection = "dev", stages?: rea
|
|
|
180
196
|
properties.receipt = { ...receipt, properties: fields };
|
|
181
197
|
}
|
|
182
198
|
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")])) };
|
|
199
|
+
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")])) } };
|
|
184
200
|
if (customProfile && tool.name === "hub_backlog_split") {
|
|
185
201
|
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")])) };
|
|
202
|
+
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
203
|
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
|
|
188
204
|
}
|
|
189
205
|
if (criteria && properties.plan) {
|
|
190
206
|
const current = properties.plan as { properties: Record<string, unknown> };
|
|
191
|
-
properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties } };
|
|
207
|
+
properties.plan = { ...current, properties: { ...current.properties, ...costPlanProperties, ...evidencePlanProperties } };
|
|
192
208
|
}
|
|
193
209
|
if (criteria && tool.name === "hub_backlog_split") {
|
|
194
210
|
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 } } } } };
|
|
211
|
+
properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: { ...parts.items.properties.plan, properties: { ...parts.items.properties.plan.properties, ...costPlanProperties, ...evidencePlanProperties } } } } };
|
|
196
212
|
}
|
|
197
213
|
return { ...tool, ...(customProfile && tool.name === "hub_task_propose" ? { description: tool.description + ` Selected profile ${data.name}: ${Object.keys(data.classes).join(", ")}. Routing uses their existing base classes.` } : {}), inputSchema: { ...tool.inputSchema, properties } };
|
|
198
214
|
});
|
|
@@ -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
|
@@ -65,7 +65,8 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
65
65
|
const kind: unknown = entry.kind?.value;
|
|
66
66
|
if (typeof kind !== "string") return fail(`${path}.kind`, "must name a native kind");
|
|
67
67
|
const definition = resolve(kind), prefix = `${kind}-`;
|
|
68
|
-
if (!definition) return fail(`${path}.kind`, "unknown native kind");
|
|
68
|
+
if (!definition) return fail(`${path}.kind`, "unknown native kind (external adapter instances are unsupported in A4)");
|
|
69
|
+
if (catalog.externalDefinitions.some(item => item.kind === kind)) return fail(`${path}.kind`, "external adapter instances are unsupported in A4");
|
|
69
70
|
if (!id.startsWith(prefix) || !/^[a-z0-9][a-z0-9-]*$/.test(id.slice(prefix.length))) return fail(path, "id must be <kind>-<name>");
|
|
70
71
|
const declaredRoles = entry.roles ? roles(entry.roles.value, `${path}.roles`) : [];
|
|
71
72
|
const reviewer = (definition.reviewerVariant ?? definition).reviewer;
|
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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import { sanitize } from "./envelope.ts";
|
|
3
|
+
|
|
4
|
+
export interface ReviewFinding {
|
|
5
|
+
id: string;
|
|
6
|
+
pass: "bugs" | "security" | "compliance";
|
|
7
|
+
severity: "important" | "nit";
|
|
8
|
+
path?: string;
|
|
9
|
+
disposition: "open" | "fixed" | "false_positive" | "accepted";
|
|
10
|
+
rationale?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ReviewFindingSet { head?: string; items: ReviewFinding[] }
|
|
13
|
+
export interface ReviewFindingTally { important: number; nit: number; openImportant: number; openNit: number; fixed: number; falsePositive: number; accepted: number }
|
|
14
|
+
export const DEFAULT_MAX_NITS = 5;
|
|
15
|
+
const MAX_ITEMS = 64, MAX_BYTES = 64 * 1024;
|
|
16
|
+
const fail = (reason: string): never => { throw new Error("review findings: " + reason); };
|
|
17
|
+
function fields(raw: unknown): Record<string, PropertyDescriptor> {
|
|
18
|
+
if (!raw || typeof raw !== "object" || types.isProxy(raw) || Array.isArray(raw)
|
|
19
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(raw)) || Object.getOwnPropertySymbols(raw).length) return fail("plain own-data finding required");
|
|
20
|
+
const descriptors: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(raw));
|
|
21
|
+
if (Object.values(descriptors).some(field => !Object.hasOwn(field, "value") || !field.enumerable)) return fail("accessors and hidden fields refused");
|
|
22
|
+
if (Object.keys(descriptors).some(key => !["id", "pass", "severity", "path", "disposition", "rationale"].includes(key))) return fail("unknown finding field");
|
|
23
|
+
return descriptors;
|
|
24
|
+
}
|
|
25
|
+
function text(raw: unknown, max: number, path: boolean): string {
|
|
26
|
+
if (typeof raw !== "string" || !raw.trim() || Buffer.byteLength(raw) > max
|
|
27
|
+
|| path && /[\p{Cc}\p{Cf}]/u.test(raw)) return fail("invalid bounded finding text");
|
|
28
|
+
const clean = sanitize(raw.trim());
|
|
29
|
+
if (Buffer.byteLength(clean) > max) return fail("sanitized finding text exceeds its bound");
|
|
30
|
+
return clean;
|
|
31
|
+
}
|
|
32
|
+
/** Supplied findings are validated independently of lifecycle mode, before any board effect. */
|
|
33
|
+
export function parseReviewFindings(raw: unknown, options: { person: boolean; maxNits: number }): ReviewFinding[] {
|
|
34
|
+
if (typeof options.person !== "boolean" || !Number.isSafeInteger(options.maxNits) || options.maxNits < 0 || options.maxNits > MAX_ITEMS) return fail("invalid finding authority or nit limit");
|
|
35
|
+
if (raw === undefined) return [];
|
|
36
|
+
if (!Array.isArray(raw) || types.isProxy(raw) || Object.getPrototypeOf(raw) !== Array.prototype
|
|
37
|
+
|| Object.getOwnPropertySymbols(raw).length || raw.length > MAX_ITEMS) return fail("at most 64 own-data findings required");
|
|
38
|
+
const descriptors: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(raw));
|
|
39
|
+
if (Object.keys(descriptors).length !== raw.length + 1) return fail("dense finding array required");
|
|
40
|
+
const ids = new Set<string>();
|
|
41
|
+
const result = Array.from({ length: raw.length }, (_, index): ReviewFinding => {
|
|
42
|
+
const entry = descriptors[String(index)];
|
|
43
|
+
if (!entry || !Object.hasOwn(entry, "value") || !entry.enumerable) return fail("finding array accessors refused");
|
|
44
|
+
const item = fields(entry.value), id: unknown = item.id?.value, pass: unknown = item.pass?.value;
|
|
45
|
+
const severity: unknown = item.severity?.value, disposition: unknown = item.disposition?.value;
|
|
46
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9._:-]+$/.test(id) || Buffer.byteLength(id) > 96 || ids.has(id)) return fail("invalid or duplicate finding id");
|
|
47
|
+
ids.add(id);
|
|
48
|
+
if (pass !== "bugs" && pass !== "security" && pass !== "compliance") return fail("invalid finding pass");
|
|
49
|
+
if (severity !== "important" && severity !== "nit") return fail("invalid finding severity");
|
|
50
|
+
if (disposition !== "open" && disposition !== "fixed" && disposition !== "false_positive" && disposition !== "accepted") return fail("invalid finding disposition");
|
|
51
|
+
const path = item.path ? text(item.path.value, 512, true) : undefined;
|
|
52
|
+
const rationale = item.rationale ? text(item.rationale.value, 2048, false) : undefined;
|
|
53
|
+
if (disposition === "accepted" && (!options.person || !rationale)) return fail("accepted findings require a person and rationale");
|
|
54
|
+
return { id, pass, severity, disposition, ...(path !== undefined ? { path } : {}), ...(rationale !== undefined ? { rationale } : {}) };
|
|
55
|
+
});
|
|
56
|
+
if (result.filter(item => item.severity === "nit").length > options.maxNits) return fail("nit limit exceeded");
|
|
57
|
+
if (Buffer.byteLength(JSON.stringify(result)) > MAX_BYTES) return fail("finding report exceeds 64 KiB");
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Prefer live artifact identity, skipping malformed historical candidates rather than hiding a valid fallback. */
|
|
62
|
+
export function findingHead(receipt?: { head?: string; reviewedHead?: string; localHead?: string }, evidence?: { head: string }, commit?: string): string | undefined {
|
|
63
|
+
return [evidence?.head, receipt?.head, receipt?.reviewedHead, receipt?.localHead, commit]
|
|
64
|
+
.find((head): head is string => typeof head === "string" && /^[a-f0-9]{40}$/.test(head));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Nits belong only to their head bucket, including the explicit undefined bucket.
|
|
69
|
+
* Open Important is an obligation across heads until an explicit disposition resolves its ID globally.
|
|
70
|
+
*/
|
|
71
|
+
export function findingsForHead(sets: readonly ReviewFindingSet[], head?: string): ReviewFinding[] {
|
|
72
|
+
interface Entry { item: ReviewFinding; order: number }
|
|
73
|
+
const current = new Map<string, Entry>(), important = new Map<string, Entry>();
|
|
74
|
+
let order = 0;
|
|
75
|
+
for (const set of sets) for (const item of set.items) {
|
|
76
|
+
const key = item.id, position = order++;
|
|
77
|
+
if (set.head === head) current.set(key, { item: { ...current.get(key)?.item, ...item }, order: position });
|
|
78
|
+
const prior = important.get(key);
|
|
79
|
+
if (item.severity === "important") {
|
|
80
|
+
important.set(key, { item: { ...prior?.item, ...item }, order: position });
|
|
81
|
+
} else if (prior && (prior.item.disposition === "open" || item.disposition !== "open")) {
|
|
82
|
+
// A nit/open reclassification cannot discharge Important. An explicit disposition can.
|
|
83
|
+
important.set(key, { item: { ...prior.item, ...item, severity: "important" }, order: position });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const [key, entry] of important) {
|
|
87
|
+
if (entry.item.disposition === "open") current.set(key, entry);
|
|
88
|
+
else {
|
|
89
|
+
const seen = current.get(key);
|
|
90
|
+
// Settling on B also settles older A when A is queried again. A later new nit/open remains a head-local nit.
|
|
91
|
+
if (seen?.item.disposition === "open" && seen.order <= entry.order) current.delete(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...current.values()].map(entry => ({ ...entry.item }));
|
|
95
|
+
}
|
|
96
|
+
export function findingTally(items: readonly ReviewFinding[]): ReviewFindingTally {
|
|
97
|
+
const tally: ReviewFindingTally = { important: 0, nit: 0, openImportant: 0, openNit: 0, fixed: 0, falsePositive: 0, accepted: 0 };
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
tally[item.severity]++;
|
|
100
|
+
if (item.disposition === "open") tally[item.severity === "important" ? "openImportant" : "openNit"]++;
|
|
101
|
+
else tally[item.disposition === "false_positive" ? "falsePositive" : item.disposition]++;
|
|
102
|
+
}
|
|
103
|
+
return tally;
|
|
104
|
+
}
|
package/src/hub/roster.ts
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
import { definitionOf as builtinDefinitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { validateInstances } from "./instances.ts";
|
|
3
|
+
import { defaultRosterEnabled as builtinDefaultRosterEnabled, createProjectAdapterCatalog as builtinCatalog } from "../adapters/registry.ts";
|
|
3
4
|
|
|
4
5
|
export type PeerRoster = Partial<Record<string, boolean>>;
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* A roster key of an instance that was declared, put on the roster and later undeclared (#225). It has the shape a declaration
|
|
9
|
+
* could have had (`<builtin kind>-<name>`, the rule validateInstances applies to an id) and no definition now, so it grants and
|
|
10
|
+
* refuses nothing: the config still loads and `ahub roster remove` can delete it. Anything else stays an unsupported peer.
|
|
11
|
+
*/
|
|
12
|
+
export function staleInstanceKey(peer: string, catalog: ProjectAdapterCatalog | undefined): boolean {
|
|
13
|
+
const definitionOf = catalog?.definitionOf ?? builtinDefinitionOf;
|
|
14
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(peer) || definitionOf(peer)) return false; // a reserved id (user, hub, digest) never starts with a kind and a dash
|
|
15
|
+
if (!catalog) return false;
|
|
16
|
+
return catalog.definitions.some(kind => peer.startsWith(`${kind.kind}-`) && /^[a-z0-9][a-z0-9-]*$/.test(peer.slice(kind.kind.length + 1)) && canDeclareInstance(peer, kind.kind, catalog) && !catalog.externalDefinitions.some(item => item.kind === kind.kind));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The declarations a person can write for an extra instance of a kind: validateInstances decides which of them its definition takes. */
|
|
20
|
+
const DECLARATION_SHAPES: readonly Record<string, unknown>[] = [
|
|
21
|
+
{ roles: ["implementer"], start_mode: "headless" }, { roles: ["reviewer"], start_mode: "headless" }, { roles: ["reviewer"] }, { roles: ["planner"] },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Whether validateInstances would take `<kind>-<name>` for this kind under any declaration, so the answer cannot drift from its policy. */
|
|
25
|
+
function canDeclareInstance(id: string, kind: string, catalog: ProjectAdapterCatalog): boolean {
|
|
26
|
+
return DECLARATION_SHAPES.some(shape => { try { validateInstances({ [id]: { kind, ...shape } }, catalog); return true; } catch { return false; } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function peerRosterConfig(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog, inertUnknown = false): PeerRoster {
|
|
7
30
|
const definitionOf = catalog?.definitionOf ?? builtinDefinitionOf;
|
|
8
31
|
if (value === undefined) return {};
|
|
9
32
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
|
|
10
33
|
const roster: PeerRoster = {};
|
|
34
|
+
const requireKnownPeer = (peer: string) => {
|
|
35
|
+
if (!definitionOf(peer, instances) && !staleInstanceKey(peer, catalog ?? builtinCatalog())) throw new Error("roster contains an unsupported peer");
|
|
36
|
+
};
|
|
11
37
|
for (const [peer, on] of Object.entries(value)) {
|
|
12
|
-
if (!
|
|
38
|
+
if (!inertUnknown || !/^[a-z][a-z0-9-]{0,31}$/.test(peer)) requireKnownPeer(peer);
|
|
13
39
|
if (typeof on !== "boolean") throw new Error(`roster.${peer} must be a boolean`);
|
|
14
40
|
roster[peer] = on;
|
|
15
41
|
}
|
|
@@ -17,10 +43,10 @@ export function peerRosterConfig(value: unknown, instances?: KindMap, catalog?:
|
|
|
17
43
|
}
|
|
18
44
|
|
|
19
45
|
/** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
|
|
20
|
-
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): boolean {
|
|
46
|
+
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances: KindMap | undefined, catalog: ProjectAdapterCatalog, inertUnknown = false): boolean {
|
|
21
47
|
const definitionOf = catalog.definitionOf;
|
|
22
48
|
const defaultRosterEnabled = (id: string, enabled: boolean) => !builtinDefinitionOf(id) ? catalog.definitionOf(id)?.defaultOnRoster ?? false : builtinDefaultRosterEnabled(id, enabled);
|
|
23
49
|
const definition = definitionOf(peer, instances);
|
|
24
|
-
if (!definition) return
|
|
50
|
+
if (!definition) return !inertUnknown || !Object.hasOwn(roster, peer); // Only a captured module scope makes unresolved rows inert; legacy external clients keep their admission rules.
|
|
25
51
|
return roster[peer] ?? (definition.kind === peer ? defaultRosterEnabled(peer, piEnabled) : true);
|
|
26
52
|
}
|
package/src/hub/sdlc-queue.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { planConformance, planConformanceBlockers } from "./plan-conformance.ts";
|
|
2
|
+
import { findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
1
3
|
import type { CriteriaView } from "./workflow.ts";
|
|
2
4
|
import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
|
|
3
|
-
export interface DeliveryTask { id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
|
|
4
|
-
export interface MergeQueueRow { pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
5
|
+
export interface DeliveryTask { plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] }; findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
|
|
6
|
+
export interface MergeQueueRow { planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
5
7
|
export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
|
|
6
8
|
export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
|
|
7
9
|
const validRepo = (value: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith("-");
|
|
@@ -49,15 +51,28 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
|
|
|
49
51
|
for (const pr of ids) {
|
|
50
52
|
const holders = tasks.filter(t => sameRepo(t.receipt?.repo) && t.receipt?.pr === pr); links.set(pr, holders);
|
|
51
53
|
const row: MergeQueueRow = { pr, tasks: holders.some(t => t.private) ? [] : holders.map(t => t.id), ready: false, blockers: [], after: [] }; result.rows.push(row);
|
|
52
|
-
if (holders.some(t => t.private)) { row.private = true; row.blockers.push("private lifecycle evidence unavailable"); continue; }
|
|
54
|
+
if (holders.some(t => t.private)) { row.private = true; if (config.mode !== "off") row.planConformance = [{ state: "unknown", outside: 0, untouched: 0, accepted: false }]; row.blockers.push("private lifecycle evidence unavailable"); continue; }
|
|
53
55
|
const read = await readSdlcPr(holders[0]?.receipt ?? { stage: config.stages[0] ?? "spec", repo, pr }, bounded);
|
|
54
56
|
if (!read || read.state !== "OPEN") { row.blockers.push("current PR evidence unknown or no longer open"); continue; }
|
|
55
57
|
evidence.set(pr, read); row.head = read.head;
|
|
58
|
+
for (const holder of holders) {
|
|
59
|
+
const items = findingsForHead(holder.findings ?? [], findingHead(holder.receipt, read));
|
|
60
|
+
if (!items.length) continue;
|
|
61
|
+
const tally = findingTally(items);
|
|
62
|
+
row.findings ??= findingTally([]);
|
|
63
|
+
for (const key of Object.keys(tally) as (keyof ReviewFindingTally)[]) row.findings[key] += tally[key];
|
|
64
|
+
}
|
|
56
65
|
if (!read.pathsComplete) row.blockers.push("PR file coverage is incomplete or unknown");
|
|
57
66
|
if (read.draft !== false) row.blockers.push("PR is draft or draft status is unknown");
|
|
58
67
|
if (!read.base) row.blockers.push("PR target branch is unknown");
|
|
59
68
|
const linked = holders.length ? holders : [undefined];
|
|
60
|
-
|
|
69
|
+
if (config.mode === "enforce" && row.findings?.openImportant) row.blockers.push("open important review findings: " + row.findings.openImportant);
|
|
70
|
+
for (const holder of linked) {
|
|
71
|
+
const conformance = holder?.plan ? { ...planConformance(holder.plan.paths, read), accepted: holder.plan.accepted.some(item => item.repo === holder.receipt?.repo && item.pr === holder.receipt?.pr && item.head === read.head && item.revision === holder.plan!.revision && item.fingerprint === holder.plan!.fingerprint && JSON.stringify([...item.paths].sort()) === JSON.stringify([...read.paths].sort())) } : undefined;
|
|
72
|
+
if (holder && conformance && config.mode !== "off") (row.planConformance ??= []).push({ task: holder.id, state: conformance.state, outside: conformance.outside.length, untouched: conformance.untouched.length, accepted: conformance.accepted });
|
|
73
|
+
row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
|
|
74
|
+
if (config.mode === "enforce") row.blockers.push(...planConformanceBlockers(conformance));
|
|
75
|
+
}
|
|
61
76
|
}
|
|
62
77
|
const dependencyEvidence = new Map<number, PrEvidence | undefined>(); let dependencyReads = 0;
|
|
63
78
|
for (const row of result.rows) {
|
package/src/hub/sdlc-release.ts
CHANGED
|
@@ -4,7 +4,22 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
6
6
|
import { hubGitSync } from "./git.ts";
|
|
7
|
-
import {
|
|
7
|
+
import type { SdlcGh } from "./sdlc.ts";
|
|
8
|
+
// Standalone document evidence does not need lifecycle/review code; load the default remote reader only when used.
|
|
9
|
+
let importDiagnosticShown = false;
|
|
10
|
+
const sdlcGh: SdlcGh = async (...args) => {
|
|
11
|
+
let reader: typeof import("./sdlc.ts");
|
|
12
|
+
try { reader = await import("./sdlc.ts"); }
|
|
13
|
+
catch {
|
|
14
|
+
if (!importDiagnosticShown) {
|
|
15
|
+
importDiagnosticShown = true;
|
|
16
|
+
try { console.warn("sdlc: release evidence reader unavailable (lifecycle import failed)"); } catch { /* a diagnostic cannot turn unknown evidence into an exception */ }
|
|
17
|
+
}
|
|
18
|
+
return { code: 1, stdout: "" };
|
|
19
|
+
}
|
|
20
|
+
try { return await reader.sdlcGh(...args); }
|
|
21
|
+
catch { return { code: 1, stdout: "" }; }
|
|
22
|
+
};
|
|
8
23
|
export interface DocEvidence { schemaVersion: 1; head?: string; version?: string; documents: { path?: string; state: "fresh" | "stale" | "nonancestor" | "unknown"; verifiedAgainst?: string; commits?: number; dirty?: boolean }[]; errors: number; fresh: boolean; }
|
|
9
24
|
export interface ReleaseReadiness { repo: string; head?: string; version?: string; tag?: string; tagCommit?: string; commits: string[]; mergedPrs: { pr: number; commit: string }[]; documents?: DocEvidence; liveIssues: { id: number; state: "open" | "closed" | "unknown" }[]; blockers: string[]; sourceReady: boolean; native: "unverified"; ready: false; }
|
|
10
25
|
export type ReleaseGit = (args: string[]) => { code: number; stdout: string };
|