@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.
Files changed (67) hide show
  1. package/.agentic-driver/router.defaults.json +64 -0
  2. package/LICENSE +11 -8
  3. package/README.md +116 -32
  4. package/extensions/attended-authority-guard.ts +1 -1
  5. package/extensions/code-phage.js +1 -1
  6. package/extensions/herdr-communication.ts +1 -1
  7. package/extensions/herdr-dispatch.ts +1 -1
  8. package/extensions/herdr-lifecycle.ts +1 -1
  9. package/extensions/linux-microvm.ts +1 -1
  10. package/extensions/pulse.ts +33 -2
  11. package/extensions/task-board.ts +1 -1
  12. package/janus/README.draft.md +210 -0
  13. package/janus/README.md +196 -0
  14. package/janus/janus/audit.ts +213 -0
  15. package/janus/janus/budget.ts +101 -0
  16. package/janus/janus/budget_breaker.ts +105 -0
  17. package/janus/janus/config.ts +332 -0
  18. package/janus/janus/errors.ts +90 -0
  19. package/janus/janus/evaluate.ts +230 -0
  20. package/janus/janus/keychain.ts +119 -0
  21. package/janus/janus/providers.ts +544 -0
  22. package/janus/janus/schema.ts +344 -0
  23. package/janus/janus/server.ts +267 -0
  24. package/janus/janus/single_flight.ts +99 -0
  25. package/janus/janus/verdict.ts +192 -0
  26. package/janus/package-lock.json +616 -0
  27. package/janus/package.json +29 -0
  28. package/janus/tests/accounting.test.ts +229 -0
  29. package/janus/tests/allowlist.test.ts +97 -0
  30. package/janus/tests/config.test.ts +151 -0
  31. package/janus/tests/errors.test.ts +147 -0
  32. package/janus/tests/evaluate_pipeline.test.ts +358 -0
  33. package/janus/tests/helpers.ts +217 -0
  34. package/janus/tests/providers.test.ts +292 -0
  35. package/janus/tests/redaction_budget.test.ts +227 -0
  36. package/janus/tests/schema.test.ts +356 -0
  37. package/janus/tests/server.test.ts +288 -0
  38. package/janus/tests/single_flight.test.ts +84 -0
  39. package/janus/tsconfig.json +19 -0
  40. package/janus/user.janus.plist +51 -0
  41. package/lib/adapters/diff-scope.mjs +1 -1
  42. package/lib/adapters/evidence.mjs +1 -1
  43. package/lib/adapters/narrative.mjs +1 -1
  44. package/lib/adapters/review-feedback.mjs +1 -1
  45. package/lib/adapters/visualization.mjs +1 -1
  46. package/lib/code-phage-core.mjs +1 -1
  47. package/lib/python_ast_metrics.py +1 -1
  48. package/lib/typescript_ast_metrics.mjs +1 -1
  49. package/package.json +14 -3
  50. package/scripts/aidr_writing_review.js +1 -1
  51. package/scripts/enforcement/attended_authority_guard.js +1 -1
  52. package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
  53. package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
  54. package/scripts/enforcement/herdr_communication_pi.js +13 -8
  55. package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
  56. package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
  57. package/scripts/enforcement/native_tui_context.js +1 -1
  58. package/scripts/enforcement/pulse_core_pi.js +38 -14
  59. package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
  60. package/scripts/enforcement/router_engine_pi.js +483 -0
  61. package/scripts/enforcement/router_ranking_pi.js +146 -0
  62. package/scripts/enforcement/router_replacement_pi.js +98 -0
  63. package/scripts/enforcement/router_runtime_pi.js +299 -0
  64. package/scripts/enforcement/router_schemas_pi.js +654 -0
  65. package/scripts/enforcement/router_store_pi.js +438 -0
  66. package/scripts/enforcement/task_board_core_pi.js +255 -44
  67. 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>
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  // diff-scope adapter — changed-range extraction for code-phage.
5
5
  //
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  // evidence adapter — content-bound source anchors for code-phage.
5
5
  //
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  // narrative adapter — structured, concern-grouped review narrative.
5
5
  //
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  // review-feedback adapter — two-intent review items for code-phage.
5
5
  //
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  // visualization adapter — optional bounded visual payloads for code-phage.
5
5
  //
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
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";
@@ -1,5 +1,5 @@
1
1
  # SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  #!/usr/bin/env python3
5
5
  """AST-backed complexity metrics for code-phage.
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  import ts from "typescript";
5
5
 
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.9.1",
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-or-later",
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
- "skills/pulse/SKILL.md"
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": [
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import fs, { realpathSync } 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-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  // Attended-authority destructive-command guard for Pi.
5
5
  //
@@ -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-or-later
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-or-later
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.8.2";
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 observed for Herdr 0.8.2.
29
- // It is deliberately not resolved through PATH or HERDR_BIN_PATH. A package
30
- // upgrade changes the realpath and therefore fails closed until this pin is
31
- // reviewed. Linux callers have no configured production path in this package.
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 TRUSTED_HERDR_REALPATH_FRAGMENT = `/Cellar/herdr/${HERDR_VERSION}/bin/herdr`;
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() || !real.endsWith(TRUSTED_HERDR_REALPATH_FRAGMENT)) throw new Error("version mismatch");
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-or-later
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 0.8.2 layout/start sequence, bounded read-back,
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 0.8.2 owns new-shell readiness. Follow its documented topology:
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-or-later
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-or-later
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-or-later
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
- export function modelUsable(availability) {
53
- return availability === "available" || availability === "full";
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
- const policyCeiling = Number.isInteger(pulsePolicy?.globalMaxConcurrent)
92
- ? pulsePolicy.globalMaxConcurrent
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
- const policyWideRemaining = Number.isInteger(pulsePolicy?.globalMaxConcurrent)
194
- ? pulsePolicy.globalMaxConcurrent - (activeClaims ?? []).length
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 === "available" || availability === "full" ? free.free : 0,
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);