gentle-pi 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +340 -138
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +485 -7
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +645 -21
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +147 -3
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +70 -10
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -15,7 +15,7 @@ export interface NotificationServiceDependencies {
|
|
|
15
15
|
policy?: NotificationOptions;
|
|
16
16
|
read?: () => NotificationResolution;
|
|
17
17
|
write?: (settings: NotificationSettings) => string;
|
|
18
|
-
backend?: Pick<NotificationPlayer, "play" | "availability" | "capabilities"
|
|
18
|
+
backend?: Pick<NotificationPlayer, "play" | "availability" | "capabilities"> & { dispose?: () => void };
|
|
19
19
|
setTimeout?: SchedulerOptions["setTimeout"];
|
|
20
20
|
clearTimeout?: SchedulerOptions["clearTimeout"];
|
|
21
21
|
}
|
|
@@ -175,6 +175,10 @@ export function claimNotificationOwner(retireRuntime: () => void, deps: Notifica
|
|
|
175
175
|
const retire = () => {
|
|
176
176
|
if (disposed) return;
|
|
177
177
|
shutdown(); disposed = true; retireRuntime();
|
|
178
|
+
// End of life for the claim also ends the audio backend: the persistent
|
|
179
|
+
// serve worker (if one was spawned) is killed here; a re-bind through
|
|
180
|
+
// attach() keeps it, and an abandoned claim self-retires idle after 60 s.
|
|
181
|
+
backend.dispose?.();
|
|
178
182
|
if (shared.ownerLease === lease) { shared.ownerLease = undefined; shared.retire = undefined; }
|
|
179
183
|
};
|
|
180
184
|
shared.retire = retire;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { readCatalog, type CatalogPage } from "./orchestrator-catalog.ts";
|
|
2
2
|
import type { PublishedWork } from "./orchestrator-work.ts";
|
|
3
3
|
import type { PresenceRecord } from "./agents-session-transport.ts";
|
|
4
|
-
import { activationHash, listPresence, readDiscovery, sessionHash, type DiscoveryMetadata } from "./orchestrator-presence.ts";
|
|
4
|
+
import { activationHash, discoveryAbsent, listPresence, readDiscovery, sessionHash, type DiscoveryMetadata } from "./orchestrator-presence.ts";
|
|
5
5
|
|
|
6
6
|
export interface OrchestratorCandidate {
|
|
7
7
|
sessionId: string;
|
|
8
8
|
reachability: "unknown";
|
|
9
9
|
freshness: "unknown" | "recent" | "stale";
|
|
10
10
|
label?: string;
|
|
11
|
+
/** Display-only label of a live peer whose code predates discovery publication:
|
|
12
|
+
* its schema-1 header heartbeats, but it never writes a discovery sidecar. */
|
|
13
|
+
legacyLabel?: string;
|
|
11
14
|
aliases?: DiscoveryMetadata["aliases"];
|
|
12
15
|
workspace?: string;
|
|
13
16
|
tasks?: DiscoveryMetadata["tasks"];
|
|
@@ -36,6 +39,7 @@ export function discoverOrchestrators(profile: string, peers: readonly PresenceR
|
|
|
36
39
|
if (matches.length !== 1) return unknown;
|
|
37
40
|
const header = matches[0];
|
|
38
41
|
const metadata = readDiscovery(profile, header);
|
|
42
|
+
if (!metadata && header.recent && discoveryAbsent(profile, header)) return { ...unknown, legacyLabel: header.label };
|
|
39
43
|
if (metadata?.activation !== activationHash(activations[0])) return unknown;
|
|
40
44
|
if (!header.recent) return { ...unknown, freshness: "stale" };
|
|
41
45
|
const catalog = readCatalog(profile, header, metadata.activation, selection?.recipientSessionId === sessionId ? selection.cursor : undefined);
|
|
@@ -171,18 +171,25 @@ function reason(error: unknown) {
|
|
|
171
171
|
return code === "ENOENT" ? "missing" : code ? "io-error" : error instanceof SyntaxError ? "malformed" : (error as Error).message;
|
|
172
172
|
}
|
|
173
173
|
function atomicWrite(root: string, name: string, bytes: string) {
|
|
174
|
-
|
|
174
|
+
// Ancestor validation is the heartbeat's cached responsibility; the destination
|
|
175
|
+
// identity check remains per-write so a replaced or linked file is never overwritten.
|
|
175
176
|
const destination = join(root, name);
|
|
176
177
|
try { regular(fs.lstatSync(destination)); }
|
|
177
178
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
178
179
|
const temp = join(root, `.${name}.${randomUUID()}.tmp`);
|
|
179
180
|
const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
181
|
+
let identity: { dev: number; ino: number };
|
|
182
|
+
try {
|
|
183
|
+
fs.writeFileSync(fd, bytes);
|
|
184
|
+
const stat = fs.fstatSync(fd);
|
|
185
|
+
identity = { dev: stat.dev, ino: stat.ino };
|
|
186
|
+
} finally { fs.closeSync(fd); }
|
|
180
187
|
try {
|
|
181
|
-
try { fs.writeFileSync(fd, bytes); } finally { fs.closeSync(fd); }
|
|
182
188
|
fs.renameSync(temp, destination);
|
|
183
189
|
} finally {
|
|
184
190
|
try { fs.unlinkSync(temp); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
185
191
|
}
|
|
192
|
+
return identity;
|
|
186
193
|
}
|
|
187
194
|
|
|
188
195
|
export interface PresencePage {
|
|
@@ -263,6 +270,15 @@ export function readActivity(profile: string, selection: Header): { activity?: A
|
|
|
263
270
|
} catch (error) { return { unavailable: reason(error) }; }
|
|
264
271
|
}
|
|
265
272
|
|
|
273
|
+
/** True only when no discovery sidecar exists for this header: the publisher
|
|
274
|
+
* predates discovery publication. A present but unreadable sidecar is not absent. */
|
|
275
|
+
export function discoveryAbsent(profile: string, h: Header): boolean {
|
|
276
|
+
try {
|
|
277
|
+
fs.lstatSync(join(rootFor(profile), filename(h, "discovery")));
|
|
278
|
+
return false;
|
|
279
|
+
} catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
|
|
280
|
+
}
|
|
281
|
+
|
|
266
282
|
/** Optional derived sidecar; failures never invalidate the schema-1 header. */
|
|
267
283
|
export function readDiscovery(profile: string, h: Header): DiscoveryMetadata | undefined {
|
|
268
284
|
try {
|
|
@@ -292,6 +308,11 @@ export class PresencePublisher {
|
|
|
292
308
|
private heartbeat?: ReturnType<typeof setInterval>;
|
|
293
309
|
private disposed = false;
|
|
294
310
|
private owned = new Map<string, { dev: number; ino: number }>();
|
|
311
|
+
/** Root paths whose ancestor chain was validated for this session identity.
|
|
312
|
+
* Seeded by the one synchronous rootFor() in start(); refreshed asynchronously
|
|
313
|
+
* by the heartbeat so writes never probe directories on the caller's thread. */
|
|
314
|
+
private readonly validRoots = new Set<string>();
|
|
315
|
+
private validating = false;
|
|
295
316
|
/** Timer I/O failures stop publication; consumers still apply the recent TTL. */
|
|
296
317
|
error?: string;
|
|
297
318
|
|
|
@@ -304,10 +325,10 @@ export class PresencePublisher {
|
|
|
304
325
|
static start(options: { profile: string; sessionId: string; label: string; labelSource?: () => string; activity: readonly ActivityInput[] }) {
|
|
305
326
|
const publisher = new PresencePublisher(options);
|
|
306
327
|
try {
|
|
307
|
-
rootFor(options.profile, true);
|
|
328
|
+
publisher.validRoots.add(rootFor(options.profile, true));
|
|
308
329
|
publisher.pending = JSON.stringify(projectActivity(options.activity));
|
|
309
330
|
publisher.flush();
|
|
310
|
-
publisher.heartbeat = setInterval(() => publisher.
|
|
331
|
+
publisher.heartbeat = setInterval(() => publisher.pulse(), 5000);
|
|
311
332
|
publisher.heartbeat.unref();
|
|
312
333
|
return publisher;
|
|
313
334
|
} catch (error) { publisher.dispose(); throw error; }
|
|
@@ -375,12 +396,68 @@ export class PresencePublisher {
|
|
|
375
396
|
if (this.disposed) return;
|
|
376
397
|
try { action(); } catch (error) { this.error = reason(error); this.dispose(); }
|
|
377
398
|
}
|
|
399
|
+
private rootPath(kind: "header" | "activity" | "discovery" | "catalog") {
|
|
400
|
+
return join(resolve(this.profile), "gentle-agents", kind === "catalog" ? "catalog" : "presence");
|
|
401
|
+
}
|
|
402
|
+
/** Publishes from the warm cache synchronously, then refreshes the validated
|
|
403
|
+
* ancestor chain asynchronously without ever overlapping itself. */
|
|
404
|
+
private pulse() {
|
|
405
|
+
this.guarded(() => this.publishHeader());
|
|
406
|
+
if (this.validating) return;
|
|
407
|
+
this.validating = true;
|
|
408
|
+
void this.revalidate().finally(() => { this.validating = false; }).catch(() => { /* revalidate never rejects */ });
|
|
409
|
+
}
|
|
410
|
+
/** Async mirror of rootFor(): same ancestor chain, modes, and ownership, so the
|
|
411
|
+
* sync write path stays cache-only. A failed validation only cools the cache. */
|
|
412
|
+
private async revalidate() {
|
|
413
|
+
const absolute = resolve(this.profile);
|
|
414
|
+
const shared = join(absolute, "gentle-agents");
|
|
415
|
+
const chains: { root: string; create: boolean }[] = [{ root: join(shared, "presence"), create: false }];
|
|
416
|
+
if (this.catalog) chains.push({ root: join(shared, "catalog"), create: true });
|
|
417
|
+
for (const { root, create } of chains) {
|
|
418
|
+
try {
|
|
419
|
+
for (let path = absolute;; path = dirname(path)) {
|
|
420
|
+
const stat = await fs.promises.lstat(path);
|
|
421
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe-directory");
|
|
422
|
+
if (dirname(path) === path) break;
|
|
423
|
+
}
|
|
424
|
+
if (create) {
|
|
425
|
+
try { await fs.promises.mkdir(root, { mode: 0o700 }); }
|
|
426
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
427
|
+
}
|
|
428
|
+
await this.checkedDirectory(shared, false);
|
|
429
|
+
await this.checkedDirectory(root, true);
|
|
430
|
+
this.validRoots.add(root);
|
|
431
|
+
} catch { this.validRoots.delete(root); }
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
private async checkedDirectory(path: string, privateMode: boolean) {
|
|
435
|
+
const stat = await fs.promises.lstat(path);
|
|
436
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.platform !== "win32"
|
|
437
|
+
&& ((stat.mode & (privateMode ? 0o077 : 0o022)) !== 0 || stat.uid !== process.getuid?.()))) throw new Error("unsafe-directory");
|
|
438
|
+
}
|
|
378
439
|
private write(kind: "header" | "activity" | "discovery" | "catalog", bytes: string) {
|
|
379
|
-
|
|
440
|
+
// A cold cache validates synchronously exactly once for that path — the same
|
|
441
|
+
// rootFor() validation the old per-write path performed — then caches and
|
|
442
|
+
// writes, so the first publication after a cold start is never deferred to a
|
|
443
|
+
// heartbeat. A failed validation (vanished or unsafe tree) is the only skip
|
|
444
|
+
// path; the heartbeat's async refresh re-warms the cache once the tree returns.
|
|
445
|
+
const root = this.rootPath(kind);
|
|
446
|
+
if (!this.validRoots.has(root)) {
|
|
447
|
+
try { rootFor(this.profile, kind === "catalog", kind === "catalog"); }
|
|
448
|
+
catch { return; }
|
|
449
|
+
this.validRoots.add(root);
|
|
450
|
+
}
|
|
380
451
|
const name = kind === "catalog" ? `${this.target.sessionHash}.${this.target.incarnation}.json` : filename(this.target, kind);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
452
|
+
let identity: { dev: number; ino: number };
|
|
453
|
+
try { identity = atomicWrite(root, name, bytes); }
|
|
454
|
+
catch (error) {
|
|
455
|
+
// A tree that vanished mid-flight is skipped, not fatal; other failures stay fatal.
|
|
456
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
457
|
+
this.validRoots.delete(root);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
this.owned.set(join(root, name), identity);
|
|
384
461
|
}
|
|
385
462
|
private writeDiscovery() {
|
|
386
463
|
if (!this.discovery) return;
|
|
@@ -393,6 +470,7 @@ export class PresencePublisher {
|
|
|
393
470
|
} catch { /* Metadata failure must not withdraw an existing activity peer. */ }
|
|
394
471
|
}
|
|
395
472
|
private publishHeader() {
|
|
473
|
+
if (!this.validRoots.has(this.rootPath("header"))) return;
|
|
396
474
|
const header = { ...this.header, label: label(this.labelSource?.() ?? this.displayLabel), heartbeat: Date.now() };
|
|
397
475
|
if (!validHeader(header)) throw new Error("malformed-header");
|
|
398
476
|
this.write("header", JSON.stringify(header));
|
|
@@ -401,6 +479,7 @@ export class PresencePublisher {
|
|
|
401
479
|
}
|
|
402
480
|
private flush() {
|
|
403
481
|
if (this.pending === this.published) return;
|
|
482
|
+
if (!this.validRoots.has(this.rootPath("activity"))) return;
|
|
404
483
|
const activity: Activity = JSON.parse(this.pending);
|
|
405
484
|
const generation = (this.header?.generation ?? 0) + 1;
|
|
406
485
|
const bytes = JSON.stringify({ schema: 1, ...this.target, generation, activity });
|
|
@@ -41,9 +41,14 @@ export function validRecordedScope(value: any): value is RecordedScope {
|
|
|
41
41
|
/** One bounded derivative snapshot, not a registry or authority. The caller supplies
|
|
42
42
|
* only admitted, unfinished, non-restored owned children and durable registry roots.
|
|
43
43
|
* Membership/cwd changes invalidate even unknown facts; session replacement clears.
|
|
44
|
-
*
|
|
44
|
+
* Recency reorders rebuild caller-ordered snapshots without refreshing visible facts.
|
|
45
|
+
* Resolution stays bounded to the host and first eight tasks/registered roots;
|
|
46
|
+
* newly visible paths are resolved lazily, including after a reorder. */
|
|
45
47
|
export class OrchestratorScopeCache {
|
|
46
48
|
private key = "";
|
|
49
|
+
private membershipKey = "";
|
|
50
|
+
private facts = new Map<string, RepositoryFact>();
|
|
51
|
+
private resolvedAt = 0;
|
|
47
52
|
private snapshot?: RecordedScope;
|
|
48
53
|
private resolver: WorktreeResolver;
|
|
49
54
|
private now: () => number;
|
|
@@ -53,14 +58,34 @@ export class OrchestratorScopeCache {
|
|
|
53
58
|
}
|
|
54
59
|
clear() {
|
|
55
60
|
this.key = "";
|
|
61
|
+
this.membershipKey = "";
|
|
62
|
+
this.facts.clear();
|
|
56
63
|
this.snapshot = undefined;
|
|
57
64
|
}
|
|
65
|
+
/** Snapshot for deferred (render-cadence trailing) publications: it never
|
|
66
|
+
* touches the resolver. A deferred publish reuses the last event-time
|
|
67
|
+
* projection as-is even when membership changed since — its Git resolution
|
|
68
|
+
* must never race a peer metadata query, whose contract adds no caller Git
|
|
69
|
+
* probes. Returns undefined when nothing has been projected yet (or the
|
|
70
|
+
* cache was cleared), in which case the caller omits the scope rather than
|
|
71
|
+
* probing; the next event-time publication refreshes the facts. */
|
|
72
|
+
cachedScope(): RecordedScope | undefined {
|
|
73
|
+
return this.snapshot;
|
|
74
|
+
}
|
|
58
75
|
project(cwd: string, tasks: readonly { id: string; cwd: string }[], roots: readonly string[]): RecordedScope {
|
|
59
76
|
const registered = [...new Set(roots)];
|
|
60
77
|
const key = createHash("sha256").update(JSON.stringify([cwd, tasks.map(t => [t.id, t.cwd]), registered])).digest("hex");
|
|
61
78
|
if (key === this.key && this.snapshot) return this.snapshot;
|
|
62
|
-
const
|
|
63
|
-
|
|
79
|
+
const membershipKey = createHash("sha256").update(JSON.stringify([
|
|
80
|
+
cwd, tasks.map(t => JSON.stringify([t.id, t.cwd])).sort(), [...registered].sort(),
|
|
81
|
+
])).digest("hex");
|
|
82
|
+
if (membershipKey !== this.membershipKey) {
|
|
83
|
+
this.facts.clear();
|
|
84
|
+
this.resolvedAt = this.now();
|
|
85
|
+
this.membershipKey = membershipKey;
|
|
86
|
+
}
|
|
87
|
+
const resolvedAt = this.resolvedAt;
|
|
88
|
+
const facts = this.facts;
|
|
64
89
|
const fact = (path: string) => {
|
|
65
90
|
if (facts.has(path)) return facts.get(path)!;
|
|
66
91
|
const unknown: RepositoryFact = { root: null, cloneHash: null, resolvedAt, source: "recorded-workspace/git" };
|
|
@@ -74,6 +99,9 @@ export class OrchestratorScopeCache {
|
|
|
74
99
|
this.snapshot = { host: fact(cwd), tasks: tasks.slice(0, 8).map(t => ({ id: t.id, repository: fact(t.cwd) })),
|
|
75
100
|
registered: registered.slice(0, 8).map(fact), omittedTasks: Math.max(0, tasks.length - 8),
|
|
76
101
|
omittedRegistered: Math.max(0, registered.length - 8), complete: tasks.length <= 8 && registered.length <= 8 };
|
|
102
|
+
// Keep fact retention bounded even when an oversized task list rotates.
|
|
103
|
+
const visible = new Set([cwd, ...tasks.slice(0, 8).map(t => t.cwd), ...registered.slice(0, 8)]);
|
|
104
|
+
for (const path of facts.keys()) if (!visible.has(path)) facts.delete(path);
|
|
77
105
|
this.key = key;
|
|
78
106
|
return this.snapshot;
|
|
79
107
|
}
|
|
@@ -89,11 +89,19 @@ export class OrchestratorStateCache {
|
|
|
89
89
|
const previous = this.value?.aliases;
|
|
90
90
|
// Null/null marks a new publication with no declaration yet; legacy absence
|
|
91
91
|
// cannot establish historical first-task knowledge.
|
|
92
|
+
// An explicitly published work topic is owner-curated public task
|
|
93
|
+
// classification, so it refreshes the current alias when the caller omits
|
|
94
|
+
// the subject (#2553). State-only refreshes and automatic republications of
|
|
95
|
+
// unchanged state must not re-derive it, and an explicit subject in the
|
|
96
|
+
// same publication always wins.
|
|
97
|
+
const topic = input !== undefined && !declared && state?.work?.topic
|
|
98
|
+
? normalizeTaskSubject(state.work.topic) : undefined;
|
|
92
99
|
let aliases = previous;
|
|
93
|
-
if (declared) {
|
|
100
|
+
if (declared || topic) {
|
|
101
|
+
const current = declared ?? topic;
|
|
94
102
|
let initialAlias = previous?.initialAlias ?? null;
|
|
95
|
-
if (!this.value || previous?.currentAlias === null) initialAlias =
|
|
96
|
-
aliases = { initialAlias, currentAlias:
|
|
103
|
+
if (!this.value || previous?.currentAlias === null) initialAlias = current;
|
|
104
|
+
aliases = { initialAlias, currentAlias: current };
|
|
97
105
|
} else if (!this.value) aliases = { initialAlias: null, currentAlias: null };
|
|
98
106
|
const cwd = input === undefined && this.value ? this.value.cwd : manager.getCwd();
|
|
99
107
|
const record = decodePublishedState({ schema: state?.work ? 2 : 1, sessionId: manager.getSessionId(),
|
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
5
5
|
|
|
6
|
-
export type PermissionMode = "normal" | "auto" | "yolo";
|
|
6
|
+
export type PermissionMode = "normal" | "auto" | "yolo" | "super-yolo";
|
|
7
7
|
export const PERMISSION_MODE_SCHEMA = "gentle-pi.permission-mode/v1";
|
|
8
8
|
export interface PermissionModeOptions { gentlePiConfigHome?: string }
|
|
9
9
|
export interface PermissionModeResolution {
|
|
@@ -13,7 +13,7 @@ export interface PermissionModeResolution {
|
|
|
13
13
|
globalFile: string;
|
|
14
14
|
}
|
|
15
15
|
export function isPermissionMode(value: unknown): value is PermissionMode {
|
|
16
|
-
return value === "normal" || value === "auto" || value === "yolo";
|
|
16
|
+
return value === "normal" || value === "auto" || value === "yolo" || value === "super-yolo";
|
|
17
17
|
}
|
|
18
18
|
/** Never consult merged Pi settings or repository configuration. */
|
|
19
19
|
export function permissionModePolicyFile(options: PermissionModeOptions = {}): string {
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// release bundle (or its extracted tree) and verifies it fail-closed before
|
|
5
5
|
// any layout, role, schema, or capability inside it is trusted:
|
|
6
6
|
//
|
|
7
|
-
// - exact eight
|
|
7
|
+
// - exact inventory: eight base entries (README.md, manifest.json, 3 schemas,
|
|
8
|
+
// 3 vectors) plus one `orchestration/<runtime>.md` (>= 1.2.0) and one
|
|
9
|
+
// `routing/<runtime>.md` (>= 1.4.0) per runtime the manifest lists;
|
|
8
10
|
// - strict manifest decode: single JSON object, no duplicate keys, no unknown fields;
|
|
9
11
|
// - per-file SHA-256 agreement between the manifest and the actual bytes;
|
|
10
12
|
// - pinned transport capability and supported contract major (1);
|
|
@@ -33,6 +35,8 @@ export const SUPPORTED_PROVIDER_CONTRACT_MAJOR = 1;
|
|
|
33
35
|
export const RUNTIMES_REQUIRED_SINCE_MINOR = 1;
|
|
34
36
|
// `orchestration` became part of the manifest in contract 1.2.0.
|
|
35
37
|
export const ORCHESTRATION_REQUIRED_SINCE_MINOR = 2;
|
|
38
|
+
// `routing` became part of the manifest in contract 1.4.0.
|
|
39
|
+
export const ROUTING_REQUIRED_SINCE_MINOR = 4;
|
|
36
40
|
// Exactly the provider roles gentle-pi supports, in the manifest's strict order.
|
|
37
41
|
export const PROVIDER_CONTRACT_ROLE_IDS = ["lens", "refuter", "targeted-validator"] as const;
|
|
38
42
|
// Every mandatory capability a role may declare. An unknown mandatory
|
|
@@ -46,11 +50,11 @@ const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
|
46
50
|
const BUNDLE_ENTRY_COUNT = 8;
|
|
47
51
|
// Generous fixed safety bound on raw archive/tree entries read BEFORE the
|
|
48
52
|
// manifest itself is parsed (the exact expected count, base entries plus one
|
|
49
|
-
// per `orchestration` runtime, is known only after
|
|
50
|
-
// contracts register a handful of runtimes; this bound
|
|
51
|
-
// absurd smuggled inventory, it is not the exact-count check.
|
|
52
|
-
const
|
|
53
|
-
const MAX_RAW_BUNDLE_ENTRIES = BUNDLE_ENTRY_COUNT +
|
|
53
|
+
// per `orchestration` and one per `routing` runtime, is known only after
|
|
54
|
+
// manifest decode). Real contracts register a handful of runtimes; this bound
|
|
55
|
+
// only rules out an absurd smuggled inventory, it is not the exact-count check.
|
|
56
|
+
const MAX_RUNTIME_FILE_ENTRIES = 64;
|
|
57
|
+
const MAX_RAW_BUNDLE_ENTRIES = BUNDLE_ENTRY_COUNT + 2 * MAX_RUNTIME_FILE_ENTRIES;
|
|
54
58
|
// setuid/setgid/sticky, any execute bit, or world-writable content is never
|
|
55
59
|
// acceptable for a data-only contract bundle.
|
|
56
60
|
const FORBIDDEN_MODE_BITS = 0o7113;
|
|
@@ -99,7 +103,13 @@ export interface VerifiedProviderContractBundle {
|
|
|
99
103
|
* 1.2.0, where `orchestration` does not exist in the manifest.
|
|
100
104
|
*/
|
|
101
105
|
readonly orchestration: ReadonlyMap<string, string>;
|
|
102
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Verified ODD implementation-routing block, runtime identity -> UTF-8
|
|
108
|
+
* `routing/<runtime>.md` contents. Empty before contract 1.4.0, where
|
|
109
|
+
* `routing` does not exist in the manifest.
|
|
110
|
+
*/
|
|
111
|
+
readonly routing: ReadonlyMap<string, string>;
|
|
112
|
+
/** Canonical path -> exact verified bytes for all entries (base eight plus one per orchestration and routing runtime). */
|
|
103
113
|
readonly entries: ReadonlyMap<string, Buffer>;
|
|
104
114
|
/** Canonical path -> SHA-256 of the exact verified bytes. */
|
|
105
115
|
readonly entrySha256: ReadonlyMap<string, string>;
|
|
@@ -321,23 +331,65 @@ function parseManifestRole(value: unknown, index: number): ManifestRole {
|
|
|
321
331
|
};
|
|
322
332
|
}
|
|
323
333
|
|
|
324
|
-
|
|
334
|
+
// One entry of a runtime-file inventory: `orchestration` (>= 1.2.0) or
|
|
335
|
+
// `routing` (>= 1.4.0). Both share the shape `{ runtime, file }` with the file
|
|
336
|
+
// at `<inventory>/<runtime>.md`.
|
|
337
|
+
interface ManifestRuntimeFileEntry {
|
|
325
338
|
readonly runtime: string;
|
|
326
339
|
readonly file: ManifestFileReference;
|
|
327
340
|
}
|
|
328
341
|
|
|
329
|
-
|
|
330
|
-
|
|
342
|
+
type RuntimeFileInventory = "orchestration" | "routing";
|
|
343
|
+
|
|
344
|
+
function parseRuntimeFileEntry(inventory: RuntimeFileInventory, value: unknown, index: number): ManifestRuntimeFileEntry {
|
|
345
|
+
const where = `manifest.${inventory}[${index}]`;
|
|
331
346
|
const object = requireObject(value, where);
|
|
332
347
|
requireExactKeys(object, ["runtime", "file"], ["runtime", "file"], where);
|
|
333
348
|
const runtime = requireString(object.runtime, `${where}.runtime`);
|
|
334
349
|
if (!RUNTIME_IDENTITY_PATTERN.test(runtime)) invalid(`${where}.runtime ${JSON.stringify(runtime)} is not a runtime identity`);
|
|
335
350
|
const file = parseFileReference(object.file, `${where}.file`);
|
|
336
|
-
const expectedPath =
|
|
351
|
+
const expectedPath = `${inventory}/${runtime}.md`;
|
|
337
352
|
if (file.path !== expectedPath) invalid(`${where}.file.path must be ${JSON.stringify(expectedPath)}`);
|
|
338
353
|
return { runtime, file };
|
|
339
354
|
}
|
|
340
355
|
|
|
356
|
+
// Decodes one runtime-file inventory: absent before the contract minor that
|
|
357
|
+
// introduced it, required from it on, strictly sorted, every runtime
|
|
358
|
+
// registered in manifest.runtimes, and `pi` always present.
|
|
359
|
+
function parseRuntimeFileInventory(
|
|
360
|
+
object: Record<string, unknown>,
|
|
361
|
+
inventory: RuntimeFileInventory,
|
|
362
|
+
requiredSinceMinor: number,
|
|
363
|
+
contractSemver: string,
|
|
364
|
+
minor: number,
|
|
365
|
+
runtimes: readonly string[] | undefined,
|
|
366
|
+
): readonly ManifestRuntimeFileEntry[] {
|
|
367
|
+
const required = minor >= requiredSinceMinor;
|
|
368
|
+
if (!Object.prototype.hasOwnProperty.call(object, inventory)) {
|
|
369
|
+
if (required) invalid(`manifest.${inventory} is required from contract 1.${requiredSinceMinor}.0 (got ${contractSemver} without it)`);
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
if (!required) {
|
|
373
|
+
invalid(`manifest.${inventory} is not part of contract ${contractSemver}; it exists from 1.${requiredSinceMinor}.0`);
|
|
374
|
+
}
|
|
375
|
+
const value = object[inventory];
|
|
376
|
+
if (!Array.isArray(value) || value.length === 0) invalid(`manifest.${inventory} must be a non-empty array`);
|
|
377
|
+
const entries = value.map((entry, index) => parseRuntimeFileEntry(inventory, entry, index));
|
|
378
|
+
for (let position = 0; position < entries.length; position += 1) {
|
|
379
|
+
const entry = entries[position] as ManifestRuntimeFileEntry;
|
|
380
|
+
if (position > 0 && (entries[position - 1] as ManifestRuntimeFileEntry).runtime >= entry.runtime) {
|
|
381
|
+
invalid(`manifest.${inventory} must be strictly sorted by runtime and unique`);
|
|
382
|
+
}
|
|
383
|
+
if (runtimes === undefined || !runtimes.includes(entry.runtime)) {
|
|
384
|
+
invalid(`manifest.${inventory}[${position}].runtime ${JSON.stringify(entry.runtime)} is not registered in manifest.runtimes`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!entries.some((entry) => entry.runtime === PI_RUNTIME_IDENTITY)) {
|
|
388
|
+
invalid(`manifest.${inventory} does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
389
|
+
}
|
|
390
|
+
return entries;
|
|
391
|
+
}
|
|
392
|
+
|
|
341
393
|
interface ParsedManifest {
|
|
342
394
|
readonly contractSemver: string;
|
|
343
395
|
readonly major: number;
|
|
@@ -347,14 +399,15 @@ interface ParsedManifest {
|
|
|
347
399
|
readonly runtimes: readonly string[] | undefined;
|
|
348
400
|
readonly readme: ManifestFileReference;
|
|
349
401
|
readonly roles: readonly ManifestRole[];
|
|
350
|
-
readonly orchestration: readonly
|
|
402
|
+
readonly orchestration: readonly ManifestRuntimeFileEntry[];
|
|
403
|
+
readonly routing: readonly ManifestRuntimeFileEntry[];
|
|
351
404
|
}
|
|
352
405
|
|
|
353
406
|
function parseManifest(payload: Buffer): ParsedManifest {
|
|
354
407
|
const object = decodeStrictManifestObject(payload);
|
|
355
408
|
requireExactKeys(
|
|
356
409
|
object,
|
|
357
|
-
["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles", "orchestration"],
|
|
410
|
+
["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles", "orchestration", "routing"],
|
|
358
411
|
["schema", "contract_semver", "transport_capability", "readme", "roles"],
|
|
359
412
|
"manifest",
|
|
360
413
|
);
|
|
@@ -398,32 +451,8 @@ function parseManifest(payload: Buffer): ParsedManifest {
|
|
|
398
451
|
invalid(`manifest.runtimes does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
399
452
|
}
|
|
400
453
|
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
if (Object.prototype.hasOwnProperty.call(object, "orchestration")) {
|
|
404
|
-
if (!orchestrationRequired) {
|
|
405
|
-
invalid(`manifest.orchestration is not part of contract ${contractSemver}; it exists from 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0`);
|
|
406
|
-
}
|
|
407
|
-
if (!Array.isArray(object.orchestration) || object.orchestration.length === 0) {
|
|
408
|
-
invalid("manifest.orchestration must be a non-empty array");
|
|
409
|
-
}
|
|
410
|
-
const parsedOrchestration = object.orchestration.map((entry, index) => parseOrchestrationEntry(entry, index));
|
|
411
|
-
for (let position = 0; position < parsedOrchestration.length; position += 1) {
|
|
412
|
-
const entry = parsedOrchestration[position] as ManifestOrchestrationEntry;
|
|
413
|
-
if (position > 0 && (parsedOrchestration[position - 1] as ManifestOrchestrationEntry).runtime >= entry.runtime) {
|
|
414
|
-
invalid("manifest.orchestration must be strictly sorted by runtime and unique");
|
|
415
|
-
}
|
|
416
|
-
if (runtimes === undefined || !runtimes.includes(entry.runtime)) {
|
|
417
|
-
invalid(`manifest.orchestration[${position}].runtime ${JSON.stringify(entry.runtime)} is not registered in manifest.runtimes`);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
if (!parsedOrchestration.some((entry) => entry.runtime === PI_RUNTIME_IDENTITY)) {
|
|
421
|
-
invalid(`manifest.orchestration does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
422
|
-
}
|
|
423
|
-
orchestration = parsedOrchestration;
|
|
424
|
-
} else if (orchestrationRequired) {
|
|
425
|
-
invalid(`manifest.orchestration is required from contract 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0 (got ${contractSemver} without it)`);
|
|
426
|
-
}
|
|
454
|
+
const orchestration = parseRuntimeFileInventory(object, "orchestration", ORCHESTRATION_REQUIRED_SINCE_MINOR, contractSemver, minor, runtimes);
|
|
455
|
+
const routing = parseRuntimeFileInventory(object, "routing", ROUTING_REQUIRED_SINCE_MINOR, contractSemver, minor, runtimes);
|
|
427
456
|
|
|
428
457
|
if (!Array.isArray(object.roles)) invalid("manifest.roles must be an array");
|
|
429
458
|
const roles = object.roles.map((role, index) => parseManifestRole(role, index));
|
|
@@ -446,6 +475,7 @@ function parseManifest(payload: Buffer): ParsedManifest {
|
|
|
446
475
|
readme: parseFileReference(object.readme, "manifest.readme"),
|
|
447
476
|
roles,
|
|
448
477
|
orchestration,
|
|
478
|
+
routing,
|
|
449
479
|
};
|
|
450
480
|
}
|
|
451
481
|
|
|
@@ -541,8 +571,9 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
541
571
|
|
|
542
572
|
// The exact expected inventory size is only known once the manifest is
|
|
543
573
|
// decoded: base entries plus exactly one `orchestration/<runtime>.md` per
|
|
544
|
-
// registered orchestration runtime
|
|
545
|
-
|
|
574
|
+
// registered orchestration runtime and one `routing/<runtime>.md` per
|
|
575
|
+
// registered routing runtime.
|
|
576
|
+
const expectedEntryCount = BUNDLE_ENTRY_COUNT + manifest.orchestration.length + manifest.routing.length;
|
|
546
577
|
if (rawEntries.size !== expectedEntryCount) {
|
|
547
578
|
invalid(`bundle inventory has ${rawEntries.size} files; exactly ${expectedEntryCount} are expected`);
|
|
548
579
|
}
|
|
@@ -554,13 +585,18 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
554
585
|
expected.add(`schemas/${role.id}.schema.json`);
|
|
555
586
|
expected.add(`vectors/${role.id}.json`);
|
|
556
587
|
}
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
588
|
+
const verifyRuntimeFiles = (inventory: RuntimeFileInventory, manifestEntries: readonly ManifestRuntimeFileEntry[]): Map<string, string> => {
|
|
589
|
+
const texts = new Map<string, string>();
|
|
590
|
+
for (const entry of manifestEntries) {
|
|
591
|
+
const path = `${inventory}/${entry.runtime}.md`;
|
|
592
|
+
const payload = verifyFileReference(rawEntries, entry.file, path);
|
|
593
|
+
texts.set(entry.runtime, payload.toString("utf8"));
|
|
594
|
+
expected.add(path);
|
|
595
|
+
}
|
|
596
|
+
return texts;
|
|
597
|
+
};
|
|
598
|
+
const orchestration = verifyRuntimeFiles("orchestration", manifest.orchestration);
|
|
599
|
+
const routing = verifyRuntimeFiles("routing", manifest.routing);
|
|
564
600
|
for (const name of rawEntries.keys()) {
|
|
565
601
|
if (!expected.has(name)) invalid(`bundle has unexpected file ${JSON.stringify(name)}`);
|
|
566
602
|
}
|
|
@@ -591,6 +627,7 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
591
627
|
vectorSha256: role.vector.sha256,
|
|
592
628
|
})),
|
|
593
629
|
orchestration,
|
|
630
|
+
routing,
|
|
594
631
|
entries,
|
|
595
632
|
entrySha256,
|
|
596
633
|
treeSha256: bundleTreeSha256(entrySha256),
|
|
@@ -745,6 +782,18 @@ export const PROVIDER_CAPABILITIES_BASELINE_SCHEMA = "gentle-pi.provider-contrac
|
|
|
745
782
|
export const PROVIDER_ROLES_BASELINE_FILE = "provider-roles.baseline.json";
|
|
746
783
|
export const PROVIDER_CAPABILITIES_BASELINE_FILE = "provider-capabilities.baseline.json";
|
|
747
784
|
|
|
785
|
+
function runtimeFileBaseline(
|
|
786
|
+
bundle: VerifiedProviderContractBundle,
|
|
787
|
+
inventory: RuntimeFileInventory,
|
|
788
|
+
texts: ReadonlyMap<string, string>,
|
|
789
|
+
): { runtime: string; path: string; sha256: string | undefined }[] {
|
|
790
|
+
return [...texts.keys()].sort().map((runtime) => ({
|
|
791
|
+
runtime,
|
|
792
|
+
path: `${inventory}/${runtime}.md`,
|
|
793
|
+
sha256: bundle.entrySha256.get(`${inventory}/${runtime}.md`),
|
|
794
|
+
}));
|
|
795
|
+
}
|
|
796
|
+
|
|
748
797
|
/**
|
|
749
798
|
* Deterministically derives the provider-owned consumer contract surface from
|
|
750
799
|
* a verified bundle. Pi overlays never live in these files; they are pure
|
|
@@ -773,11 +822,8 @@ export function generateProviderContractBaselines(bundle: VerifiedProviderContra
|
|
|
773
822
|
mandatory_capabilities: mandatory,
|
|
774
823
|
runtimes: bundle.runtimes === undefined ? null : [...bundle.runtimes],
|
|
775
824
|
pi_registered: piRuntimeRegistration(bundle).registered,
|
|
776
|
-
orchestration:
|
|
777
|
-
|
|
778
|
-
path: `orchestration/${runtime}.md`,
|
|
779
|
-
sha256: bundle.entrySha256.get(`orchestration/${runtime}.md`),
|
|
780
|
-
})),
|
|
825
|
+
orchestration: runtimeFileBaseline(bundle, "orchestration", bundle.orchestration),
|
|
826
|
+
routing: runtimeFileBaseline(bundle, "routing", bundle.routing),
|
|
781
827
|
};
|
|
782
828
|
return new Map([
|
|
783
829
|
[PROVIDER_ROLES_BASELINE_FILE, `${JSON.stringify(roles, null, 2)}\n`],
|