@evoclock/pi-agentic-driver 0.9.0 → 0.9.2
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 +94 -29
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +9 -2
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +5 -4
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +105 -0
- package/extensions/task-board.ts +1 -1
- 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 +17 -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 +66 -4
- package/scripts/enforcement/herdr_communication_pi.js +55 -19
- 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 +292 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +1083 -0
- 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 +604 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +597 -48
- package/skills/pulse/SKILL.md +39 -0
|
@@ -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
|
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
HERDR_COMMUNICATION_ACTIONS,
|
|
17
17
|
} from "./herdr_communication_pi.js";
|
|
18
18
|
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
19
|
-
import { prepareEnvelopeForExecution, validateEnvelopeForExecution, consumeEnvelope } from "./task_board_core_pi.js";
|
|
19
|
+
import { prepareEnvelopeForExecution, validateEnvelopeForExecution, consumeEnvelope, gitHead } from "./task_board_core_pi.js";
|
|
20
|
+
import { sha256Hex } from "./task_board_core_pi.js";
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
20
22
|
|
|
21
23
|
export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
|
|
22
24
|
export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
|
|
@@ -29,10 +31,17 @@ const MAX_JOURNEY_STEPS = 200;
|
|
|
29
31
|
const MAX_REPORT_BYTES = 32 * 1024;
|
|
30
32
|
const REGISTRATIONS = new WeakSet();
|
|
31
33
|
|
|
34
|
+
// The worker-side review-request marker. An implementation journey whose last
|
|
35
|
+
// complete report carries this marker terminates as review_requested (§6.3):
|
|
36
|
+
// it projects review readiness without approval. The board lane transition
|
|
37
|
+
// remains a human/writer decision; Pulse never infers it.
|
|
38
|
+
export const REVIEW_REQUEST_MARKER = "[REVIEW_REQUESTED]";
|
|
39
|
+
|
|
32
40
|
// Terminal journey states. Only `cancelled` and `failed` are failures; every
|
|
33
41
|
// other terminal state is an observed outcome, and no state is retried.
|
|
34
42
|
export const WORKER_DISPATCH_TERMINAL_STATES = Object.freeze([
|
|
35
43
|
"completed", "exhausted", "role-blocked", "cancelled", "failed", "waiting-approval", "worker-unresponsive",
|
|
44
|
+
"review_requested",
|
|
36
45
|
]);
|
|
37
46
|
|
|
38
47
|
export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
|
|
@@ -225,6 +234,46 @@ function journeyReceipt(journey) {
|
|
|
225
234
|
return body;
|
|
226
235
|
}
|
|
227
236
|
|
|
237
|
+
// §10.2 progress snapshot: a compact observation attached to the journey
|
|
238
|
+
// step/terminal evidence it belongs to. Not authority, not persisted
|
|
239
|
+
// separately, and not written on every timer tick — only at assignment start
|
|
240
|
+
// and meaningful checkpoints. Repository observations are read-only.
|
|
241
|
+
function progressSnapshot({ taskId = null, envelope = null, repository = null, workerStatus = null, lastReportAt = null }) {
|
|
242
|
+
const snapshot = {
|
|
243
|
+
schema: "agentic-driver.progress-snapshot.v1",
|
|
244
|
+
taskId,
|
|
245
|
+
at: new Date().toISOString(),
|
|
246
|
+
};
|
|
247
|
+
if (envelope) {
|
|
248
|
+
snapshot.envelopeId = envelope.envelopeId ?? null;
|
|
249
|
+
snapshot.cardId = envelope.cardId ?? null;
|
|
250
|
+
snapshot.branch = envelope.branch ?? null;
|
|
251
|
+
}
|
|
252
|
+
if (repository) {
|
|
253
|
+
snapshot.repository = repository;
|
|
254
|
+
snapshot.head = gitHead(repository);
|
|
255
|
+
const diff = scopedDiffHash(repository);
|
|
256
|
+
snapshot.scopedDiffHash = diff.hash;
|
|
257
|
+
snapshot.changedPaths = diff.paths;
|
|
258
|
+
}
|
|
259
|
+
if (workerStatus !== null) snapshot.workerStatus = workerStatus;
|
|
260
|
+
if (lastReportAt !== null) snapshot.lastCompleteReportAt = lastReportAt;
|
|
261
|
+
return snapshot;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Hash of the repository's uncommitted diff (scoped work-in-progress signal).
|
|
265
|
+
// Returns {hash, paths}; a repository where git is unavailable yields nulls.
|
|
266
|
+
function scopedDiffHash(repository) {
|
|
267
|
+
try {
|
|
268
|
+
const paths = execFileSync("git", ["status", "--porcelain"], { cwd: repository, encoding: "utf8" })
|
|
269
|
+
.split("\n").map((line) => line.slice(3).trim()).filter(Boolean);
|
|
270
|
+
const diff = execFileSync("git", ["diff", "HEAD"], { cwd: repository, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
271
|
+
return { hash: sha256Hex(diff), paths: paths.slice(0, 64) };
|
|
272
|
+
} catch {
|
|
273
|
+
return { hash: null, paths: [] };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
228
277
|
// One continuous journey. Each step: pulse (liveness + eligibility), observe
|
|
229
278
|
// the next dispatchable item, one prompt exchange (no retry on any failure),
|
|
230
279
|
// read the marked report, collate. Bounded by maxSteps, never wall-clock.
|
|
@@ -256,7 +305,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
256
305
|
const finish = async (requestedStatus) => {
|
|
257
306
|
let status = requestedStatus;
|
|
258
307
|
if (board && boardAttemptStarted && !envelopeConsumed) {
|
|
259
|
-
const reason = ["completed", "exhausted", "cancelled", "failed", "waiting-approval", "role-blocked", "worker-unresponsive"].includes(status)
|
|
308
|
+
const reason = ["completed", "exhausted", "cancelled", "failed", "waiting-approval", "role-blocked", "worker-unresponsive", "review_requested"].includes(status)
|
|
260
309
|
? status
|
|
261
310
|
: status.startsWith("worker-unresponsive") ? "worker-unresponsive" : "failed";
|
|
262
311
|
let consumed;
|
|
@@ -277,7 +326,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
277
326
|
journey.status = status;
|
|
278
327
|
return {
|
|
279
328
|
schema: WORKER_DISPATCH_SCHEMA,
|
|
280
|
-
ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
|
|
329
|
+
ok: status === "completed" || status === "exhausted" || status === "waiting-approval" || status === "review_requested",
|
|
281
330
|
action: "dispatch",
|
|
282
331
|
mode,
|
|
283
332
|
autonomy,
|
|
@@ -590,8 +639,21 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
590
639
|
status: "done",
|
|
591
640
|
workerStatus: exchange.agentStatus,
|
|
592
641
|
report: exchange.report,
|
|
642
|
+
// §10.2 progress snapshot at the meaningful checkpoint (step completion).
|
|
643
|
+
progress: progressSnapshot({
|
|
644
|
+
taskId: task.id,
|
|
645
|
+
envelope: board?.envelope ?? null,
|
|
646
|
+
repository: options.repository ?? null,
|
|
647
|
+
workerStatus: exchange.agentStatus ?? null,
|
|
648
|
+
lastReportAt: new Date().toISOString(),
|
|
649
|
+
}),
|
|
593
650
|
});
|
|
594
651
|
dispatched.add(task.id);
|
|
652
|
+
// §6.3 review routing: the last complete report requests review.
|
|
653
|
+
if (typeof exchange.report === "string" && exchange.report.includes(REVIEW_REQUEST_MARKER)) {
|
|
654
|
+
journey.status = "review_requested";
|
|
655
|
+
return finish("review_requested");
|
|
656
|
+
}
|
|
595
657
|
}
|
|
596
658
|
|
|
597
659
|
journey.status = "completed";
|
|
@@ -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"]);
|
|
@@ -42,6 +47,10 @@ const MAX_IDENTITY_FIELD_BYTES = 4 * 1024;
|
|
|
42
47
|
const MAX_MARKER_HORIZONTAL_WHITESPACE = 128;
|
|
43
48
|
const MAX_PROMPT_CONTRACT_ECHO_BYTES = 4 * 1024;
|
|
44
49
|
const MAX_READ_LINES = 400;
|
|
50
|
+
// A valid 32 KiB report can contain more than 400 short lines. A second,
|
|
51
|
+
// finite read may cover one line per byte plus framing without relaxing the
|
|
52
|
+
// existing 128 KiB process-output ceiling.
|
|
53
|
+
const MAX_REPORT_READ_LINES = MAX_REPORT_BYTES + 16;
|
|
45
54
|
const MAX_WAIT_TIMEOUT_MS = 300_000;
|
|
46
55
|
const MAX_IMPLEMENTER_PROMPT_TIMEOUT_MS = 120_000;
|
|
47
56
|
const COMMAND_TIMEOUT_MS = 15_000;
|
|
@@ -397,6 +406,14 @@ function validateParams(params) {
|
|
|
397
406
|
if (!isReviewerRole(params.role) && params.timeoutMs > MAX_IMPLEMENTER_PROMPT_TIMEOUT_MS) {
|
|
398
407
|
throw communicationError("implementer_prompt_timeout_exceeded", "worker prompts are limited to one two-minute atomic step", "denied");
|
|
399
408
|
}
|
|
409
|
+
// Prompt freshness: each explicit prompt carries its own task scope and
|
|
410
|
+
// contract framing built from THAT call's prompt text. A stale or reused
|
|
411
|
+
// prompt text never binds a later exchange — provenance requires the
|
|
412
|
+
// echoed contract to match the current prompt exactly (provenancedReport
|
|
413
|
+
// Segment), so a prior exchange's scope cannot silently cover this one.
|
|
414
|
+
if (params.prompt.includes(REPORT_CONTRACT_LINE)) {
|
|
415
|
+
throw communicationError("prompt_preformatted", "the prompt must carry task scope only; the report contract is added by the transport (fails closed)", "denied");
|
|
416
|
+
}
|
|
400
417
|
}
|
|
401
418
|
if (action === "wait") {
|
|
402
419
|
if (!Number.isInteger(params.timeoutMs) || params.timeoutMs < 1 || params.timeoutMs > MAX_WAIT_TIMEOUT_MS) {
|
|
@@ -415,7 +432,7 @@ function productionExecutable() {
|
|
|
415
432
|
real = realpathSync(TRUSTED_HERDR_EXECUTABLE);
|
|
416
433
|
const stat = statSync(real);
|
|
417
434
|
accessSync(real, fsConstants.X_OK);
|
|
418
|
-
if (!stat.isFile() || !
|
|
435
|
+
if (!stat.isFile() || !TRUSTED_HERDR_REALPATH_PATTERN.test(real)) throw new Error("trust mismatch");
|
|
419
436
|
} catch {
|
|
420
437
|
throw communicationError("trusted_executable_unavailable", `the configured Herdr ${HERDR_VERSION} executable was not observed`);
|
|
421
438
|
}
|
|
@@ -458,7 +475,7 @@ function fixedArgv(action, params) {
|
|
|
458
475
|
case "read":
|
|
459
476
|
return [
|
|
460
477
|
"agent", "read", params.role,
|
|
461
|
-
"--source", "recent-unwrapped", "--lines", String(MAX_READ_LINES),
|
|
478
|
+
"--source", "recent-unwrapped", "--lines", String(params.readLines ?? MAX_READ_LINES),
|
|
462
479
|
"--format", "text",
|
|
463
480
|
];
|
|
464
481
|
default:
|
|
@@ -468,11 +485,11 @@ function fixedArgv(action, params) {
|
|
|
468
485
|
|
|
469
486
|
function promptWithReportRequirement(role, prompt) {
|
|
470
487
|
const marker = reportMarkersForRole(role);
|
|
488
|
+
// Communication adds transport framing only. Task scope and role behaviour
|
|
489
|
+
// belong to the caller's current prompt; injecting either here would make a
|
|
490
|
+
// task-scoped instruction silently bind every later exchange for that role.
|
|
471
491
|
const requirement = [
|
|
472
492
|
"",
|
|
473
|
-
...(isReviewerRole(role) ? ["Remain strictly read-only; do not modify files, state, or Git."] : [
|
|
474
|
-
"MANDATORY ATOMIC EXECUTION CONTRACT: execute one acceptance-checked step only; do not continue to a second file, test group, or follow-up; stop and report incomplete work as CHANGES_REQUIRED before the two-minute boundary.",
|
|
475
|
-
]),
|
|
476
493
|
REPORT_CONTRACT_LINE,
|
|
477
494
|
marker.open,
|
|
478
495
|
marker.close,
|
|
@@ -1223,11 +1240,23 @@ export async function executeHerdrCommunication(params, context, options = {}, s
|
|
|
1223
1240
|
if (waitedStatus === "blocked") {
|
|
1224
1241
|
return errorResult(operation, communicationError("role_blocked", "the prompted role reached blocked state", "blocked"));
|
|
1225
1242
|
}
|
|
1226
|
-
|
|
1227
|
-
const post = readText(rawReport);
|
|
1243
|
+
let post = readText(await invokeHerdr("read", { action: "read", role }, context, options, signal));
|
|
1228
1244
|
const sentPrompt = promptWithReportRequirement(role, request.prompt);
|
|
1229
|
-
|
|
1230
|
-
|
|
1245
|
+
let report;
|
|
1246
|
+
let reportReadCount = 1;
|
|
1247
|
+
try {
|
|
1248
|
+
const boundary = provenancedReportSegment(pre, post, sentPrompt, role);
|
|
1249
|
+
report = extractReportFromSegment(post, role, boundary.end);
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
if (!(error instanceof HerdrCommunicationError)
|
|
1252
|
+
|| !["report_scope_unavailable", "report_reversed"].includes(error.code)) throw error;
|
|
1253
|
+
// The ordinary terminal tail can begin inside a valid current report.
|
|
1254
|
+
// Expand once; never resend the prompt and never retry indefinitely.
|
|
1255
|
+
post = readText(await invokeHerdr("read", { action: "read", role, readLines: MAX_REPORT_READ_LINES }, context, options, signal));
|
|
1256
|
+
reportReadCount = 2;
|
|
1257
|
+
const boundary = provenancedReportSegment(pre, post, sentPrompt, role);
|
|
1258
|
+
report = extractReportFromSegment(post, role, boundary.end);
|
|
1259
|
+
}
|
|
1231
1260
|
return successResult(operation, {
|
|
1232
1261
|
status: "complete",
|
|
1233
1262
|
role,
|
|
@@ -1237,7 +1266,7 @@ export async function executeHerdrCommunication(params, context, options = {}, s
|
|
|
1237
1266
|
promptSent: true,
|
|
1238
1267
|
invocationCount: 1,
|
|
1239
1268
|
waitCount: 1,
|
|
1240
|
-
readCount:
|
|
1269
|
+
readCount: reportReadCount,
|
|
1241
1270
|
report,
|
|
1242
1271
|
reportMarkers: reportMarkersForRole(role),
|
|
1243
1272
|
});
|
|
@@ -1250,8 +1279,15 @@ export async function executeHerdrCommunication(params, context, options = {}, s
|
|
|
1250
1279
|
}
|
|
1251
1280
|
return successResult(operation, { role, status, agentStatus: status, repository });
|
|
1252
1281
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1282
|
+
let raw = await invokeHerdr(operation, request, context, options, signal);
|
|
1283
|
+
let report;
|
|
1284
|
+
try {
|
|
1285
|
+
report = extractLatestHerdrReport(readText(raw), role);
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
if (!(error instanceof HerdrCommunicationError) || error.code !== "report_reversed") throw error;
|
|
1288
|
+
raw = await invokeHerdr("read", { action: "read", role, readLines: MAX_REPORT_READ_LINES }, context, options, signal);
|
|
1289
|
+
report = extractLatestHerdrReport(readText(raw), role);
|
|
1290
|
+
}
|
|
1255
1291
|
return successResult(operation, {
|
|
1256
1292
|
role,
|
|
1257
1293
|
report,
|
|
@@ -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
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Pulse core (PULSE_DESIGN_v3 §2.2, §4, §5): a side-effect-free evaluation of
|
|
5
|
+
// one selected board. It consumes validated board cards, active claim
|
|
6
|
+
// observations, a validated automation policy (with its optional closed
|
|
7
|
+
// `pulse` object), and model-availability observations supplied by trusted
|
|
8
|
+
// adapters. It returns one Pulse result per card, composed free capacity per
|
|
9
|
+
// role/model route, and a deterministic scheduling proposal.
|
|
10
|
+
//
|
|
11
|
+
// It never reads files, claims cards, spawns workers, starts timers, or
|
|
12
|
+
// mutates policy. Eligibility grants no authority: the existing dispatcher
|
|
13
|
+
// owns atomic claim and envelope creation.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
canonicalJsonString, sha256Hex, isDispatchable,
|
|
17
|
+
} from "./task_board_core_pi.js";
|
|
18
|
+
export { canonicalJsonString, sha256Hex };
|
|
19
|
+
|
|
20
|
+
export const PULSE_SCAN_SCHEMA = "agentic-driver.board-pulse.v1";
|
|
21
|
+
|
|
22
|
+
// The five closed Pulse results (§5). Exactly one per evaluated card.
|
|
23
|
+
export const PULSE_RESULTS = Object.freeze([
|
|
24
|
+
"READY_FOR_NEXT", "BLOCKED", "STALE", "DENIED", "REVIEW_REQUIRED",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const PRIORITY_ORDER = Object.freeze({ P0: 0, P1: 1, P2: 2, P3: 3 });
|
|
28
|
+
const AVAILABILITY = Object.freeze([
|
|
29
|
+
"available", "full", "unauthenticated", "unavailable", "unknown",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Close the availability observation for one model. Only declared
|
|
37
|
+
// preferred/fallback route models are considered; anything else is unknown
|
|
38
|
+
// and unavailable for routing (§3.1: fails closed).
|
|
39
|
+
export function modelAvailability({ model, observations = null, declaredModels = null }) {
|
|
40
|
+
if (declaredModels !== null && !declaredModels.includes(model)) return "unknown";
|
|
41
|
+
const observed = isPlainObject(observations) ? observations[model] : undefined;
|
|
42
|
+
if (observed === undefined) return "unknown";
|
|
43
|
+
if (!isPlainObject(observed)) return "unknown";
|
|
44
|
+
const status = observed.status;
|
|
45
|
+
if (status === "available" || status === "full"
|
|
46
|
+
|| status === "unauthenticated" || status === "unavailable") return status;
|
|
47
|
+
return "unknown";
|
|
48
|
+
}
|
|
49
|
+
|
|
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
|
+
// 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;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Composed free capacity for one role/model route (§4.2):
|
|
65
|
+
// free = min(configured route ceiling, role ceiling, policy-wide ceiling)
|
|
66
|
+
// - unique active work for role/model
|
|
67
|
+
// Active work is deduplicated by attempt identity across claims and verified
|
|
68
|
+
// sessions; a session bound to an active claim counts once.
|
|
69
|
+
export function freeCapacity({ role, model, pulsePolicy, activeClaims = [], activeSessions = [], providerLimit = null }) {
|
|
70
|
+
const routing = pulsePolicy?.routing ?? {};
|
|
71
|
+
const roleEntry = routing[role];
|
|
72
|
+
const routes = [...(roleEntry?.preferred ?? []), ...(roleEntry?.fallback ?? [])];
|
|
73
|
+
const route = routes.find((entry) => entry?.model === model) ?? null;
|
|
74
|
+
if (!route) return { free: 0, configured: 0, reason: `model "${model}" is not a declared route for role "${role}"` };
|
|
75
|
+
|
|
76
|
+
// Deduplicate active work by attempt identity (envelope id when present,
|
|
77
|
+
// else session id). A session matching an active claim counts once.
|
|
78
|
+
const claimIdentities = new Set(
|
|
79
|
+
(activeClaims ?? [])
|
|
80
|
+
.filter((claim) => claim?.role === role && claimModel(claim) === model)
|
|
81
|
+
.map((claim) => claim?.envelopeId ?? claim?.envelope?.envelopeId ?? `claim:${claim?.cardId}`)
|
|
82
|
+
.filter((id) => typeof id === "string"),
|
|
83
|
+
);
|
|
84
|
+
const boundSessionIds = new Set(
|
|
85
|
+
(activeClaims ?? []).map((claim) => claim?.sessionId).filter((id) => typeof id === "string"),
|
|
86
|
+
);
|
|
87
|
+
const orphanSessions = (activeSessions ?? []).filter((session) => {
|
|
88
|
+
if (session?.role !== role || session?.model !== model) return false;
|
|
89
|
+
if (typeof session?.sessionId !== "string") return false;
|
|
90
|
+
if (boundSessionIds.has(session.sessionId)) return false;
|
|
91
|
+
// An observed orphan session counts separately only after reconciliation
|
|
92
|
+
// cannot bind it to an active attempt; ambiguity consumes capacity (§4.2).
|
|
93
|
+
return session.reconciled === true || session.reconciled === undefined;
|
|
94
|
+
});
|
|
95
|
+
const active = claimIdentities.size + orphanSessions.length;
|
|
96
|
+
|
|
97
|
+
const routeCeiling = route.maxConcurrent;
|
|
98
|
+
const roleCeiling = roleEntry?.maxConcurrent ?? routeCeiling;
|
|
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
|
|
105
|
+
: null;
|
|
106
|
+
const configured = Math.min(routeCeiling, roleCeiling, policyCeiling ?? routeCeiling);
|
|
107
|
+
const providerCap = Number.isInteger(providerLimit) && providerLimit >= 0 ? providerLimit : null;
|
|
108
|
+
const ceiling = providerCap === null ? configured : Math.min(configured, providerCap);
|
|
109
|
+
return { free: Math.max(0, ceiling - active), configured, reason: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The model a claim is running under. Claims carry the exact model on their
|
|
113
|
+
// envelope when Pulse created them; legacy claims without one count against
|
|
114
|
+
// no specific model route — we attribute them to nothing rather than guess
|
|
115
|
+
// (no silent substitution).
|
|
116
|
+
function claimModel(claim) {
|
|
117
|
+
return typeof claim?.envelope?.model === "string" ? claim.envelope.model : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Ordered candidate routes for a role: preferred first, then fallback in
|
|
121
|
+
// declaration order (§3.1). Fallback is considered only when preferred
|
|
122
|
+
// routes are unavailable or full.
|
|
123
|
+
export function routesForRole(role, pulsePolicy) {
|
|
124
|
+
const entry = pulsePolicy?.routing?.[role];
|
|
125
|
+
if (!entry) return [];
|
|
126
|
+
return [
|
|
127
|
+
...entry.preferred.map((route) => ({ ...route, tier: "preferred" })),
|
|
128
|
+
...entry.fallback.map((route) => ({ ...route, tier: "fallback" })),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Evaluate one card and return exactly one Pulse result plus a reason (§5).
|
|
133
|
+
// Inputs are already trusted: the card comes from parseBoard/validateBoard,
|
|
134
|
+
// the policy from checkAutomationPolicy. Capacity never changes a card
|
|
135
|
+
// result — it only changes `capacity` and `proposedDispatches`.
|
|
136
|
+
export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, acceptedRepositories = null, providerLimit = null, providerActive = null }) {
|
|
137
|
+
// Provenance verification is the caller adapter's responsibility: it
|
|
138
|
+
// supplies cards with `statePath` only when the trusted writer state is
|
|
139
|
+
// available. The core stays side-effect-free and file-free.
|
|
140
|
+
const failed = isDispatchable(card, boardIndex).failedConditions ?? [];
|
|
141
|
+
const claimed = (activeClaims ?? []).some((claim) => claim?.cardId === card.cardId);
|
|
142
|
+
if (claimed) failed.push("card is already claimed by an active authenticated attempt");
|
|
143
|
+
if (failed.length > 0) {
|
|
144
|
+
const text = failed.join("; ");
|
|
145
|
+
if (claimed) {
|
|
146
|
+
return { result: "BLOCKED", reason: "card is already claimed by an active authenticated attempt" };
|
|
147
|
+
}
|
|
148
|
+
if (/hash|provenance|authority|tampered/.test(text)) {
|
|
149
|
+
return { result: "STALE", reason: text };
|
|
150
|
+
}
|
|
151
|
+
if (/expired|revoked|prohibited|not declared|placement|risk/i.test(text)) {
|
|
152
|
+
return { result: "DENIED", reason: text };
|
|
153
|
+
}
|
|
154
|
+
if (/dependenc|lane|blocked|cancelled|flag/i.test(text)) {
|
|
155
|
+
return { result: "BLOCKED", reason: text };
|
|
156
|
+
}
|
|
157
|
+
return { result: "REVIEW_REQUIRED", reason: text };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Policy gate (§5 DENIED): no policy, expired/revoked policy, or a role the
|
|
161
|
+
// policy does not declare and route.
|
|
162
|
+
if (!isPlainObject(pulsePolicy) || pulsePolicy.enabled !== true) {
|
|
163
|
+
return { result: "REVIEW_REQUIRED", reason: "Pulse is not enabled for this board" };
|
|
164
|
+
}
|
|
165
|
+
const role = typeof card.role === "string" && card.role !== "" ? card.role : "implementer";
|
|
166
|
+
const routes = routesForRole(role, pulsePolicy);
|
|
167
|
+
if (routes.length === 0) {
|
|
168
|
+
return { result: "DENIED", reason: `no Pulse route is declared for role "${role}" (fails closed)` };
|
|
169
|
+
}
|
|
170
|
+
// Repository gate (§9): the card must identify a repository the policy
|
|
171
|
+
// accepts. A card naming exactly one repository is unambiguous; otherwise
|
|
172
|
+
// a single-repository policy supplies the default. Ambiguity fails closed.
|
|
173
|
+
if (Array.isArray(acceptedRepositories) && acceptedRepositories.length > 0) {
|
|
174
|
+
const named = Array.isArray(card.repositories) ? card.repositories.filter((r) => typeof r === "string" && r !== "") : [];
|
|
175
|
+
const repository = named.length === 1 ? named[0]
|
|
176
|
+
: named.length === 0 && acceptedRepositories.length === 1 ? acceptedRepositories[0]
|
|
177
|
+
: null;
|
|
178
|
+
if (repository === null || !acceptedRepositories.includes(repository)) {
|
|
179
|
+
return { result: "DENIED", reason: "card repository is ambiguous or not in the policy acceptedRepositories (fails closed)" };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Route availability: at least one declared route must be usable. Without
|
|
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).
|
|
185
|
+
const usable = routes.filter((route) => modelUsable(modelAvailability({
|
|
186
|
+
model: route.model, observations: modelObservations, declaredModels,
|
|
187
|
+
}), {
|
|
188
|
+
providerLimit,
|
|
189
|
+
providerActive: isPlainObject(providerActive) ? providerActive[route.model] : providerActive,
|
|
190
|
+
}));
|
|
191
|
+
if (usable.length === 0) {
|
|
192
|
+
return { result: "REVIEW_REQUIRED", reason: `no usable declared route for role "${role}" (preferred unavailable without authorised fallback)` };
|
|
193
|
+
}
|
|
194
|
+
return { result: "READY_FOR_NEXT", reason: null, role, routes: usable };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// One full board scan (§4.3, §5). Deterministic: cards in board P0→P3 then
|
|
198
|
+
// card-ID order; capacity in role-then-route declaration order; proposals in
|
|
199
|
+
// selection order. No cross-board priority comparison happens here.
|
|
200
|
+
export function scanBoard({ cards, boardPath, boardRevision = null, policyRevision = null, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, observedAt = null, placement = null, activeSessions = [], providerLimit = null }) {
|
|
201
|
+
const index = new Map(cards.map((card) => [card.cardId, card]));
|
|
202
|
+
const ordered = [...cards].sort((a, b) =>
|
|
203
|
+
(PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99)
|
|
204
|
+
|| String(a.cardId).localeCompare(String(b.cardId)));
|
|
205
|
+
|
|
206
|
+
const cardResults = [];
|
|
207
|
+
const capacity = [];
|
|
208
|
+
const proposedDispatches = [];
|
|
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
|
|
214
|
+
: null;
|
|
215
|
+
|
|
216
|
+
const roleOrder = pulsePolicy?.routing ? Object.keys(pulsePolicy.routing) : [];
|
|
217
|
+
for (const role of roleOrder) {
|
|
218
|
+
for (const route of routesForRole(role, pulsePolicy)) {
|
|
219
|
+
const availability = modelAvailability({ model: route.model, observations: modelObservations, declaredModels });
|
|
220
|
+
const free = freeCapacity({ role, model: route.model, pulsePolicy, activeClaims, activeSessions, providerLimit });
|
|
221
|
+
capacity.push({
|
|
222
|
+
role, model: route.model, configured: free.configured,
|
|
223
|
+
activeClaims: (activeClaims ?? []).filter((claim) => claim?.role === role && claimModel(claim) === route.model).length,
|
|
224
|
+
startingOrRunning: 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,
|
|
227
|
+
availability,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const remainingByRoute = new Map(capacity.map((entry) => [`${entry.role}\u0000${entry.model}`, entry.free]));
|
|
233
|
+
let globalRemaining = policyWideRemaining;
|
|
234
|
+
// Placement for proposals is supplied by the scheduler adapter (the pulse
|
|
235
|
+
// object does not carry it; the policy's placement field governs). In
|
|
236
|
+
// automated mode containment is the default when available (§3.2); the core
|
|
237
|
+
// never decides containment availability.
|
|
238
|
+
const scanPlacement = placement;
|
|
239
|
+
|
|
240
|
+
for (const card of ordered) {
|
|
241
|
+
const evaluation = evaluateCard({
|
|
242
|
+
card, boardIndex: index, boardPath, activeClaims, pulsePolicy,
|
|
243
|
+
modelObservations, declaredModels,
|
|
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])),
|
|
250
|
+
});
|
|
251
|
+
const entry = { title: card.title, role: evaluation.role ?? (typeof card.role === "string" && card.role !== "" ? card.role : "implementer"), result: evaluation.result, reason: evaluation.reason };
|
|
252
|
+
cardResults.push(entry);
|
|
253
|
+
if (evaluation.result !== "READY_FOR_NEXT") continue;
|
|
254
|
+
|
|
255
|
+
// Propose at most free capacity and policy-wide remaining capacity (§4.3
|
|
256
|
+
// step 5): preferred routes first, then declared fallback, in order.
|
|
257
|
+
let proposed = false;
|
|
258
|
+
for (const route of evaluation.routes) {
|
|
259
|
+
const key = `${entry.role}\u0000${route.model}`;
|
|
260
|
+
const remaining = remainingByRoute.get(key) ?? 0;
|
|
261
|
+
if (remaining <= 0) continue;
|
|
262
|
+
if (globalRemaining !== null && globalRemaining <= 0) break;
|
|
263
|
+
remainingByRoute.set(key, remaining - 1);
|
|
264
|
+
if (globalRemaining !== null) globalRemaining -= 1;
|
|
265
|
+
proposedDispatches.push({ title: card.title, role: entry.role, model: route.model, placement: scanPlacement });
|
|
266
|
+
proposed = true;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
if (!proposed && evaluation.routes.length > 0) {
|
|
270
|
+
// Ready but no free slot: the card result stays READY_FOR_NEXT (§5);
|
|
271
|
+
// capacity alone gates the proposal.
|
|
272
|
+
entry.reason = "ready; no free capacity on any declared route this tick";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const scan = {
|
|
277
|
+
schema: PULSE_SCAN_SCHEMA,
|
|
278
|
+
board: boardPath,
|
|
279
|
+
boardRevision,
|
|
280
|
+
policyRevision,
|
|
281
|
+
observedAt: observedAt ?? null,
|
|
282
|
+
cards: cardResults,
|
|
283
|
+
capacity,
|
|
284
|
+
proposedDispatches,
|
|
285
|
+
authorityCreated: false,
|
|
286
|
+
};
|
|
287
|
+
// Internal diagnostic hash only (§5): canonical input is the closed public
|
|
288
|
+
// result, serialized by the existing canonical JSON helper and SHA-256
|
|
289
|
+
// hashed. It grants no authority and is not part of the public shape.
|
|
290
|
+
const diagnosticHash = sha256Hex(canonicalJsonString(scan));
|
|
291
|
+
return { scan, diagnosticHash };
|
|
292
|
+
}
|