@evoclock/pi-agentic-driver 0.9.1 → 0.9.3
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/.agentic-driver/router.defaults.json +64 -0
- package/LICENSE +11 -8
- package/README.md +116 -32
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +1 -1
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +1 -1
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +33 -2
- package/extensions/task-board.ts +1 -1
- package/janus/README.draft.md +210 -0
- package/janus/README.md +196 -0
- package/janus/janus/audit.ts +213 -0
- package/janus/janus/budget.ts +101 -0
- package/janus/janus/budget_breaker.ts +105 -0
- package/janus/janus/config.ts +332 -0
- package/janus/janus/errors.ts +90 -0
- package/janus/janus/evaluate.ts +230 -0
- package/janus/janus/keychain.ts +119 -0
- package/janus/janus/providers.ts +544 -0
- package/janus/janus/schema.ts +344 -0
- package/janus/janus/server.ts +267 -0
- package/janus/janus/single_flight.ts +99 -0
- package/janus/janus/verdict.ts +192 -0
- package/janus/package-lock.json +616 -0
- package/janus/package.json +29 -0
- package/janus/tests/accounting.test.ts +229 -0
- package/janus/tests/allowlist.test.ts +97 -0
- package/janus/tests/config.test.ts +151 -0
- package/janus/tests/errors.test.ts +147 -0
- package/janus/tests/evaluate_pipeline.test.ts +358 -0
- package/janus/tests/helpers.ts +217 -0
- package/janus/tests/providers.test.ts +292 -0
- package/janus/tests/redaction_budget.test.ts +227 -0
- package/janus/tests/schema.test.ts +356 -0
- package/janus/tests/server.test.ts +288 -0
- package/janus/tests/single_flight.test.ts +84 -0
- package/janus/tsconfig.json +19 -0
- package/janus/user.janus.plist +51 -0
- package/lib/adapters/diff-scope.mjs +1 -1
- package/lib/adapters/evidence.mjs +1 -1
- package/lib/adapters/narrative.mjs +1 -1
- package/lib/adapters/review-feedback.mjs +1 -1
- package/lib/adapters/visualization.mjs +1 -1
- package/lib/code-phage-core.mjs +1 -1
- package/lib/python_ast_metrics.py +1 -1
- package/lib/typescript_ast_metrics.mjs +1 -1
- package/package.json +14 -3
- package/scripts/aidr_writing_review.js +1 -1
- package/scripts/enforcement/attended_authority_guard.js +1 -1
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
- package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
- package/scripts/enforcement/herdr_communication_pi.js +13 -8
- package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
- package/scripts/enforcement/native_tui_context.js +1 -1
- package/scripts/enforcement/pulse_core_pi.js +38 -14
- package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
- package/scripts/enforcement/router_engine_pi.js +483 -0
- package/scripts/enforcement/router_ranking_pi.js +146 -0
- package/scripts/enforcement/router_replacement_pi.js +98 -0
- package/scripts/enforcement/router_runtime_pi.js +299 -0
- package/scripts/enforcement/router_schemas_pi.js +654 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +255 -44
- package/skills/pulse/SKILL.md +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Unit tests: single-flight per evalInputDigest (M9) and the global
|
|
5
|
+
// concurrency cap with busy rejection.
|
|
6
|
+
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { SingleFlight } from "../janus/single_flight.js";
|
|
10
|
+
import { JanusError } from "../janus/errors.js";
|
|
11
|
+
|
|
12
|
+
test("identical digests coalesce onto one task", async () => {
|
|
13
|
+
const sf = new SingleFlight(4);
|
|
14
|
+
let runs = 0;
|
|
15
|
+
const task = () =>
|
|
16
|
+
sf.run("digest-a", async () => {
|
|
17
|
+
runs += 1;
|
|
18
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
19
|
+
return "result";
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const [a, b, c] = await Promise.all([task(), task(), task()]);
|
|
23
|
+
assert.equal(runs, 1);
|
|
24
|
+
assert.equal(a, "result");
|
|
25
|
+
assert.equal(b, "result");
|
|
26
|
+
assert.equal(c, "result");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("distinct digests run as separate tasks", async () => {
|
|
30
|
+
const sf = new SingleFlight(4);
|
|
31
|
+
let runs = 0;
|
|
32
|
+
const make = (digest: string) =>
|
|
33
|
+
sf.run(digest, async () => {
|
|
34
|
+
runs += 1;
|
|
35
|
+
return digest;
|
|
36
|
+
});
|
|
37
|
+
await Promise.all([make("d1"), make("d2"), make("d3")]);
|
|
38
|
+
assert.equal(runs, 3);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("global cap rejects distinct digests with busy", async () => {
|
|
42
|
+
const sf = new SingleFlight(2);
|
|
43
|
+
const release: (() => void)[] = [];
|
|
44
|
+
const make = (digest: string) =>
|
|
45
|
+
sf.run(digest, () => new Promise<string>((resolvePromise) => release.push(() => resolvePromise(digest))));
|
|
46
|
+
|
|
47
|
+
void make("d1");
|
|
48
|
+
void make("d2");
|
|
49
|
+
assert.throws(() => make("d3"), (error: unknown) => {
|
|
50
|
+
return error instanceof JanusError && error.code === "busy";
|
|
51
|
+
});
|
|
52
|
+
for (const r of release) r();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("digests are released after completion", async () => {
|
|
56
|
+
const sf = new SingleFlight(1);
|
|
57
|
+
await sf.run("d1", async () => "x");
|
|
58
|
+
// Cap of 1 must allow the next request after the first completes.
|
|
59
|
+
assert.equal(await sf.run("d2", async () => "y"), "y");
|
|
60
|
+
assert.equal(await sf.run("d1", async () => "z"), "z");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("failed flights release their digest", async () => {
|
|
64
|
+
const sf = new SingleFlight(1);
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
sf.run("d1", async () => {
|
|
67
|
+
throw new Error("boom");
|
|
68
|
+
}),
|
|
69
|
+
/boom/,
|
|
70
|
+
);
|
|
71
|
+
assert.equal(await sf.run("d1", async () => "recovered"), "recovered");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("coalesced waiters share a failed flight's rejection", async () => {
|
|
75
|
+
const sf = new SingleFlight(4);
|
|
76
|
+
const task = () =>
|
|
77
|
+
sf.run("digest-fail", async () => {
|
|
78
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
79
|
+
throw new Error("gateway down");
|
|
80
|
+
});
|
|
81
|
+
const results = await Promise.allSettled([task(), task()]);
|
|
82
|
+
assert.equal(results[0]?.status, "rejected");
|
|
83
|
+
assert.equal(results[1]?.status, "rejected");
|
|
84
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2023"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"exactOptionalPropertyTypes": true,
|
|
11
|
+
"noFallthroughCasesInSwitch": true,
|
|
12
|
+
"noImplicitOverride": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"noEmit": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["janus/**/*.ts", "tests/**/*.ts"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<!--
|
|
4
|
+
janus launchd template — a STARTING POINT, not a finished service file.
|
|
5
|
+
Everything marked ADJUST below is user-specific by design; the template
|
|
6
|
+
ships with placeholders because the label, paths, and log locations belong
|
|
7
|
+
to the user's machine, not to this repository.
|
|
8
|
+
|
|
9
|
+
Install:
|
|
10
|
+
1. Copy the template somewhere out of the repo:
|
|
11
|
+
cp janus/user.janus.plist ~/Library/LaunchAgents/user.janus.plist
|
|
12
|
+
2. ADJUST the values marked below (Label, WorkingDirectory, log paths).
|
|
13
|
+
3. Load it:
|
|
14
|
+
launchctl load ~/Library/LaunchAgents/user.janus.plist
|
|
15
|
+
|
|
16
|
+
The service binds 127.0.0.1 only and reads the gateway key from the
|
|
17
|
+
Keychain at runtime; no environment variables are required.
|
|
18
|
+
-->
|
|
19
|
+
<plist version="1.0">
|
|
20
|
+
<dict>
|
|
21
|
+
<key>Label</key>
|
|
22
|
+
<!-- ADJUST: any label you like; the file name should match it. -->
|
|
23
|
+
<string>user.janus</string>
|
|
24
|
+
<key>ProgramArguments</key>
|
|
25
|
+
<array>
|
|
26
|
+
<!-- ADJUST if your tooling lives elsewhere (e.g. /usr/local/bin/npx). -->
|
|
27
|
+
<string>/opt/homebrew/bin/npx</string>
|
|
28
|
+
<string>tsx</string>
|
|
29
|
+
<string>janus/server.ts</string>
|
|
30
|
+
</array>
|
|
31
|
+
<key>WorkingDirectory</key>
|
|
32
|
+
<!-- ADJUST: the janus/ directory of your checkout. -->
|
|
33
|
+
<string>/path/to/pi-agentic-driver/janus</string>
|
|
34
|
+
<key>RunAtLoad</key>
|
|
35
|
+
<true/>
|
|
36
|
+
<key>KeepAlive</key>
|
|
37
|
+
<true/>
|
|
38
|
+
<key>StandardOutPath</key>
|
|
39
|
+
<!-- ADJUST: where stdout goes. -->
|
|
40
|
+
<string>/tmp/janus.stdout.log</string>
|
|
41
|
+
<key>StandardErrorPath</key>
|
|
42
|
+
<!-- ADJUST: where stderr goes. -->
|
|
43
|
+
<string>/tmp/janus.stderr.log</string>
|
|
44
|
+
<key>EnvironmentVariables</key>
|
|
45
|
+
<dict>
|
|
46
|
+
<key>PATH</key>
|
|
47
|
+
<!-- ADJUST if your npx lives on a different PATH. -->
|
|
48
|
+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
49
|
+
</dict>
|
|
50
|
+
</dict>
|
|
51
|
+
</plist>
|
package/lib/code-phage-core.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evoclock/pi-agentic-driver",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"license": "AGPL-3.0-
|
|
6
|
+
"license": "AGPL-3.0-only",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.5.0"
|
|
9
|
+
},
|
|
7
10
|
"keywords": [
|
|
8
11
|
"pi-package",
|
|
9
12
|
"pi-extension",
|
|
@@ -70,7 +73,15 @@
|
|
|
70
73
|
"scripts/enforcement/task_board_core_pi.js",
|
|
71
74
|
"scripts/enforcement/pulse_core_pi.js",
|
|
72
75
|
"scripts/enforcement/pulse_scheduler_pi.js",
|
|
73
|
-
"
|
|
76
|
+
"scripts/enforcement/router_engine_pi.js",
|
|
77
|
+
"scripts/enforcement/router_schemas_pi.js",
|
|
78
|
+
"scripts/enforcement/router_ranking_pi.js",
|
|
79
|
+
"scripts/enforcement/router_store_pi.js",
|
|
80
|
+
"scripts/enforcement/router_replacement_pi.js",
|
|
81
|
+
"scripts/enforcement/router_runtime_pi.js",
|
|
82
|
+
".agentic-driver/router.defaults.json",
|
|
83
|
+
"skills/pulse/SKILL.md",
|
|
84
|
+
"janus"
|
|
74
85
|
],
|
|
75
86
|
"pi": {
|
|
76
87
|
"extensions": [
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
|
|
11
11
|
{"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
|
|
12
12
|
{"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
|
|
13
|
-
{"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
|
|
13
|
+
{"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp not present in the pre-arming socket baseline, at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep-baselined"},
|
|
14
14
|
{"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
|
|
15
15
|
{"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
|
|
16
16
|
{"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
4
|
// Async continuous worker dispatch and pulse. One bounded step on top of the
|
|
5
5
|
// shipped herdr-communication interface: a worker progresses through an
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
accessSync,
|
|
@@ -14,7 +14,7 @@ import { resolve } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
export const HERDR_COMMUNICATION_TOOL = "agentic_herdr_communication";
|
|
16
16
|
export const HERDR_COMMUNICATION_SCHEMA = "agentic-driver.herdr-communication.v1";
|
|
17
|
-
export const HERDR_VERSION = "0.
|
|
17
|
+
export const HERDR_VERSION = "0.9.1";
|
|
18
18
|
// One versioned policy: every schema-valid dynamic role is eligible except the
|
|
19
19
|
// coordinator class (`coordinator` and `coordinator-*`).
|
|
20
20
|
export const HERDR_ROLE_POLICY = Object.freeze({
|
|
@@ -25,12 +25,17 @@ export const HERDR_ROLE_PATTERN = "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$";
|
|
|
25
25
|
const HERDR_ROLE_REGEXP = new RegExp(HERDR_ROLE_PATTERN);
|
|
26
26
|
export const HERDR_COMMUNICATION_ACTIONS = Object.freeze(["list", "get", "prompt", "wait", "read"]);
|
|
27
27
|
|
|
28
|
-
// The Homebrew link is the configured driver-node path
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
28
|
+
// The Homebrew link is the configured driver-node path. It is deliberately
|
|
29
|
+
// not resolved through PATH or HERDR_BIN_PATH. The realpath is validated as
|
|
30
|
+
// <Cellar root>/herdr/<any-version>/bin/herdr so a Homebrew upgrade does not
|
|
31
|
+
// break the trust seam: the executable must be the Homebrew-managed herdr
|
|
32
|
+
// binary (same path family), executable, and a regular file, but the version
|
|
33
|
+
// number is not pinned because pinning would break every package upgrade.
|
|
34
|
+
// The semantic version constant above records the version this integration was
|
|
35
|
+
// last validated against and is advisory only. Linux callers have no
|
|
36
|
+
// configured production path in this package.
|
|
32
37
|
export const TRUSTED_HERDR_EXECUTABLE = "/opt/homebrew/bin/herdr";
|
|
33
|
-
const
|
|
38
|
+
const TRUSTED_HERDR_REALPATH_PATTERN = /\/Cellar\/herdr\/[0-9]+\.[0-9]+\.[0-9]+\/bin\/herdr$/;
|
|
34
39
|
const WORKER_REPOSITORY_REGISTRY = "config/herdr-worker-repositories.v1.json";
|
|
35
40
|
const WORKER_REPOSITORY_SCHEMA = "agentic-driver.herdr-worker-repositories.v1";
|
|
36
41
|
const WORKER_REPOSITORY_FIELDS = new Set(["schema", "repositories"]);
|
|
@@ -427,7 +432,7 @@ function productionExecutable() {
|
|
|
427
432
|
real = realpathSync(TRUSTED_HERDR_EXECUTABLE);
|
|
428
433
|
const stat = statSync(real);
|
|
429
434
|
accessSync(real, fsConstants.X_OK);
|
|
430
|
-
if (!stat.isFile() || !
|
|
435
|
+
if (!stat.isFile() || !TRUSTED_HERDR_REALPATH_PATTERN.test(real)) throw new Error("trust mismatch");
|
|
431
436
|
} catch {
|
|
432
437
|
throw communicationError("trusted_executable_unavailable", `the configured Herdr ${HERDR_VERSION} executable was not observed`);
|
|
433
438
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
4
|
// Guarded spawn_worker composite capability (Tranche 07). One tool, one closed
|
|
5
|
-
// request, one documented Herdr
|
|
5
|
+
// request, one documented Herdr layout/start sequence, bounded read-back,
|
|
6
6
|
// and a bounded receipt. This is deliberately NOT a general Herdr management
|
|
7
7
|
// surface: no raw management verbs, no terminal remote control, no model
|
|
8
8
|
// allowlist constant. Pane placement uses pane split directly; tab placement
|
|
@@ -767,7 +767,7 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
|
|
|
767
767
|
}
|
|
768
768
|
canonicalRoot = reconfirmedRoot;
|
|
769
769
|
|
|
770
|
-
// Herdr
|
|
770
|
+
// Herdr owns new-shell readiness. Follow its documented topology:
|
|
771
771
|
// split directly for pane placement, or create a tab for tab placement.
|
|
772
772
|
let coordinator = null;
|
|
773
773
|
if (placement !== "tab") {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
4
|
// Public native-TUI context predicate shared by the shipped enforcement
|
|
5
5
|
// interfaces. Restriction-gated actions stay fail-closed: anything that is
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
4
|
// Pulse core (PULSE_DESIGN_v3 §2.2, §4, §5): a side-effect-free evaluation of
|
|
5
5
|
// one selected board. It consumes validated board cards, active claim
|
|
@@ -48,9 +48,17 @@ export function modelAvailability({ model, observations = null, declaredModels =
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// True only when the model can take new work this tick. "full" means the
|
|
51
|
-
// provider reported a live limit reached; the route is full, not unusable
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// provider reported a live limit reached; the route is full, not unusable —
|
|
52
|
+
// UNLESS an explicit provider-limit observation says otherwise (router design
|
|
53
|
+
// §9 item 1): a bare "full" with no provider limit is treated as unusable
|
|
54
|
+
// because capacity cannot be verified, and capacity races must fail closed.
|
|
55
|
+
export function modelUsable(availability, { providerLimit = null, providerActive = null } = {}) {
|
|
56
|
+
if (availability === "available") return true;
|
|
57
|
+
if (availability === "full") {
|
|
58
|
+
return Number.isInteger(providerLimit) && Number.isInteger(providerActive)
|
|
59
|
+
&& providerLimit > 0 && providerActive < providerLimit;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
// Composed free capacity for one role/model route (§4.2):
|
|
@@ -88,8 +96,12 @@ export function freeCapacity({ role, model, pulsePolicy, activeClaims = [], acti
|
|
|
88
96
|
|
|
89
97
|
const routeCeiling = route.maxConcurrent;
|
|
90
98
|
const roleCeiling = roleEntry?.maxConcurrent ?? routeCeiling;
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
// §9 item 2: the policy-wide ceiling is the automation policy's own
|
|
100
|
+
// maxConcurrent (the actual global ceiling). The legacy pulsePolicy
|
|
101
|
+
// .globalMaxConcurrent lookup is removed — that field never existed in the
|
|
102
|
+
// closed pulse shape.
|
|
103
|
+
const policyCeiling = Number.isInteger(pulsePolicy?.maxConcurrent)
|
|
104
|
+
? pulsePolicy.maxConcurrent
|
|
93
105
|
: null;
|
|
94
106
|
const configured = Math.min(routeCeiling, roleCeiling, policyCeiling ?? routeCeiling);
|
|
95
107
|
const providerCap = Number.isInteger(providerLimit) && providerLimit >= 0 ? providerLimit : null;
|
|
@@ -121,7 +133,7 @@ export function routesForRole(role, pulsePolicy) {
|
|
|
121
133
|
// Inputs are already trusted: the card comes from parseBoard/validateBoard,
|
|
122
134
|
// the policy from checkAutomationPolicy. Capacity never changes a card
|
|
123
135
|
// result — it only changes `capacity` and `proposedDispatches`.
|
|
124
|
-
export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, acceptedRepositories = null }) {
|
|
136
|
+
export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, acceptedRepositories = null, providerLimit = null, providerActive = null }) {
|
|
125
137
|
// Provenance verification is the caller adapter's responsibility: it
|
|
126
138
|
// supplies cards with `statePath` only when the trusted writer state is
|
|
127
139
|
// available. The core stays side-effect-free and file-free.
|
|
@@ -169,9 +181,13 @@ export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy
|
|
|
169
181
|
}
|
|
170
182
|
// Route availability: at least one declared route must be usable. Without
|
|
171
183
|
// an authorised fallback this is REVIEW_REQUIRED (§5), never a substitution.
|
|
184
|
+
// "full" counts as usable only with an explicit provider limit (§9 item 1).
|
|
172
185
|
const usable = routes.filter((route) => modelUsable(modelAvailability({
|
|
173
186
|
model: route.model, observations: modelObservations, declaredModels,
|
|
174
|
-
})
|
|
187
|
+
}), {
|
|
188
|
+
providerLimit,
|
|
189
|
+
providerActive: isPlainObject(providerActive) ? providerActive[route.model] : providerActive,
|
|
190
|
+
}));
|
|
175
191
|
if (usable.length === 0) {
|
|
176
192
|
return { result: "REVIEW_REQUIRED", reason: `no usable declared route for role "${role}" (preferred unavailable without authorised fallback)` };
|
|
177
193
|
}
|
|
@@ -181,7 +197,7 @@ export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy
|
|
|
181
197
|
// One full board scan (§4.3, §5). Deterministic: cards in board P0→P3 then
|
|
182
198
|
// card-ID order; capacity in role-then-route declaration order; proposals in
|
|
183
199
|
// selection order. No cross-board priority comparison happens here.
|
|
184
|
-
export function scanBoard({ cards, boardPath, boardRevision = null, policyRevision = null, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, observedAt = null, placement = null }) {
|
|
200
|
+
export function scanBoard({ cards, boardPath, boardRevision = null, policyRevision = null, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, observedAt = null, placement = null, activeSessions = [], providerLimit = null }) {
|
|
185
201
|
const index = new Map(cards.map((card) => [card.cardId, card]));
|
|
186
202
|
const ordered = [...cards].sort((a, b) =>
|
|
187
203
|
(PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99)
|
|
@@ -190,21 +206,24 @@ export function scanBoard({ cards, boardPath, boardRevision = null, policyRevisi
|
|
|
190
206
|
const cardResults = [];
|
|
191
207
|
const capacity = [];
|
|
192
208
|
const proposedDispatches = [];
|
|
193
|
-
|
|
194
|
-
|
|
209
|
+
// §9 item 2: the policy-wide ceiling is the automation policy's
|
|
210
|
+
// maxConcurrent. §9 item 3: the production path supplies activeSessions
|
|
211
|
+
// and providerLimit; the scan composes them into capacity.
|
|
212
|
+
const policyWideRemaining = Number.isInteger(pulsePolicy?.maxConcurrent)
|
|
213
|
+
? pulsePolicy.maxConcurrent - (activeClaims ?? []).length
|
|
195
214
|
: null;
|
|
196
215
|
|
|
197
216
|
const roleOrder = pulsePolicy?.routing ? Object.keys(pulsePolicy.routing) : [];
|
|
198
217
|
for (const role of roleOrder) {
|
|
199
218
|
for (const route of routesForRole(role, pulsePolicy)) {
|
|
200
219
|
const availability = modelAvailability({ model: route.model, observations: modelObservations, declaredModels });
|
|
201
|
-
const free = freeCapacity({ role, model: route.model, pulsePolicy, activeClaims });
|
|
220
|
+
const free = freeCapacity({ role, model: route.model, pulsePolicy, activeClaims, activeSessions, providerLimit });
|
|
202
221
|
capacity.push({
|
|
203
222
|
role, model: route.model, configured: free.configured,
|
|
204
223
|
activeClaims: (activeClaims ?? []).filter((claim) => claim?.role === role && claimModel(claim) === route.model).length,
|
|
205
224
|
startingOrRunning: 0,
|
|
206
|
-
providerLimit: null,
|
|
207
|
-
free: availability
|
|
225
|
+
providerLimit: Number.isInteger(providerLimit) && providerLimit >= 0 ? providerLimit : null,
|
|
226
|
+
free: modelUsable(availability, { providerLimit, providerActive: (activeSessions ?? []).filter((s) => s?.model === route.model).length }) ? free.free : 0,
|
|
208
227
|
availability,
|
|
209
228
|
});
|
|
210
229
|
}
|
|
@@ -223,6 +242,11 @@ export function scanBoard({ cards, boardPath, boardRevision = null, policyRevisi
|
|
|
223
242
|
card, boardIndex: index, boardPath, activeClaims, pulsePolicy,
|
|
224
243
|
modelObservations, declaredModels,
|
|
225
244
|
acceptedRepositories: pulsePolicy?.acceptedRepositories ?? null,
|
|
245
|
+
providerLimit,
|
|
246
|
+
providerActive: Object.fromEntries(routesForRole(
|
|
247
|
+
typeof card.role === "string" && card.role !== "" ? card.role : "implementer",
|
|
248
|
+
pulsePolicy,
|
|
249
|
+
).map((route) => [route.model, (activeSessions ?? []).filter((session) => session?.model === route.model).length])),
|
|
226
250
|
});
|
|
227
251
|
const entry = { title: card.title, role: evaluation.role ?? (typeof card.role === "string" && card.role !== "" ? card.role : "implementer"), result: evaluation.result, reason: evaluation.reason };
|
|
228
252
|
cardResults.push(entry);
|