@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
|
@@ -20,7 +20,7 @@ export function operationLines(op: RecoveryOperation, runner: number | "unknown"
|
|
|
20
20
|
const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
|
|
21
21
|
const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
|
|
22
22
|
for (const p of op.projects) {
|
|
23
|
-
const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
|
|
23
|
+
const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value, { authority: "historical" })}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
|
|
24
24
|
lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
|
|
25
25
|
}
|
|
26
26
|
// The error ends with the commands status would print; the menu below offers them instead.
|
|
@@ -76,7 +76,7 @@ export function renderOperation(op: RecoveryOperation, runner: number | "unknown
|
|
|
76
76
|
if (key.startsWith("restored:")) {
|
|
77
77
|
let outcome = "recorded outcome unavailable";
|
|
78
78
|
try {
|
|
79
|
-
const state = restoreReceiptStatus(value);
|
|
79
|
+
const state = restoreReceiptStatus(value, { authority: "historical" }); // display only: a revoked declaration must not hide the receipt (#225)
|
|
80
80
|
outcome = state === "done" ? "session restored" : state === "failed" ? "session restoration needs attention" : state.startsWith("waiting:") ? "waiting for the recorded session to attach" : "session restoration is pending";
|
|
81
81
|
} catch { /* Malformed historical metadata is unknown, never successful. */ }
|
|
82
82
|
output.push(...lines(` ${key.slice("restored:".length)}: ${outcome}`, "muted", options));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
1
|
+
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
2
2
|
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
3
3
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
4
4
|
|
|
@@ -66,8 +66,9 @@ export function decodeRestoreReceipt(value: unknown, options: { authority?: "cur
|
|
|
66
66
|
return { state: "done", binding: terminalBinding(value, historical) };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
/** The authority is the caller's: a decision reads the current one, a screen that only shows what was recorded reads the historical one (#225). */
|
|
70
|
+
export function restoreReceiptStatus(value: unknown, options: { authority?: "current" | "historical" } = {}): RestoreReceiptStatus {
|
|
71
|
+
const receipt = decodeRestoreReceipt(value, options);
|
|
71
72
|
return !receipt ? "unstarted" : receipt.state === "waiting" ? `waiting:${receipt.origin}` : receipt.state;
|
|
72
73
|
}
|
|
73
74
|
export function canChooseFresh(value: unknown): boolean {
|
|
@@ -102,7 +103,8 @@ export function restoreTransition(receipt: RestoreReceipt, evidence: RestoreEvid
|
|
|
102
103
|
/** Checkable compact documentation, enumerated through the same pure decision function. */
|
|
103
104
|
export function renderRestoreTransitionTable(): string {
|
|
104
105
|
const launch = { packageEntrypoint: "table", command: "table", argv: [], env: {} };
|
|
105
|
-
const
|
|
106
|
+
const examplePeer = ADAPTERS.find(definition => definition.transport === "codex-appserver")!.kind;
|
|
107
|
+
const binding: TerminalBinding = { peer: examplePeer, handle: "selected", incarnationId: "selected", worktreeId: "selected", projectRoot: "table", sessionId: "planned", launch, launchMetadata: launch };
|
|
106
108
|
const receipts: RestoreReceipt[] = [{ state: "pending" }, { state: "waiting", origin: "pending", binding }, { state: "waiting", origin: "failed", binding }, { state: "done", binding }, { state: "failed" }];
|
|
107
109
|
const launchers: RestoreEvidence["launcher"][] = ["live", "gone", "unknown", "no-record-yet", "replaced", "older-instance"];
|
|
108
110
|
const result = (receipt: RestoreReceipt, evidence: RestoreEvidence, unchanged = false) => {
|
|
@@ -35,7 +35,7 @@ export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
|
|
|
35
35
|
const orcaExecutable = () => process.env.ORCA_CLI_COMMAND || (process.env.ORCA_DEV_REPO_ROOT ? "orca-dev" : process.platform === "linux" && !process.env.ORCA_TERMINAL_HANDLE ? "orca-ide" : "orca");
|
|
36
36
|
|
|
37
37
|
/** Definitions are resolved against the owning project's current trusted declarations, never a global instance map. */
|
|
38
|
-
const projectDefinition = (peer: string, projectRoot: string) => definitionOf(peer)
|
|
38
|
+
const projectDefinition = (peer: string, projectRoot: string) => { const builtin = definitionOf(peer); if (builtin) return builtin; const bound = readInstanceConfig(projectRoot); return bound.catalog.definitionOf(peer, bound.instances); };
|
|
39
39
|
|
|
40
40
|
const sessionState = (binding: TerminalBinding): "found" | "missing" | "unknown" => {
|
|
41
41
|
const state = projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptExists?.(binding);
|
|
@@ -144,7 +144,15 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
144
144
|
|
|
145
145
|
/** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
|
|
146
146
|
export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
|
|
147
|
-
const
|
|
147
|
+
const bound = readInstanceConfig(projectRoot);
|
|
148
|
+
if (bound.adapter_modules?.length && !(Array.isArray(features) && features.includes("external_adapter_modules"))) return "source lacks external_adapter_modules; restart with a supporting source before recovering module peers";
|
|
149
|
+
const configured = Object.keys(bound.adapters ?? {});
|
|
150
|
+
const configuredIds = [...configured, ...Object.entries(bound.instances ?? {}).filter(([, entry]) => configured.includes(entry.kind)).map(([id]) => id)];
|
|
151
|
+
if (configuredIds.length && !(Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("configured_acp"))) {
|
|
152
|
+
const rows = observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers) ? Object.entries(observedPeers) : undefined;
|
|
153
|
+
if (!rows || !rows.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") || rows.some(([id, peer]) => configuredIds.includes(id) && (peer as { state: string }).state !== "offline")) return "source lacks configured_acp; configured native identities require a supporting source before recovery";
|
|
154
|
+
}
|
|
155
|
+
const declared = Object.keys(bound.instances ?? {});
|
|
148
156
|
if (!declared.length) return undefined;
|
|
149
157
|
if (Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("peer_instances")) return undefined;
|
|
150
158
|
if (observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers)) {
|
|
@@ -152,7 +160,7 @@ export function instanceRecoverySourceRefusal(projectRoot: string, features: unk
|
|
|
152
160
|
if (peers.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") &&
|
|
153
161
|
!peers.some(([id, peer]) => declared.includes(id) && (peer as { state: string }).state !== "offline")) return undefined;
|
|
154
162
|
}
|
|
155
|
-
return "source lacks peer_instances support
|
|
163
|
+
return "source lacks peer_instances support and a declared instance of this project may be live; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end the extra native sessions first (a source whose declared instances are all offline proceeds without further steps); only if one cannot be ended, temporarily remove its declaration, upgrade the source, then declare it again; legacy peer identities were not reinterpreted";
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
|
|
@@ -178,10 +186,16 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
178
186
|
}
|
|
179
187
|
// A dead pid or a missing project directory is final whatever protocol its manifest names: probing it would read
|
|
180
188
|
// "unavailable" forever and wedge abort, resume and stop-and-archive (#215).
|
|
181
|
-
if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported))
|
|
182
|
-
|
|
189
|
+
if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) {
|
|
190
|
+
let blockers: string[];
|
|
191
|
+
if (base.state === "stopped") blockers = snapshotError ? [snapshotError.message] : [];
|
|
192
|
+
else if (base.state === "incompatible") blockers = ["manual-bootstrap-required: source lacks the recovery contract; use its matching CLI"];
|
|
193
|
+
else blockers = [base.error ?? base.state];
|
|
194
|
+
return {
|
|
195
|
+
state: base.state, peers: [], blockers,
|
|
183
196
|
...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}), ...(snapshotError ? { snapshotError } : {}),
|
|
184
|
-
|
|
197
|
+
};
|
|
198
|
+
}
|
|
185
199
|
let status = base.status;
|
|
186
200
|
if (!status && control && sourceProtocol === PROTOCOL) return {
|
|
187
201
|
state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
|
|
@@ -613,6 +627,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
613
627
|
return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
|
|
614
628
|
}
|
|
615
629
|
if (cached && op.targetFeatures !== undefined) {
|
|
630
|
+
if (!op.targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
|
|
631
|
+
if (!op.targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
|
|
616
632
|
if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
617
633
|
return { ...target, targetFeatures: [...op.targetFeatures] };
|
|
618
634
|
}
|
|
@@ -624,6 +640,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
624
640
|
if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
|
|
625
641
|
if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
|
|
626
642
|
const targetFeatures: readonly string[] = [...metadata.features];
|
|
643
|
+
if (!targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
|
|
644
|
+
if (!targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
|
|
627
645
|
if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
628
646
|
if (!targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
629
647
|
const verified = { ...target, targetFeatures };
|
|
@@ -674,7 +692,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
674
692
|
// #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
|
|
675
693
|
const transcript = projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
|
|
676
694
|
if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
|
|
677
|
-
if (transcript === "missing" && !planned.freshStart?.includes(
|
|
695
|
+
if (transcript === "missing" && !planned.freshStart?.includes(binding.peer)) {
|
|
678
696
|
throw new Error(`${unresumable(binding)}; no terminal was closed. To continue without its conversation, end that Codex session and close its Orca terminal ${binding.handle} first, then resume`);
|
|
679
697
|
}
|
|
680
698
|
const idle = await waitForIdle(binding, 600_000, terminalOptions(run));
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nativeRestoreGroup } from "../adapters/definitions/native-identity-defaults.ts";
|
|
1
2
|
import { definitionOf } from "../adapters/registry.ts";
|
|
2
3
|
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
3
4
|
import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
|
|
@@ -18,7 +19,7 @@ import { RecoveryPresentationError, type RecoveryPresentationCause, type Recover
|
|
|
18
19
|
const projectDefinition = (peer: string, root?: string) => {
|
|
19
20
|
const builtin = definitionOf(peer);
|
|
20
21
|
if (builtin || !root) return builtin;
|
|
21
|
-
try { return definitionOf(peer,
|
|
22
|
+
try { const bound = readInstanceConfig(root); return bound.catalog.definitionOf(peer, bound.instances); } catch { return undefined; }
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
|
|
@@ -894,7 +895,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
894
895
|
};
|
|
895
896
|
await target();
|
|
896
897
|
attempted = true;
|
|
897
|
-
await driver.restore(planned, progress, op, projectDefinition(native, planned.project.root)?.transport
|
|
898
|
+
await driver.restore(planned, progress, op, nativeRestoreGroup(projectDefinition(native, planned.project.root)?.transport), save, native);
|
|
898
899
|
// A done receipt is written by the runtime only after authenticated attachment and exact settlement.
|
|
899
900
|
// Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
|
|
900
901
|
const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
|
package/src/hub/board.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { planFingerprint } from "./plan-conformance.ts";
|
|
1
2
|
import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
2
3
|
import type { GitHubSource } from "./github-import.ts";
|
|
3
4
|
import { Database } from "bun:sqlite";
|
|
@@ -6,6 +7,7 @@ import { HUB, type PeerId } from "./envelope.ts";
|
|
|
6
7
|
import type { SweepRecord } from "./task-sweep.ts";
|
|
7
8
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
8
9
|
import type { SdlcReceipt } from "./sdlc.ts";
|
|
10
|
+
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
9
11
|
import type { CostPlan, ReviewCost } from "./cost-governance.ts";
|
|
10
12
|
|
|
11
13
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
@@ -15,9 +17,9 @@ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "
|
|
|
15
17
|
export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
|
|
16
18
|
export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
|
|
17
19
|
export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
|
|
18
|
-
export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable"] as const;
|
|
20
|
+
export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes"] as const;
|
|
19
21
|
export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
|
|
20
|
-
export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency"; peer?: string; dependency?: number; code: TaskStallCode; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
|
|
22
|
+
export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency" | "no-writes"; peer?: string; dependency?: number; code: TaskStallCode; turns?: number; acceptance?: number; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
|
|
21
23
|
|
|
22
24
|
export interface TaskRefs {
|
|
23
25
|
repo?: string;
|
|
@@ -28,7 +30,7 @@ export interface TaskRefs {
|
|
|
28
30
|
/** What an owner says it will change, before it starts (issue #31). Each list holds short free-text items. */
|
|
29
31
|
export const PLAN_KEYS = ["paths", "symbols", "signatures", "insertion_points"] as const;
|
|
30
32
|
export const DOCUMENT_PLAN_KEYS = ["paths", "sections", "sources", "claims"] as const;
|
|
31
|
-
export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims"] as const;
|
|
33
|
+
export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims", "proof", "risks"] as const;
|
|
32
34
|
export type TaskPlan = Partial<Record<(typeof ALL_PLAN_KEYS)[number], string[]>> & CostPlan;
|
|
33
35
|
/** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
|
|
34
36
|
export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
|
|
@@ -55,10 +57,19 @@ export interface HistoryEntry {
|
|
|
55
57
|
sweep?: SweepRecord;
|
|
56
58
|
/** The PII screen did not clear this entry's note (#198): only the console reads it. */
|
|
57
59
|
withheld?: true;
|
|
60
|
+
/** Host evidence at done, never inferred from the owner's summary. */
|
|
61
|
+
noWrites?: true;
|
|
58
62
|
/** Host-recorded expected cost and exact review head, never narrative. */
|
|
59
63
|
reviewCost?: ReviewCost;
|
|
60
64
|
/** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
|
|
61
65
|
reviewContext?: "fresh" | "continuing";
|
|
66
|
+
/** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
|
|
67
|
+
reviewFindings?: ReviewFindingSet;
|
|
68
|
+
/** Host-authored plan content revision, separate from backlog planningRevision. */
|
|
69
|
+
planRevision?: { revision: number; fingerprint: string };
|
|
70
|
+
planObservation?: { repo: string; pr: number; head: string; revision: number; fingerprint: string };
|
|
71
|
+
planDepartureInvalidated?: true;
|
|
72
|
+
planDeparture?: { generation: string; scope?: string; repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] };
|
|
62
73
|
reviewerFamily?: string;
|
|
63
74
|
reviewerReason?: ReviewerPoolReason;
|
|
64
75
|
reviewBlocked?: true;
|
|
@@ -151,6 +162,8 @@ export class Board {
|
|
|
151
162
|
private pendingChanges: { task: Task; entry: HistoryEntry }[] | undefined;
|
|
152
163
|
/** Every recorded change, as it lands (telemetry, issue #40). */
|
|
153
164
|
onChange?: (task: Task, entry: HistoryEntry) => void;
|
|
165
|
+
/** Host lifecycle policy, installed by Tasks; off projects keep their original history shape. */
|
|
166
|
+
trackPlanRevisions?: () => boolean;
|
|
154
167
|
private changed(task: Task, entry: HistoryEntry): void {
|
|
155
168
|
if (this.pendingChanges) { this.pendingChanges.push({ task, entry }); return; }
|
|
156
169
|
try { this.onChange?.(task, entry); } catch { /* never throw after a board write */ }
|
|
@@ -230,7 +243,7 @@ export class Board {
|
|
|
230
243
|
|
|
231
244
|
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; profile?: import("./profiles.ts").ProfileName; profileClass?: string; customProfile?: true; role?: string; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[]; backlog?: boolean; external?: GitHubSource }): Task {
|
|
232
245
|
const now = Date.now();
|
|
233
|
-
const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed" }];
|
|
246
|
+
const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed", ...(this.trackPlanRevisions?.() && t.plan && Object.keys(t.plan).length ? { planRevision: { revision: 1, fingerprint: planFingerprint(t.plan) } } : {}) }];
|
|
234
247
|
const { lastInsertRowid } = this.taskWrite(() => this.db
|
|
235
248
|
.query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated, project_profile, task_role, external_repo, external_issue) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
236
249
|
.run(t.title, t.detail ?? "", t.class, t.backlog ? "backlog" : "proposed", JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now, t.profile && (t.profile !== "dev" || t.customProfile || t.profileClass !== undefined && t.profileClass !== t.class) ? JSON.stringify({ profile: t.profile, profileClass: t.profileClass ?? t.class }) : null, t.role ?? null, t.external?.repo.toLowerCase() ?? null, t.external?.issue ?? null));
|
|
@@ -287,10 +300,11 @@ export class Board {
|
|
|
287
300
|
const value = JSON.parse(row.payload) as TaskStall, cause = value.cause;
|
|
288
301
|
const peer = (id: unknown) => typeof id === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(id);
|
|
289
302
|
if (Object.keys(value).some(key => !["task", "signature", "replanRevision", "cause", "at"].includes(key)) || !Number.isSafeInteger(value.task) || value.task < 1 || !Number.isSafeInteger(value.replanRevision) || value.replanRevision < 0 || !Number.isFinite(value.at)
|
|
290
|
-
|| !cause || !["reservation", "no-eligible-peer", "cancelled-dependency"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
|
|
303
|
+
|| !cause || !["reservation", "no-eligible-peer", "cancelled-dependency", "no-writes"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
|
|
304
|
+
|| cause.kind === "no-writes" && (cause.code !== "no-writes" || !peer(cause.peer) || !Number.isSafeInteger(cause.turns) || cause.turns! < 1 || !Number.isSafeInteger(cause.acceptance) || cause.acceptance! < 0)
|
|
291
305
|
|| cause.peer !== undefined && !peer(cause.peer) || cause.dependency !== undefined && (!Number.isSafeInteger(cause.dependency) || cause.dependency < 1)
|
|
292
306
|
|| cause.refusals !== undefined && (!Array.isArray(cause.refusals) || cause.refusals.length > 64 || cause.refusals.some(row => Object.keys(row).some(key => !["peer", "code"].includes(key)) || !peer(row.peer) || !TASK_STALL_CODES.includes(row.code)))
|
|
293
|
-
|| Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals"].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
|
|
307
|
+
|| Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals", ...(cause.kind === "no-writes" ? ["turns", "acceptance"] : [])].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
|
|
294
308
|
return [value];
|
|
295
309
|
} catch { return []; }
|
|
296
310
|
});
|
|
@@ -304,7 +318,7 @@ export class Board {
|
|
|
304
318
|
|
|
305
319
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
306
320
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
307
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
|
|
321
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
|
|
308
322
|
const task = this.get(id);
|
|
309
323
|
if (!task) throw new Error(`no task #${id}`);
|
|
310
324
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -320,7 +334,22 @@ export class Board {
|
|
|
320
334
|
}
|
|
321
335
|
if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
|
|
322
336
|
const reviewMetadata = reviewerHistory(extra);
|
|
323
|
-
|
|
337
|
+
let reviewFindings: ReviewFindingSet | undefined;
|
|
338
|
+
if (extra.reviewFindings !== undefined) {
|
|
339
|
+
if (!["approved", "changes_requested"].includes(event) || patch.state !== event) throw new Error("finding metadata requires a saved review verdict");
|
|
340
|
+
const head = extra.reviewFindings.head;
|
|
341
|
+
if (head !== undefined && !/^[a-f0-9]{40}$/.test(head)) throw new Error("finding metadata requires a full reviewed head");
|
|
342
|
+
reviewFindings = { ...(head ? { head } : {}), items: parseReviewFindings(extra.reviewFindings.items, { person: by === "user", maxNits: 64 }) };
|
|
343
|
+
}
|
|
344
|
+
if (extra.planDeparture && (by !== "user" || event !== "plan departure accepted")) throw new Error("plan departure acceptance is person-only");
|
|
345
|
+
const trackPlan = this.trackPlanRevisions?.() === true, hasDeparture = task.history.some(h => h.planDeparture);
|
|
346
|
+
const fingerprint = patch.plan && (trackPlan || hasDeparture) ? planFingerprint(next.plan ?? {}) : undefined;
|
|
347
|
+
const planChanged = fingerprint !== undefined && fingerprint !== planFingerprint(task.plan ?? {});
|
|
348
|
+
const planRevision = trackPlan && planChanged ? { revision: (task.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0) + 1, fingerprint: fingerprint! } : undefined;
|
|
349
|
+
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
350
|
+
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
351
|
+
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
352
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
324
353
|
this.taskWrite(() => this.db
|
|
325
354
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
326
355
|
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
|
package/src/hub/budget.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type AdapterDefinition } from "../adapters/registry.ts";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import type { PeerId } from "./envelope.ts";
|
|
4
4
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
@@ -13,6 +13,10 @@ export interface UsageWindow {
|
|
|
13
13
|
windowMins?: number;
|
|
14
14
|
source: string;
|
|
15
15
|
}
|
|
16
|
+
/** No declared source is unknown, not a synthetic empty provider reading. */
|
|
17
|
+
export function readNativeQuota(definition: AdapterDefinition | undefined, value: unknown): UsageWindow[] | undefined {
|
|
18
|
+
return definition?.budget?.reader?.read(value);
|
|
19
|
+
}
|
|
16
20
|
interface Reading extends UsageWindow {
|
|
17
21
|
at: number;
|
|
18
22
|
}
|
|
@@ -46,6 +50,8 @@ export interface BudgetConfig {
|
|
|
46
50
|
export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
|
|
47
51
|
|
|
48
52
|
export interface BudgetHooks {
|
|
53
|
+
/** Startup-bound project resolver; undefined keeps builtin-only compatibility. */
|
|
54
|
+
definitionOf?(peer: PeerId): AdapterDefinition | undefined;
|
|
49
55
|
/** Stable/idempotent for this Budget lifetime. All lifecycle hooks receive this canonical account id. */
|
|
50
56
|
account?(peer: PeerId): PeerId;
|
|
51
57
|
pause(peer: PeerId): void;
|
|
@@ -202,7 +208,8 @@ export class Budget {
|
|
|
202
208
|
// Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
|
|
203
209
|
const reporter = peer;
|
|
204
210
|
peer = this.account(peer);
|
|
205
|
-
const
|
|
211
|
+
const resolve = this.hooks.definitionOf ?? definitionOf;
|
|
212
|
+
const adapter = resolve(reporter) ?? resolve(peer);
|
|
206
213
|
if (reporter === "user" || reporter === "hub" || peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
|
|
207
214
|
// An override ignores soft readings, not a provider that has actually refused another turn.
|
|
208
215
|
if (hard && this.ignoreUntil.delete(peer)) {
|
package/src/hub/bus.ts
CHANGED
|
@@ -322,7 +322,7 @@ export class Bus {
|
|
|
322
322
|
try {
|
|
323
323
|
this.deferredHub.delete(env.id);
|
|
324
324
|
this.publishNow(env);
|
|
325
|
-
} catch
|
|
325
|
+
} catch {
|
|
326
326
|
// Only publication state is transactional. A live peer's preface, pauses, attempts and
|
|
327
327
|
// recovery mode are independent state, not something a failed checkpoint may reload.
|
|
328
328
|
this.deferredHub.clear(); for (const [id, pending] of beforeDeferred) this.deferredHub.set(id, pending);
|
|
@@ -673,7 +673,11 @@ export class Bus {
|
|
|
673
673
|
this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
|
|
674
674
|
}
|
|
675
675
|
|
|
676
|
-
/**
|
|
676
|
+
/**
|
|
677
|
+
* Reset only: audit every deferred recipient without publishing or releasing work. The guard below is what it
|
|
678
|
+
* checks: no attached peers, no flush or replay in progress and a readable journal. It cannot see a running
|
|
679
|
+
* daemon, so the caller (`resetRuntime`) must hold the stopped-hub proof; a bare Bus has none.
|
|
680
|
+
*/
|
|
677
681
|
discardDeferredHub(reason: string): string[] {
|
|
678
682
|
if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
|
|
679
683
|
const pending = [...this.deferredHub];
|
package/src/hub/claude-state.ts
CHANGED
|
@@ -1,37 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
/** The peer a Claude process writes for: its AGENTHUB_PEER_ID when well formed, otherwise the builtin claude peer. */
|
|
5
|
-
export function claudePeer(value: string | undefined): string {
|
|
6
|
-
return value !== undefined && PEER.test(value) ? value : "claude";
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** Per-peer Claude state file names. The builtin claude peer keeps its original names, so nothing migrates. */
|
|
10
|
-
export function claudeStateFile(base: "claude-launch" | "claude-session" | "claude-context", peer: string): string {
|
|
11
|
-
// Instance ids are always <kind>-<name>; a builtin id has no hyphen.
|
|
12
|
-
return peer.includes("-") && PEER.test(peer) ? `${base}.${peer}.json` : `${base}.json`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** A Claude peer's own row in terminal-recovery.json for this daemon, never a sibling instance's (row order is arbitrary). */
|
|
16
|
-
export function ownTerminalRow(records: unknown, peer: string, projectRoot: string, instanceId: string): { launchId?: unknown } | undefined {
|
|
17
|
-
if (!Array.isArray(records)) return undefined;
|
|
18
|
-
return records.find((row) => row?.peer === peer && row?.projectRoot === projectRoot && row?.instanceId === instanceId);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Whether a Claude session's transcript exists: the path the peer's own status line reported for that session, else
|
|
23
|
-
* the builtin's default location. An instance may run under its own CLAUDE_CONFIG_DIR, which the hub cannot see:
|
|
24
|
-
* without its reported path the answer is unknown, and unknown counts as persisted (it never waives session identity).
|
|
25
|
-
*/
|
|
26
|
-
export function transcriptPersisted(reported: { sessionId?: string; transcriptPath?: string }, sessionId: string, instance: boolean, fallbackPath: string, exists: (path: string) => boolean | "unknown"): boolean {
|
|
27
|
-
// Only a positive "missing" (ENOENT) is not persisted; an unreadable path is unknown and keeps the identity strict.
|
|
28
|
-
const found = (path: string) => exists(path) !== false;
|
|
29
|
-
if (reported.sessionId === sessionId && reported.transcriptPath) return found(reported.transcriptPath);
|
|
30
|
-
return instance || found(fallbackPath);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** lstat evidence: true when present, false only on ENOENT/ENOTDIR, "unknown" for any other failure (EACCES included). */
|
|
34
|
-
export function pathEvidence(path: string, lstat: (path: string) => unknown): boolean | "unknown" {
|
|
35
|
-
try { lstat(path); return true; }
|
|
36
|
-
catch (error) { const code = (error as { code?: string }).code; return code === "ENOENT" || code === "ENOTDIR" ? false : "unknown"; }
|
|
37
|
-
}
|
|
1
|
+
// Stable leaf import for released statusline/CLI callers. Native policy belongs to the adapter.
|
|
2
|
+
export { claudePeer, claudeStateFile, ownTerminalRow, transcriptPersisted, pathEvidence } from "../adapters/definitions/native-channel-state.ts";
|
package/src/hub/conductor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import type { Task } from "./board.ts";
|
|
4
4
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
@@ -10,6 +10,7 @@ import type { HubEvent } from "./events.ts";
|
|
|
10
10
|
import type { SupervisionFeed } from "./supervision.ts";
|
|
11
11
|
import type { Budget } from "./budget.ts";
|
|
12
12
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
13
|
+
import { supportsHeadlessInstances } from "./instances.ts";
|
|
13
14
|
|
|
14
15
|
/** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
|
|
15
16
|
export function conductorProgressSink(record: (event: HubEvent) => void, feed: Pick<SupervisionFeed, "milestone">): (event: HubEvent) => void {
|
|
@@ -109,14 +110,14 @@ export type ConductorStart = { peer: string; mode: "headless" } | { peer: "claud
|
|
|
109
110
|
* What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
|
|
110
111
|
* only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
|
|
111
112
|
*/
|
|
112
|
-
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap): ConductorStart {
|
|
113
|
+
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): ConductorStart {
|
|
113
114
|
const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
|
|
114
115
|
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
|
|
115
|
-
const adapter = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
|
|
116
|
+
const adapter = typeof peer === "string" ? (adapterCatalog?.definitionOf ?? definitionOf)(peer, instances) : undefined;
|
|
116
117
|
if (typeof peer !== "string" || !adapter?.start.conductorStartable) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
|
|
117
118
|
const selected = peer;
|
|
118
119
|
const extra = adapter.kind !== selected;
|
|
119
|
-
if (extra &&
|
|
120
|
+
if (extra && !supportsHeadlessInstances(adapter)) throw refuse(`${peer}: this native transport does not support extra headless instances`);
|
|
120
121
|
const own = startMode(selected);
|
|
121
122
|
if (extra && own !== "headless") throw refuse(`${peer}: extra instances require headless mode in I2`);
|
|
122
123
|
if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
|
|
@@ -156,6 +157,7 @@ export interface ConductEvent {
|
|
|
156
157
|
kind: "conduct"; actor: string; action: string; task?: number; peer?: string;
|
|
157
158
|
}
|
|
158
159
|
export interface ConductorHooks {
|
|
160
|
+
adapterCatalog?: ProjectAdapterCatalog;
|
|
159
161
|
roles(): unknown;
|
|
160
162
|
capabilities(): Record<string, unknown>;
|
|
161
163
|
status(): ConductorStatusInput;
|
|
@@ -236,8 +238,8 @@ export class Conductor {
|
|
|
236
238
|
}
|
|
237
239
|
if (tool === "hub_peer_start") {
|
|
238
240
|
const instances = this.hooks.instances?.();
|
|
239
|
-
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances);
|
|
240
|
-
if (definitionOf(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
|
|
241
|
+
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances, this.hooks.adapterCatalog);
|
|
242
|
+
if ((this.hooks.adapterCatalog?.definitionOf ?? definitionOf)(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
|
|
241
243
|
const result = await this.hooks.start(start.peer);
|
|
242
244
|
const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
|
|
243
245
|
const already = result && typeof result === "object" && (result as { already?: unknown }).already === true ? { already: true } : {};
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { adapterModuleDeclarations } from "../adapters/external-modules.ts";
|
|
2
|
+
import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
|
|
1
3
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
2
4
|
import type { HubConfig } from "./daemon.ts";
|
|
3
5
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
4
|
-
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
6
|
+
import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
5
7
|
import { CLASSES } from "./board.ts";
|
|
6
8
|
import { permissionDefaults } from "./permission-mode.ts";
|
|
7
9
|
import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
|
|
@@ -10,12 +12,18 @@ import { resolveProfile } from "./profiles.ts";
|
|
|
10
12
|
import { taskSweepConfig } from "./task-sweep.ts";
|
|
11
13
|
import { projectionConfig } from "./text-projection.ts";
|
|
12
14
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
13
|
-
import { peerRosterConfig } from "./roster.ts";
|
|
15
|
+
import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
|
|
14
16
|
import { CHARACTER_IDS } from "../pet/characters.ts";
|
|
15
17
|
import { petPalette } from "../pet/config.ts";
|
|
16
18
|
|
|
17
19
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
18
20
|
export interface ApplyRule { applies: ApplyClass; reason?: string }
|
|
21
|
+
export interface ConfigIdentityScope { catalog: ProjectAdapterCatalog; instances: InstancesConfig }
|
|
22
|
+
/** Plain captured metadata stays cloneable with ConfigSources; native callbacks are compiled outside it. */
|
|
23
|
+
export interface ConfigIdentitySnapshot { adapters?: HubConfig["adapters"]; externalAdapters?: readonly ExternalAdapterMetadata[]; instances: InstancesConfig }
|
|
24
|
+
export function configIdentityScope(snapshot: ConfigIdentitySnapshot): ConfigIdentityScope {
|
|
25
|
+
return { catalog: createProjectAdapterCatalog(snapshot.adapters, externalMetadataDefinitions(snapshot.externalAdapters)), instances: snapshot.instances };
|
|
26
|
+
}
|
|
19
27
|
export interface ConfigChange extends ApplyRule { key: string; before: unknown; after: unknown }
|
|
20
28
|
export interface ConfigLayer { name: "config.json" | "config.local.json"; raw: Record<string, unknown> }
|
|
21
29
|
export interface ReloadGuard { key: string; path: readonly string[]; risk: "safe" | "raises"; floor?: string | boolean | number; store: "runtime" | "config" | "routing" }
|
|
@@ -128,7 +136,8 @@ const schema: Record<string, Check> = {
|
|
|
128
136
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
129
137
|
watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
|
|
130
138
|
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
131
|
-
|
|
139
|
+
adapter_modules: v => { adapterModuleDeclarations(v); },
|
|
140
|
+
adapters: v => { createProjectAdapterCatalog(v); }, instances: v => { validateInstances(v, createProjectAdapterCatalog()); }, pet: validatePetConfig,
|
|
132
141
|
batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
|
|
133
142
|
memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
|
|
134
143
|
short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
|
|
@@ -145,7 +154,7 @@ const schema: Record<string, Check> = {
|
|
|
145
154
|
mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
|
|
146
155
|
contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
|
|
147
156
|
approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
|
|
148
|
-
task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
|
|
157
|
+
task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, no_writes_turns: number(0, Number.MAX_SAFE_INTEGER, true), interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
|
|
149
158
|
checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
150
159
|
if (value === undefined) continue;
|
|
151
160
|
if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
|
|
@@ -160,24 +169,29 @@ const schema: Record<string, Check> = {
|
|
|
160
169
|
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
161
170
|
};
|
|
162
171
|
export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
|
|
163
|
-
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
|
|
172
|
+
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog, inertModuleRoster = false): asserts raw is Record<string, unknown> {
|
|
164
173
|
const value = object(raw, "config");
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
block({ ...schema,
|
|
168
|
-
|
|
174
|
+
const catalog = value.adapters !== undefined ? createProjectAdapterCatalog(value.adapters, context?.externalDefinitions) : context ?? createProjectAdapterCatalog();
|
|
175
|
+
const instances = validateInstances(value.instances ?? declarations, catalog);
|
|
176
|
+
block({ ...schema,
|
|
177
|
+
instances: v => { validateInstances(v, catalog); },
|
|
178
|
+
permission_modes: v => { permissionDefaults(v, instances, catalog); },
|
|
179
|
+
roster: v => { peerRosterConfig(v, instances, catalog, inertModuleRoster); },
|
|
180
|
+
peers: v => { peerStartConfig(v, instances, catalog); },
|
|
169
181
|
})(raw, "config");
|
|
170
182
|
}
|
|
171
|
-
export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
|
|
183
|
+
export function validateEffectiveConfig(value: unknown, catalog?: ProjectAdapterCatalog): asserts value is HubConfig {
|
|
172
184
|
const raw = { ...object(value, "config") };
|
|
173
185
|
delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
|
|
174
|
-
validateRawConfig(raw);
|
|
186
|
+
validateRawConfig(raw, undefined, catalog);
|
|
175
187
|
const profile = resolveProfile(raw.profile, raw.profile_overrides);
|
|
176
188
|
for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
|
|
177
189
|
for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
|
|
178
190
|
}
|
|
179
191
|
|
|
180
192
|
const restart: Record<string, string> = {
|
|
193
|
+
adapter_modules: "External adapter modules are verified and imported only at daemon startup",
|
|
194
|
+
adapters: "Configured adapter identity, commands and capability policy are fixed at daemon startup",
|
|
181
195
|
instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
|
|
182
196
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
|
|
183
197
|
memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
|
|
@@ -190,21 +204,21 @@ const restart: Record<string, string> = {
|
|
|
190
204
|
const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
|
|
191
205
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
192
206
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
193
|
-
export function configApply(key: string): ApplyRule {
|
|
207
|
+
export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
|
|
194
208
|
const top = key.split(".")[0]!;
|
|
195
209
|
if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
|
|
196
210
|
if (top !== "routing" && top !== "permission" && key.includes(".")) {
|
|
197
211
|
const special: Record<string, readonly string[]> = {
|
|
198
|
-
task_sweep: ["enabled", "auto_reassign", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
212
|
+
task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
199
213
|
checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
|
|
200
214
|
};
|
|
201
215
|
let check = schema[top];
|
|
202
216
|
for (const [index, part] of key.split(".").slice(1).entries()) {
|
|
203
217
|
const shape = check && shapes.get(check);
|
|
204
218
|
if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
|
|
205
|
-
else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
|
|
219
|
+
else if (shape?.item) { if (shape.keys && !shape.keys.includes(part) && !(top === "roster" && scopes.some(scope => scope.catalog.definitionOf(part, scope.instances)))) return fail(key, "unknown configuration key"); check = shape.item; }
|
|
206
220
|
else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
|
|
207
|
-
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances"].includes(top)) break;
|
|
221
|
+
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances", "adapters", "adapter_modules"].includes(top)) break;
|
|
208
222
|
else return fail(key, "unknown configuration key");
|
|
209
223
|
}
|
|
210
224
|
}
|
|
@@ -219,13 +233,19 @@ export function configApply(key: string): ApplyRule {
|
|
|
219
233
|
const canonical = (v: unknown): unknown => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])])) : v;
|
|
220
234
|
const same = (a: unknown, b: unknown): boolean => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
221
235
|
const at = (raw: unknown, path: readonly string[]): unknown => path.reduce<unknown>((v, k) => v && typeof v === "object" && Object.hasOwn(v, k) ? (v as Record<string, unknown>)[k] : undefined, raw);
|
|
222
|
-
export function configChanges(running: HubConfig, next: HubConfig): ConfigChange[] {
|
|
236
|
+
export function configChanges(running: HubConfig, next: HubConfig, scopes: readonly ConfigIdentityScope[]): ConfigChange[] {
|
|
223
237
|
const changes: ConfigChange[] = [];
|
|
238
|
+
// Scopes are captured/validated by the host. Their union classifies removed/added keys only; it cannot
|
|
239
|
+
// authorize a declaration or turn a restart-retained hybrid into a fresh startup configuration.
|
|
224
240
|
const walk = (before: unknown, after: unknown, key: string) => {
|
|
225
241
|
if (same(before, after)) return;
|
|
226
242
|
if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
|
|
227
243
|
for (const child of new Set([...Object.keys(before), ...Object.keys(after)])) walk(at(before, [child]), at(after, [child]), key ? `${key}.${child}` : child);
|
|
228
|
-
} else
|
|
244
|
+
} else {
|
|
245
|
+
// #225: deleting a roster key whose instance was undeclared is live and needs no identity; adding or setting one stays an unknown key.
|
|
246
|
+
const stale = after === undefined && /^roster\.[^.]+$/.test(key) && scopes.some(scope => staleInstanceKey(key.slice("roster.".length), scope.catalog));
|
|
247
|
+
changes.push({ key, before, after, ...(stale ? { applies: "live" as const } : configApply(key, scopes)) });
|
|
248
|
+
}
|
|
229
249
|
};
|
|
230
250
|
const clean = (v: HubConfig) => Object.fromEntries(Object.entries(v).filter(([key]) => !["ignored", "retired", "permission_default_sources"].includes(key)));
|
|
231
251
|
walk(clean(running), clean(next), ""); return changes.sort((a, b) => a.key.localeCompare(b.key));
|