@staix/agent-hub 0.12.43 → 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 +38 -3
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +228 -0
- package/docs/agent-notes/README.md +14 -0
- package/docs/agent-notes/adapters.md +61 -42
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +16 -10
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +13 -6
- package/docs/agent-notes/tests.md +9 -7
- package/docs/operations.md +163 -12
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +26 -8
- package/docs/security.md +11 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
- package/docs/verification/2026-10-21-0.12.44.md +54 -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-21-grok-config-acp-t0.md +30 -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 +38 -20
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10287 -9560
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +4 -4
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +21 -12
- package/src/adapters/codex-appserver.ts +17 -2
- package/src/adapters/configured-acp.ts +124 -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 +60 -39
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +7 -9
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- 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 +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +6 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +28 -16
- package/src/adapters/registry.ts +115 -17
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +27 -16
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +101 -32
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +6 -4
- package/src/cli/upgrade-runtime.ts +25 -7
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +37 -8
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +37 -17
- package/src/hub/config-reload.ts +42 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +412 -328
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +57 -5
- package/src/hub/instance-config.ts +33 -12
- package/src/hub/instance-execution.ts +23 -11
- package/src/hub/instances.ts +47 -22
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/peers.ts +9 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +35 -6
- package/src/hub/routing.ts +21 -20
- 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 +54 -16
- package/src/hub/snapshots.ts +32 -6
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +358 -60
- package/src/hub/ui.ts +2 -2
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +563 -77
- 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/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Task } from "./board.ts";
|
|
2
|
+
|
|
3
|
+
export interface NoWriteObservation {
|
|
4
|
+
task: number;
|
|
5
|
+
/** Opaque binding to the current owner and its latest acceptance. */
|
|
6
|
+
generation: string;
|
|
7
|
+
limit: number;
|
|
8
|
+
changed: boolean | undefined;
|
|
9
|
+
effect: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Pure per-task streaks; callers own evidence, privacy and warning delivery. */
|
|
13
|
+
export class NoWriteTurns {
|
|
14
|
+
private readonly streaks = new Map<number, { generation: string; count: number; emitted: boolean }>();
|
|
15
|
+
|
|
16
|
+
observe({ task, generation, limit, changed, effect }: NoWriteObservation): number | undefined {
|
|
17
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || changed !== false || effect !== false) {
|
|
18
|
+
this.reset(task);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const previous = this.streaks.get(task);
|
|
22
|
+
const streak = previous?.generation === generation ? previous : { generation, count: 0, emitted: false };
|
|
23
|
+
if (streak !== previous) this.streaks.set(task, streak);
|
|
24
|
+
if (streak.emitted) return undefined;
|
|
25
|
+
if (streak.count < Number.MAX_SAFE_INTEGER) streak.count++;
|
|
26
|
+
if (streak.count < limit) return undefined;
|
|
27
|
+
streak.emitted = true;
|
|
28
|
+
return streak.count;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
reset(task: number): void { this.streaks.delete(task); }
|
|
32
|
+
|
|
33
|
+
retain(activeTaskIds: readonly number[]): void {
|
|
34
|
+
const active = new Set(activeTaskIds);
|
|
35
|
+
for (const task of this.streaks.keys()) if (!active.has(task)) this.reset(task);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut"]);
|
|
41
|
+
/** A read does not prove progress; failed/pending mutations are handled conservatively by the host. */
|
|
42
|
+
export function ownerToolEffect(op: string, input: Record<string, unknown> | undefined): boolean {
|
|
43
|
+
return OWNER_TOOL_EFFECTS.has(op) || op === "hub_task_accept" && !!input?.plan && typeof input.plan === "object" && Object.keys(input.plan).length > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Canonical snapshots cannot disprove work in the owner's separately recorded checkout/PR. */
|
|
47
|
+
export function canonicalWriteUnknown(task: Pick<Task, "refs" | "plan" | "sdlc">): boolean {
|
|
48
|
+
return !!(task.refs.branch || task.refs.commit || task.sdlc?.head || task.sdlc?.localHead || task.sdlc?.pr)
|
|
49
|
+
|| [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].some(path => /(?:^|[\\/])\.worktrees(?:[\\/]|$)/i.test(path));
|
|
50
|
+
}
|
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). */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
|
|
2
2
|
import { lstatSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
|
|
@@ -8,13 +8,15 @@ export type PermissionMode = typeof PERMISSION_MODES[number];
|
|
|
8
8
|
export { KIMI_MODE_IDS } from "../adapters/definitions/kimi-native.ts";
|
|
9
9
|
export { QWEN_MODE_IDS } from "../adapters/definitions/qwen-native.ts";
|
|
10
10
|
export const isPermissionMode = (value: unknown): value is PermissionMode => typeof value === "string" && (PERMISSION_MODES as readonly string[]).includes(value);
|
|
11
|
-
export function permissionDefaults(value: unknown, instances?: KindMap): Record<string, PermissionMode> {
|
|
11
|
+
export function permissionDefaults(value: unknown, instances?: KindMap, catalog?: ProjectAdapterCatalog): Record<string, PermissionMode> {
|
|
12
12
|
if (value === undefined) return {};
|
|
13
13
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("permission_modes must be an object with ask, ask-when-needed or never-ask values");
|
|
14
14
|
const result: Record<string, PermissionMode> = {};
|
|
15
15
|
for (const [peer, mode] of Object.entries(value)) {
|
|
16
|
-
const definition = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
|
|
16
|
+
const definition = typeof peer === "string" ? (catalog?.definitionOf ?? definitionOf)(peer, instances) : undefined;
|
|
17
17
|
if (!definition) throw new Error(`permission_modes.${peer}: unsupported peer`);
|
|
18
|
+
if (instances && Object.hasOwn(instances, peer) && (instances[peer] as { readOnly?: boolean })?.readOnly) throw new Error(`permission_modes.${peer}: read-only ceiling is locked`);
|
|
19
|
+
if (definition.permission.genericAcp && (!definition.permission.genericAcp.modes || typeof mode !== "string" || !Object.hasOwn(definition.permission.genericAcp.modes, mode))) throw new Error(`permission_modes.${peer}: native mode mapping is unavailable`);
|
|
18
20
|
if (definition.permission.unmanaged) throw new Error(`${definition.displayName} native policy is unmanaged; no hub permission_modes.${peer} mapping exists`);
|
|
19
21
|
if (!isPermissionMode(mode)) throw new Error(`permission_modes.${peer} must be ask, ask-when-needed or never-ask`);
|
|
20
22
|
result[peer] = mode;
|
|
@@ -22,16 +24,16 @@ export function permissionDefaults(value: unknown, instances?: KindMap): Record<
|
|
|
22
24
|
return result;
|
|
23
25
|
}
|
|
24
26
|
/** Approval frequency does not create a common sandbox across native peers. */
|
|
25
|
-
export function permissionBoundary(peer: string, instances
|
|
26
|
-
const definition = definitionOf(peer, instances);
|
|
27
|
+
export function permissionBoundary(peer: string, instances: KindMap | undefined, catalog: ProjectAdapterCatalog): string {
|
|
28
|
+
const definition = catalog.definitionOf(peer, instances);
|
|
27
29
|
if (definition?.permission.unmanaged) return `${peer}: native policy unmanaged; NO hub sandbox; approvals belong to its native tools`;
|
|
28
30
|
if (definition?.trust.hubSandboxed) return `${peer}: inside hub sandbox, path guard and denylist`;
|
|
29
31
|
if (definition?.transport === "claude-channel" || definition?.transport === "codex-appserver") return `${peer}: native vendor bounds; no hub sandbox`;
|
|
30
32
|
return `${peer}: runs its own tools; NO hub sandbox`;
|
|
31
33
|
}
|
|
32
34
|
/** What a mode lets a peer do without asking, in one line a person reads before choosing it (#269). */
|
|
33
|
-
export function permissionGrant(peer: string, mode: PermissionMode, instances?: KindMap): string {
|
|
34
|
-
const grant = definitionOf(peer, instances)?.permission.grants?.[mode];
|
|
35
|
+
export function permissionGrant(peer: string, mode: PermissionMode, instances?: KindMap, catalog?: ProjectAdapterCatalog): string {
|
|
36
|
+
const grant = (catalog?.definitionOf ?? definitionOf)(peer, instances)?.permission.grants?.[mode];
|
|
35
37
|
if (grant !== undefined) return grant;
|
|
36
38
|
if (mode === "ask") return "every write, edit and shell command asks";
|
|
37
39
|
return mode === "never-ask" ? "every tool runs without asking, inside the hub sandbox and path guard" : "write and edit inside the project run without asking; shell and git writes still ask";
|
|
@@ -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,14 +1,41 @@
|
|
|
1
|
-
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
2
|
-
import {
|
|
1
|
+
import { definitionOf as builtinDefinitionOf, type ProjectAdapterCatalog, type KindMap } from "../adapters/registry.ts";
|
|
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 {
|
|
30
|
+
const definitionOf = catalog?.definitionOf ?? builtinDefinitionOf;
|
|
7
31
|
if (value === undefined) return {};
|
|
8
32
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("roster must be an object of boolean peer values");
|
|
9
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
|
+
};
|
|
10
37
|
for (const [peer, on] of Object.entries(value)) {
|
|
11
|
-
if (!
|
|
38
|
+
if (!inertUnknown || !/^[a-z][a-z0-9-]{0,31}$/.test(peer)) requireKnownPeer(peer);
|
|
12
39
|
if (typeof on !== "boolean") throw new Error(`roster.${peer} must be a boolean`);
|
|
13
40
|
roster[peer] = on;
|
|
14
41
|
}
|
|
@@ -16,8 +43,10 @@ export function peerRosterConfig(value: unknown, instances?: KindMap): PeerRoste
|
|
|
16
43
|
}
|
|
17
44
|
|
|
18
45
|
/** External ids keep their existing admission rules; Pi's legacy switch is only its default. */
|
|
19
|
-
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances
|
|
46
|
+
export function rosterAllows(roster: PeerRoster, peer: string, piEnabled: boolean, instances: KindMap | undefined, catalog: ProjectAdapterCatalog, inertUnknown = false): boolean {
|
|
47
|
+
const definitionOf = catalog.definitionOf;
|
|
48
|
+
const defaultRosterEnabled = (id: string, enabled: boolean) => !builtinDefinitionOf(id) ? catalog.definitionOf(id)?.defaultOnRoster ?? false : builtinDefaultRosterEnabled(id, enabled);
|
|
20
49
|
const definition = definitionOf(peer, instances);
|
|
21
|
-
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.
|
|
22
51
|
return roster[peer] ?? (definition.kind === peer ? defaultRosterEnabled(peer, piEnabled) : true);
|
|
23
52
|
}
|
package/src/hub/routing.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { selectReviewerPool, type ReviewerPoolSelection } from "./reviewer-pool.ts";
|
|
2
|
-
import { ADAPTERS, definitionOf, kindOf, familyOf } from "../adapters/registry.ts";
|
|
2
|
+
import { ADAPTERS, definitionOf, kindOf, familyOf, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
3
3
|
import { readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
@@ -115,6 +115,7 @@ export const PI: PeerId = "pi";
|
|
|
115
115
|
|
|
116
116
|
/** Startup-bound project data, never inferred from a suffix or read from configuration here. */
|
|
117
117
|
export interface InstanceRoutingOptions {
|
|
118
|
+
adapterCatalog?: ProjectAdapterCatalog;
|
|
118
119
|
instances?: Readonly<Record<string, { kind: string }>>;
|
|
119
120
|
wip?: Record<string, number>;
|
|
120
121
|
load?: Record<string, number>;
|
|
@@ -133,8 +134,8 @@ function siblingOrder(states: Readonly<Record<string, PeerState>>, opts: Instanc
|
|
|
133
134
|
function expandClassPeers(peers: readonly PeerId[], states: Readonly<Record<string, PeerState>>, opts: InstanceRoutingOptions): PeerId[] {
|
|
134
135
|
if (!hasInstances(opts)) return [...peers];
|
|
135
136
|
return [...new Set(peers.flatMap(peer => {
|
|
136
|
-
const kind = kindOf(peer, opts.instances);
|
|
137
|
-
return kind && kind === peer ? [peer, ...Object.keys(opts.instances!).filter(id => kindOf(id, opts.instances) === kind)].sort(siblingOrder(states, opts)) : [peer];
|
|
137
|
+
const kind = (opts.adapterCatalog?.kindOf ?? kindOf)(peer, opts.instances);
|
|
138
|
+
return kind && kind === peer ? [peer, ...Object.keys(opts.instances!).filter(id => (opts.adapterCatalog?.kindOf ?? kindOf)(id, opts.instances) === kind)].sort(siblingOrder(states, opts)) : [peer];
|
|
138
139
|
}))];
|
|
139
140
|
}
|
|
140
141
|
/** Quota/adaptive policy retains kind slots; sibling workload chooses only within that kind's slots. */
|
|
@@ -142,7 +143,7 @@ function orderSiblingSlots(peers: PeerId[], states: Readonly<Record<string, Peer
|
|
|
142
143
|
if (!hasInstances(opts)) return peers;
|
|
143
144
|
const groups = new Map<string, number[]>(), out = [...peers];
|
|
144
145
|
for (const [index, peer] of peers.entries()) {
|
|
145
|
-
const kind = kindOf(peer, opts.instances);
|
|
146
|
+
const kind = (opts.adapterCatalog?.kindOf ?? kindOf)(peer, opts.instances);
|
|
146
147
|
if (kind) { const slots = groups.get(kind) ?? []; slots.push(index); groups.set(kind, slots); }
|
|
147
148
|
}
|
|
148
149
|
for (const slots of groups.values()) {
|
|
@@ -154,11 +155,11 @@ function orderSiblingSlots(peers: PeerId[], states: Readonly<Record<string, Peer
|
|
|
154
155
|
|
|
155
156
|
/** The quota/roster relay keeps its native preference order, then the class's original tail. */
|
|
156
157
|
export function relayCandidates(classPeers: readonly PeerId[], pii: boolean, opts: InstanceRoutingOptions & { states?: Readonly<Record<string, PeerState>> } = {}): PeerId[] {
|
|
157
|
-
const preferred = ADAPTERS.filter(adapter => adapter.routing?.handoffPriority !== undefined)
|
|
158
|
+
const preferred = (opts.adapterCatalog?.definitions ?? ADAPTERS).filter(adapter => adapter.routing?.handoffPriority !== undefined)
|
|
158
159
|
.sort((a, b) => a.routing!.handoffPriority! - b.routing!.handoffPriority!);
|
|
159
160
|
const candidates = [
|
|
160
161
|
...preferred.filter(adapter => !pii || adapter.trust.onPrem).map(adapter => adapter.kind),
|
|
161
|
-
...classPeers.filter(peer => definitionOf(peer, opts.instances)?.routing?.handoffPriority === undefined),
|
|
162
|
+
...classPeers.filter(peer => (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.handoffPriority === undefined),
|
|
162
163
|
].filter((peer): peer is PeerId => !!peer);
|
|
163
164
|
return expandClassPeers(candidates, opts.states ?? {}, opts);
|
|
164
165
|
}
|
|
@@ -378,10 +379,10 @@ export function assign(
|
|
|
378
379
|
const refusals = new Map<string, RoutingRefusal>();
|
|
379
380
|
const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
|
|
380
381
|
const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
|
|
381
|
-
const ceiling = definitionOf(peer, opts.instances)?.routing?.allowedRoles;
|
|
382
|
+
const ceiling = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.allowedRoles;
|
|
382
383
|
const requiredRole = role === "reviewer" || task.class === "review" ? "reviewer" : task.class === "plan" ? "planner" : "implementer";
|
|
383
384
|
if (ceiling && !ceiling.includes(requiredRole)) return deny("policy", "adapter role ceiling excludes this assignment");
|
|
384
|
-
const unavailable = role === "reviewer" ? definitionOf(peer, opts.instances)?.reviewer?.unavailable : undefined;
|
|
385
|
+
const unavailable = role === "reviewer" ? (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.reviewer?.unavailable : undefined;
|
|
385
386
|
if (unavailable) return deny("policy", unavailable);
|
|
386
387
|
if (role === "owner" && opts.capabilities && !pii) {
|
|
387
388
|
const caps = peerEntry(opts.capabilities, peer);
|
|
@@ -394,7 +395,7 @@ export function assign(
|
|
|
394
395
|
if (!Object.hasOwn(states, peer)) return deny("not-attached", "not attached");
|
|
395
396
|
if (opts.exclude?.includes(peer)) return deny("excluded", "excluded (declined or replaced)");
|
|
396
397
|
if (role === "owner" && opts.excludeOwners?.includes(peer)) return deny("owner-admission", "owner admission: WIP or delivery hold");
|
|
397
|
-
const adapter = definitionOf(peer, opts.instances);
|
|
398
|
+
const adapter = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances);
|
|
398
399
|
if (role === "owner" && adapter?.routing?.reviewOnly) return deny("owner-admission", "owner admission: a review-only instance owns no task");
|
|
399
400
|
if (role === "owner" && needsTests && adapter && adapter.kind !== peer) {
|
|
400
401
|
if (!peerEntry(opts.roles, peer)?.includes("implementer")) return deny("owner-admission", "owner admission: declared write owner requires the current implementer role");
|
|
@@ -404,8 +405,8 @@ export function assign(
|
|
|
404
405
|
if (states[peer] === "paused" && routing.constraints.budget_paused === "skip_peer") return deny("paused", "paused");
|
|
405
406
|
const failing = peerEntry(opts.failing, peer);
|
|
406
407
|
if (failing) return deny("failing", `failing: ${failing}`);
|
|
407
|
-
if (pii && definitionOf(peer, opts.instances)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
|
|
408
|
-
const nativeRouting = definitionOf(peer, opts.instances)?.routing;
|
|
408
|
+
if (pii && (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.trust.onPrem !== true) return deny("pii", "pii: on-prem peers only");
|
|
409
|
+
const nativeRouting = (opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing;
|
|
409
410
|
if (nativeRouting?.localAllowedRefusal && policy?.local_allowed === false) return deny("policy", nativeRouting.localAllowedRefusal);
|
|
410
411
|
if (nativeRouting?.longContext === "backend-limit" && task.signals.includes("long_context")) {
|
|
411
412
|
const backend = policy?.pi_backend ?? "dgx";
|
|
@@ -462,7 +463,7 @@ export function assign(
|
|
|
462
463
|
}
|
|
463
464
|
const ranked = sortSiblings ? orderSiblingSlots(rank(ok, role, not), states, opts) : rank(ok, role, not);
|
|
464
465
|
// Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
|
|
465
|
-
const localTier = ranked.filter((p) => definitionOf(p, opts.instances)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
|
|
466
|
+
const localTier = ranked.filter((p) => (opts.adapterCatalog?.definitionOf ?? definitionOf)(p, opts.instances)?.routing?.preferredTier === true && !(role === "owner" && peerEntry(opts.demoted, p)));
|
|
466
467
|
if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
|
|
467
468
|
return ranked.find((p) => states[p] === "idle") ?? ranked[0];
|
|
468
469
|
};
|
|
@@ -492,13 +493,13 @@ export function assign(
|
|
|
492
493
|
trace.push(opts.namedReviewer === undefined ? ` reviewer candidates: [classes.review] peers ${classPeers.join(", ") || "none"}${rolePeers.length ? `; reviewer role ${rolePeers.join(", ")}` : ""}` : ` reviewer candidate explicitly named: ${opts.namedReviewer}`);
|
|
493
494
|
const reviewOwner = owner ?? opts.notReviewer;
|
|
494
495
|
const automatic = [...new Set([...expandClassPeers(classPeers, states, opts), ...rolePeers])];
|
|
495
|
-
const ownerFamily = instanceMode && reviewOwner ? familyOf(reviewOwner, opts.instances) : undefined;
|
|
496
|
-
const sameFamily = (peer: PeerId) => ownerFamily !== undefined && familyOf(peer, opts.instances) === ownerFamily;
|
|
496
|
+
const ownerFamily = instanceMode && reviewOwner ? (opts.adapterCatalog?.familyOf ?? familyOf)(reviewOwner, opts.instances) : undefined;
|
|
497
|
+
const sameFamily = (peer: PeerId) => ownerFamily !== undefined && (opts.adapterCatalog?.familyOf ?? familyOf)(peer, opts.instances) === ownerFamily;
|
|
497
498
|
const independent = ownerFamily === undefined ? [] : automatic.filter(peer => peer !== reviewOwner && !blocked(peer, "reviewer") && !sameFamily(peer));
|
|
498
499
|
const named = opts.namedReviewer;
|
|
499
500
|
const declaredPool = opts.reviewPool && named === undefined ? Object.keys(opts.instances ?? {}).flatMap(id => {
|
|
500
|
-
const roles = peerEntry(opts.roles, id), definition = definitionOf(id, opts.instances);
|
|
501
|
-
const kind = kindOf(id, opts.instances), family = familyOf(id, opts.instances);
|
|
501
|
+
const roles = peerEntry(opts.roles, id), definition = (opts.adapterCatalog?.definitionOf ?? definitionOf)(id, opts.instances);
|
|
502
|
+
const kind = (opts.adapterCatalog?.kindOf ?? kindOf)(id, opts.instances), family = (opts.adapterCatalog?.familyOf ?? familyOf)(id, opts.instances);
|
|
502
503
|
if (roles?.length !== 1 || roles[0] !== "reviewer" || !definition?.routing?.reviewOnly || !kind || !family) return [];
|
|
503
504
|
const reason = id === reviewOwner ? "is the owner" : definition.reviewer?.unavailable ?? blocked(id, "reviewer")
|
|
504
505
|
?? (states[id] === "paused" ? "paused" : undefined)
|
|
@@ -509,7 +510,7 @@ export function assign(
|
|
|
509
510
|
if (declaredPool.length || opts.reviewPool?.required && named === undefined) {
|
|
510
511
|
for (const candidate of declaredPool) if (!opts.reviewPool!.pool.includes(candidate.kind)) trace.push(` reviewer pool candidate ${candidate.id} (${candidate.family}): unavailable skip: ${candidate.blocked ?? "kind outside configured pool"}`);
|
|
511
512
|
const last = task.history?.findLast(entry => entry.event === "changes_requested");
|
|
512
|
-
const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? familyOf(last.by, opts.instances) : undefined;
|
|
513
|
+
const continuityFamily = last && "reviewerFamily" in last && typeof last.reviewerFamily === "string" ? last.reviewerFamily : last ? (opts.adapterCatalog?.familyOf ?? familyOf)(last.by, opts.instances) : undefined;
|
|
513
514
|
const pr = task.sdlc?.pr;
|
|
514
515
|
const seed = typeof pr === "number" && Number.isSafeInteger(pr) && pr > 0 ? pr : task.id ?? 0;
|
|
515
516
|
reviewSelection = selectReviewerPool({ pool: opts.reviewPool!.pool, candidates: declaredPool, seed, implementerFamily: ownerFamily, continuityFamily, rerotate: opts.reviewPool!.rerotate, failed: opts.reviewPool!.failed });
|
|
@@ -518,7 +519,7 @@ export function assign(
|
|
|
518
519
|
if (reviewSelection.blocked) {
|
|
519
520
|
trace.push("blocked: no reviewer can run");
|
|
520
521
|
// Optional defaults retain the legacy planner escape when no fresh reviewer can run.
|
|
521
|
-
const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !definitionOf(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
|
|
522
|
+
const planners = opts.reviewPool?.required ? [] : automatic.filter(peer => peer !== reviewOwner && peerEntry(opts.roles, peer)?.includes("planner") && !(opts.adapterCatalog?.definitionOf ?? definitionOf)(peer, opts.instances)?.routing?.reviewOnly && !blocked(peer, "reviewer"));
|
|
522
523
|
const otherFamily = planners.filter(peer => !sameFamily(peer));
|
|
523
524
|
const planner = pick(otherFamily.length ? otherFamily : planners, "reviewer", reviewOwner);
|
|
524
525
|
if (planner) {
|
|
@@ -555,8 +556,8 @@ export function assign(
|
|
|
555
556
|
const route = effectiveLocalRoute(routing.local.route, policy?.route, policy?.fixed_model);
|
|
556
557
|
const fixedModel = policy?.fixed_model ?? routing.local.fixed_model;
|
|
557
558
|
const piBackend = policy?.pi_backend;
|
|
558
|
-
if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
559
|
-
if (owner && definitionOf(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${definitionOf(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
559
|
+
if (owner && (opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)?.routing?.trace === "route") trace.push(`route: ${route ?? "(none)"}, fixed_model ${fixedModel}`);
|
|
560
|
+
if (owner && (opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)?.routing?.trace === "backend") trace.push(`${(opts.adapterCatalog?.definitionOf ?? definitionOf)(owner, opts.instances)!.routing!.backendLabel} decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
560
561
|
return { ...(reviewSelection ? { reviewSelection } : {}), ...(reviewBlocked ? { reviewBlocked: true as const } : {}), ...(sameFamilyFallback ? { sameFamilyFallback: true as const } : {}), ...(plannerFallback ? { plannerFallback: true as const } : {}), ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), ...(unreserved ? { unreserved } : {}), ...(refusals.size ? { refusals: [...refusals.values()] } : {}), trace };
|
|
561
562
|
}
|
|
562
563
|
|
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 };
|