@evoclock/pi-agentic-driver 0.10.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agentic-driver/nudge.defaults.json +11 -0
- package/.githooks/pre-commit +4 -0
- package/README.md +43 -31
- package/config/codebase-inventory.schema.json +23 -0
- package/docs/codebase-inventory.md +15 -0
- package/docs/recovery-evidence-store.md +9 -0
- package/extensions/herdr-dispatch.ts +15 -2
- package/extensions/recovery-evidence.ts +14 -0
- package/extensions/task-store-adapter.ts +101 -0
- package/extensions/tasks.ts +34 -11
- package/package.json +19 -1
- package/scripts/codebase_inventory.py +105 -0
- package/scripts/codebase_inventory_gate.py +48 -0
- package/scripts/enforcement/board_claim_bridge_pi.js +204 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +83 -30
- package/scripts/enforcement/herdr_async_seam_pi.js +347 -0
- package/scripts/enforcement/herdr_communication_pi.js +608 -95
- package/scripts/enforcement/herdr_lifecycle_pi.js +266 -13
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +6 -6
- package/scripts/enforcement/native_tui_context.js +16 -0
- package/scripts/enforcement/nudge_classifier_pi.js +176 -0
- package/scripts/enforcement/nudge_config_pi.js +234 -0
- package/scripts/enforcement/nudge_ledger_pi.js +514 -0
- package/scripts/enforcement/nudge_reason_state_pi.js +135 -0
- package/scripts/enforcement/nudge_validate_pi.js +224 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +15 -3
- package/scripts/enforcement/recovery_evidence_review_pi.js +269 -0
- package/scripts/enforcement/recovery_evidence_store_pi.js +170 -0
- package/scripts/enforcement/session_tasks_core_pi.js +14 -0
- package/scripts/enforcement/task_board_core_pi.js +96 -7
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Owner-local, non-authorizing recovery evidence. Nothing in the journey calls
|
|
5
|
+
// this module yet. The host chooses a private root; the store never chooses an
|
|
6
|
+
// implicit repository or app-data path and never evicts evidence on its own.
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { constants, closeSync, existsSync, lstatSync, mkdirSync, openSync, realpathSync } from "node:fs";
|
|
10
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
11
|
+
import { appendTerminalResult, nudgeCanonicalJsonString, validateLedgerEntry } from "./nudge_ledger_pi.js";
|
|
12
|
+
|
|
13
|
+
export const RECOVERY_EVIDENCE_SCHEMA = "agentic-driver.recovery-evidence.v1";
|
|
14
|
+
const FILE = "recovery-evidence.v1.sqlite";
|
|
15
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
16
|
+
|
|
17
|
+
function refuse(code, message) {
|
|
18
|
+
return Object.assign(new Error(message), { code });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function positiveBound(value, name) {
|
|
22
|
+
if (!Number.isSafeInteger(value) || value < 1) throw refuse("invalid-bound", `${name} must be a positive safe integer`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function privateRoot(root, create) {
|
|
27
|
+
if (typeof root !== "string" || !isAbsolute(root) || resolve(root) !== root) {
|
|
28
|
+
throw refuse("invalid-root", "a canonical absolute owner-provided root is required");
|
|
29
|
+
}
|
|
30
|
+
if (create && !existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
31
|
+
const stat = lstatSync(root);
|
|
32
|
+
if (!stat.isDirectory() || (stat.mode & 0o077) !== 0 || realpathSync(root) !== root) {
|
|
33
|
+
throw refuse("untrusted-root", "evidence root must be a private, non-symlink directory");
|
|
34
|
+
}
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function evidenceFile(root, create) {
|
|
39
|
+
const path = join(root, FILE);
|
|
40
|
+
if (create && !existsSync(path)) {
|
|
41
|
+
try {
|
|
42
|
+
const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
43
|
+
closeSync(fd);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code !== "EEXIST") throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const stat = lstatSync(path);
|
|
49
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0 || realpathSync(path) !== path) {
|
|
50
|
+
throw refuse("untrusted-file", "evidence file must be private and not a symlink");
|
|
51
|
+
}
|
|
52
|
+
return path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Explicit host root; readOnly observation never creates a directory or file. */
|
|
56
|
+
export function openRecoveryEvidenceStore({ root, readOnly = false, maxRecordBytes = 128 * 1024, maxTotalBytes = 16 * 1024 * 1024, maxRecords = 1000 } = {}) {
|
|
57
|
+
positiveBound(maxRecordBytes, "maxRecordBytes");
|
|
58
|
+
positiveBound(maxTotalBytes, "maxTotalBytes");
|
|
59
|
+
positiveBound(maxRecords, "maxRecords");
|
|
60
|
+
const path = evidenceFile(privateRoot(root, !readOnly), !readOnly);
|
|
61
|
+
const db = new DatabaseSync(path, { readOnly });
|
|
62
|
+
try {
|
|
63
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
64
|
+
if (!readOnly) db.exec(`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS evidence (
|
|
66
|
+
journey_id TEXT NOT NULL, entry_id TEXT NOT NULL,
|
|
67
|
+
phase TEXT NOT NULL CHECK(phase IN ('requested','terminal')),
|
|
68
|
+
body TEXT NOT NULL, digest TEXT NOT NULL, previous_digest TEXT,
|
|
69
|
+
bytes INTEGER NOT NULL, created_at TEXT NOT NULL,
|
|
70
|
+
PRIMARY KEY (journey_id, entry_id, phase)
|
|
71
|
+
);
|
|
72
|
+
`);
|
|
73
|
+
} catch (error) { db.close(); throw error; }
|
|
74
|
+
return { db, path, readOnly, maxRecordBytes, maxTotalBytes, maxRecords };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function closeRecoveryEvidenceStore(store) {
|
|
78
|
+
store.db.close();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function digest(text) { return createHash("sha256").update(text, "utf8").digest("hex"); }
|
|
82
|
+
|
|
83
|
+
function reference(row) {
|
|
84
|
+
return Object.freeze({ schema: RECOVERY_EVIDENCE_SCHEMA, journeyId: row.journey_id,
|
|
85
|
+
entryId: row.entry_id, phase: row.phase, sha256: row.digest, bytes: row.bytes });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fetchRow(store, { journeyId, entryId, phase }) {
|
|
89
|
+
return store.db.prepare("SELECT * FROM evidence WHERE journey_id=? AND entry_id=? AND phase=?")
|
|
90
|
+
.get(journeyId, entryId, phase);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Bounded, read-only listing of closed retrieval references; never bodies. */
|
|
94
|
+
export function listRecoveryEvidenceReferences(store, { limit = 50 } = {}) {
|
|
95
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw refuse("invalid-bound", "limit must be a positive safe integer");
|
|
96
|
+
return store.db.prepare(
|
|
97
|
+
"SELECT journey_id, entry_id, phase, digest, bytes FROM evidence ORDER BY created_at DESC, journey_id, entry_id, phase LIMIT ?",
|
|
98
|
+
).all(limit).map((row) => reference(row));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Returns exact bytes only when the caller's reference and its predecessor verify. */
|
|
102
|
+
export function readRecoveryEvidence(store, ref) {
|
|
103
|
+
if (!ref || ref.schema !== RECOVERY_EVIDENCE_SCHEMA || !["requested", "terminal"].includes(ref.phase)
|
|
104
|
+
|| typeof ref.journeyId !== "string" || !ref.journeyId || typeof ref.entryId !== "string" || !ref.entryId
|
|
105
|
+
|| !SHA256.test(ref.sha256 ?? "") || !Number.isSafeInteger(ref.bytes) || ref.bytes < 1) {
|
|
106
|
+
throw refuse("invalid-reference", "a complete evidence reference is required");
|
|
107
|
+
}
|
|
108
|
+
const row = fetchRow(store, ref);
|
|
109
|
+
if (!row) throw refuse("evidence-missing", "referenced recovery evidence is unavailable");
|
|
110
|
+
if (row.digest !== ref.sha256 || row.bytes !== ref.bytes || digest(row.body) !== row.digest
|
|
111
|
+
|| Buffer.byteLength(row.body, "utf8") !== row.bytes) {
|
|
112
|
+
throw refuse("evidence-corrupt", "recovery evidence digest or size mismatch");
|
|
113
|
+
}
|
|
114
|
+
let entry;
|
|
115
|
+
try { entry = JSON.parse(row.body); } catch { throw refuse("evidence-corrupt", "recovery evidence is not valid JSON"); }
|
|
116
|
+
if (entry.journeyId !== ref.journeyId || entry.entryId !== ref.entryId || !validateLedgerEntry(entry).ok
|
|
117
|
+
|| (ref.phase === "requested" && entry.result !== "requested")
|
|
118
|
+
|| (ref.phase === "terminal" && entry.result === "requested")) {
|
|
119
|
+
throw refuse("evidence-corrupt", "recovery evidence does not match its identity and phase");
|
|
120
|
+
}
|
|
121
|
+
if (ref.phase === "terminal") {
|
|
122
|
+
const parent = fetchRow(store, { ...ref, phase: "requested" });
|
|
123
|
+
if (!parent || row.previous_digest !== parent.digest || digest(parent.body) !== parent.digest) {
|
|
124
|
+
throw refuse("evidence-corrupt", "terminal evidence has no intact requested predecessor");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { reference: reference(row), entry };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function append(store, entry, phase, previousDigest = null) {
|
|
131
|
+
if (store.readOnly) throw refuse("read-only", "evidence store is read-only");
|
|
132
|
+
const body = nudgeCanonicalJsonString(entry);
|
|
133
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
134
|
+
if (bytes > store.maxRecordBytes) throw refuse("evidence-too-large", "recovery record exceeds per-record bound");
|
|
135
|
+
store.db.exec("BEGIN IMMEDIATE;");
|
|
136
|
+
try {
|
|
137
|
+
const usage = store.db.prepare("SELECT COUNT(*) AS count, COALESCE(SUM(bytes),0) AS bytes FROM evidence").get();
|
|
138
|
+
if (usage.count >= store.maxRecords || usage.bytes + bytes > store.maxTotalBytes) {
|
|
139
|
+
throw refuse("evidence-capacity", "owner review or increased capacity is required; unreviewed evidence is never evicted");
|
|
140
|
+
}
|
|
141
|
+
store.db.prepare("INSERT INTO evidence VALUES (?,?,?,?,?,?,?,?)").run(
|
|
142
|
+
entry.journeyId, entry.entryId, phase, body, digest(body), previousDigest, bytes, new Date().toISOString());
|
|
143
|
+
store.db.exec("COMMIT;");
|
|
144
|
+
} catch (error) {
|
|
145
|
+
store.db.exec("ROLLBACK;");
|
|
146
|
+
if (String(error.message).startsWith("UNIQUE constraint failed: evidence.")) {
|
|
147
|
+
throw refuse("evidence-duplicate", "recovery phase already recorded");
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
return reference({ journey_id: entry.journeyId, entry_id: entry.entryId, phase, digest: digest(body), bytes });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Must succeed before the caller attempts any nudge or replacement. */
|
|
155
|
+
export function recordRecoveryRequest(store, entry) {
|
|
156
|
+
const valid = validateLedgerEntry(entry);
|
|
157
|
+
if (!valid.ok || entry.result !== "requested") throw refuse("invalid-entry", "a valid requested ledger entry is required");
|
|
158
|
+
return append(store, entry, "requested");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A failed post-action write is ambiguous; callers must retain their identity lock. */
|
|
162
|
+
export function recordRecoveryResult(store, requestedRef, terminal) {
|
|
163
|
+
if (!requestedRef || requestedRef.phase !== "requested") throw refuse("invalid-reference", "requested evidence reference required");
|
|
164
|
+
const requested = readRecoveryEvidence(store, requestedRef);
|
|
165
|
+
if (fetchRow(store, { ...requestedRef, phase: "terminal" })) {
|
|
166
|
+
throw refuse("evidence-duplicate", "terminal evidence already recorded; never replay an action");
|
|
167
|
+
}
|
|
168
|
+
const [entry] = appendTerminalResult([requested.entry], requested.entry.entryId, terminal);
|
|
169
|
+
return append(store, entry, "terminal", requestedRef.sha256);
|
|
170
|
+
}
|
|
@@ -36,6 +36,7 @@ export const TASK_PROMOTION_SCHEMA = "agentic-driver.task-promotion.v1";
|
|
|
36
36
|
export const TASK_STATE_ENTRY = "picc-tasks-state";
|
|
37
37
|
export const SESSION_TASK_STATUSES = Object.freeze(["pending", "in_progress", "completed"]);
|
|
38
38
|
export const TASK_LIST_STATUSES = Object.freeze(["pending", "in_progress", "completed"]);
|
|
39
|
+
export const SESSION_TASK_PRIORITIES = Object.freeze(["P0", "P1", "P2", "P3"]);
|
|
39
40
|
export const ORIGIN_VALUES = Object.freeze(["coordinator", "worker"]);
|
|
40
41
|
export const LEGACY_UNKNOWN_ORIGIN = "legacy-unknown";
|
|
41
42
|
export const PROMOTED_TASK_STATUSES = Object.freeze(["pending", "in_progress"]);
|
|
@@ -43,14 +44,27 @@ export const PROMOTION_LANES = Object.freeze(["backlog", "in-progress"]);
|
|
|
43
44
|
export const COMPLETED_TASK_POLICY = Object.freeze(["skip", "promote-as-done"]);
|
|
44
45
|
export const BATCH_END_POLICY = Object.freeze(["offered", "off"]);
|
|
45
46
|
export const TRUSTED_HERDR_EXECUTABLE = "/opt/homebrew/bin/herdr";
|
|
47
|
+
export const PROVENANCE_CAPTURE_STATUSES = Object.freeze([
|
|
48
|
+
"captured", "host-session-unavailable", "task-list-mismatch", "source-unavailable",
|
|
49
|
+
]);
|
|
46
50
|
export const DEFAULT_PROMOTION_LANE = "backlog";
|
|
47
51
|
|
|
52
|
+
// Deterministic opaque display token for model-visible evidence. Session task
|
|
53
|
+
// IDs are unbounded caller-controlled strings that can carry secret-like text;
|
|
54
|
+
// only this full 64-hex SHA-256 token (no lossy truncation, no fabricated
|
|
55
|
+
// identity) is ever displayed. The raw ID stays inside the task store for
|
|
56
|
+
// selection, dispatch, and provenance lookups.
|
|
57
|
+
export function sessionTaskDisplayId(id) {
|
|
58
|
+
return createHash("sha256").update(`agentic-driver.session-task-display.v1:${String(id ?? "")}`, "utf8").digest("hex");
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
/** picc-tasks task shape: the closed snapshot shape replay and migration read. */
|
|
49
62
|
export function validateSessionTask(value) {
|
|
50
63
|
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
51
64
|
if (typeof value.id !== "string" || value.id === "") return false;
|
|
52
65
|
if (!SESSION_TASK_STATUSES.includes(value.status)) return false;
|
|
53
66
|
if (typeof value.subject !== "string" || typeof value.description !== "string") return false;
|
|
67
|
+
if (value.priority !== undefined && !SESSION_TASK_PRIORITIES.includes(value.priority)) return false;
|
|
54
68
|
for (const field of ["blocks", "blockedBy"]) {
|
|
55
69
|
if (!Array.isArray(value[field]) || value[field].some((entry) => typeof entry !== "string")) return false;
|
|
56
70
|
}
|
|
@@ -379,6 +379,75 @@ function safeJsonParse(text) {
|
|
|
379
379
|
}
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
const CAPABILITY_REGISTRY_PATH = ".vogelkop/capabilities.json";
|
|
383
|
+
|
|
384
|
+
function capabilityRegistryError(registryPath, reason) {
|
|
385
|
+
return Object.assign(new Error(`capability registry is invalid at ${registryPath}: ${reason}`), {
|
|
386
|
+
code: "capability-registry-invalid",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The workspace registry is deliberately loaded at tool-call time. A missing
|
|
391
|
+
// registry is an empty declaration; an unreadable or malformed present file is
|
|
392
|
+
// not silently downgraded to empty. Capability-less boards are independent of
|
|
393
|
+
// the registry, so they retain the writer's existing behavior even while an
|
|
394
|
+
// unused registry file is malformed.
|
|
395
|
+
function loadWorkspaceCapabilityNames(boardPath) {
|
|
396
|
+
const registryPath = join(dirname(boardPath), CAPABILITY_REGISTRY_PATH);
|
|
397
|
+
let text;
|
|
398
|
+
try {
|
|
399
|
+
text = readFileSync(registryPath, "utf8");
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (error?.code === "ENOENT") return [];
|
|
402
|
+
throw capabilityRegistryError(registryPath, String(error?.message || error).slice(0, 256));
|
|
403
|
+
}
|
|
404
|
+
let entries;
|
|
405
|
+
try {
|
|
406
|
+
entries = JSON.parse(text);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
throw capabilityRegistryError(registryPath, `invalid JSON: ${String(error?.message || error).slice(0, 256)}`);
|
|
409
|
+
}
|
|
410
|
+
if (!Array.isArray(entries)) {
|
|
411
|
+
throw capabilityRegistryError(registryPath, "expected a JSON array");
|
|
412
|
+
}
|
|
413
|
+
const names = [];
|
|
414
|
+
for (const [index, entry] of entries.entries()) {
|
|
415
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)
|
|
416
|
+
|| typeof entry.name !== "string" || entry.name.trim() === ""
|
|
417
|
+
|| (entry.description !== undefined && typeof entry.description !== "string")) {
|
|
418
|
+
throw capabilityRegistryError(registryPath, `entry ${index} must contain a non-empty string name and an optional string description`);
|
|
419
|
+
}
|
|
420
|
+
names.push(entry.name);
|
|
421
|
+
}
|
|
422
|
+
return names;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function boardHasCapabilities(boardPath) {
|
|
426
|
+
if (!existsSync(boardPath)) return false;
|
|
427
|
+
try {
|
|
428
|
+
const parsed = parseBoard(readFileSync(boardPath, "utf8"));
|
|
429
|
+
return parsed.cards.some((card) => Array.isArray(card.capabilities) && card.capabilities.length > 0);
|
|
430
|
+
} catch {
|
|
431
|
+
// Let the writer report the board error; do not use an unreadable board to
|
|
432
|
+
// justify ignoring a malformed capability registry.
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function workspaceRegistriesFor(boardPath, requestedCapabilities) {
|
|
438
|
+
try {
|
|
439
|
+
return { capabilities: loadWorkspaceCapabilityNames(boardPath) };
|
|
440
|
+
} catch (error) {
|
|
441
|
+
const capabilityRequested = requestedCapabilities !== undefined
|
|
442
|
+
&& requestedCapabilities !== null
|
|
443
|
+
&& (!Array.isArray(requestedCapabilities) || requestedCapabilities.length > 0);
|
|
444
|
+
if (error?.code === "capability-registry-invalid" && !capabilityRequested && !boardHasCapabilities(boardPath)) {
|
|
445
|
+
return { capabilities: [] };
|
|
446
|
+
}
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
382
451
|
// ---------------------------------------------------------------------------
|
|
383
452
|
// Closed validator (§1, §6 gate 2). Validate before persist; decline on
|
|
384
453
|
// violation; duplicate cardId is a validation error; decorative use of the
|
|
@@ -1708,7 +1777,11 @@ const PULSE_FIELDS = Object.freeze([
|
|
|
1708
1777
|
]);
|
|
1709
1778
|
const PULSE_ROUTE_FIELDS = Object.freeze(["model", "maxConcurrent"]);
|
|
1710
1779
|
const PULSE_MODES = Object.freeze(["interactive", "automated"]);
|
|
1711
|
-
|
|
1780
|
+
// Pi model IDs are provider/model-id. The provider is one bounded segment;
|
|
1781
|
+
// model-id may contain bounded nested segments and Pi's documented colon
|
|
1782
|
+
// punctuation. The separate total-length check keeps the complete ID bounded.
|
|
1783
|
+
const PULSE_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127})+$/;
|
|
1784
|
+
const PULSE_MODEL_MAX_LENGTH = 192;
|
|
1712
1785
|
|
|
1713
1786
|
function pulseCapacityOk(value) {
|
|
1714
1787
|
return Number.isInteger(value) && value >= 1 && value <= 32;
|
|
@@ -1719,8 +1792,9 @@ function checkPulseRouteEntry(entry, { seen }) {
|
|
|
1719
1792
|
|| !exactKeys(entry, PULSE_ROUTE_FIELDS)) {
|
|
1720
1793
|
return "each routing entry must have exactly {model, maxConcurrent}";
|
|
1721
1794
|
}
|
|
1722
|
-
if (typeof entry.model !== "string" ||
|
|
1723
|
-
|
|
1795
|
+
if (typeof entry.model !== "string" || entry.model.length > PULSE_MODEL_MAX_LENGTH
|
|
1796
|
+
|| !PULSE_MODEL_RE.test(entry.model)) {
|
|
1797
|
+
return `route model "${entry.model}" is not an exact bounded provider/model identifier`;
|
|
1724
1798
|
}
|
|
1725
1799
|
if (seen.has(entry.model)) {
|
|
1726
1800
|
return `model "${entry.model}" appears more than once for this role`;
|
|
@@ -2169,7 +2243,20 @@ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, rep
|
|
|
2169
2243
|
if (activeClaims.length >= concurrency) {
|
|
2170
2244
|
return { ok: false, code: "policy-concurrency-refused", reason: `the automation policy allows at most ${concurrency} concurrent claim(s); ${activeClaims.length} are active` };
|
|
2171
2245
|
}
|
|
2172
|
-
|
|
2246
|
+
// Pulse policies also carry a role-wide ceiling. Enforce it here, under the
|
|
2247
|
+
// writer lock and after expired claims are released, so concurrent claimers
|
|
2248
|
+
// cannot race past the route's pre-scan capacity check.
|
|
2249
|
+
const roleConcurrency = policyCheck.policy.pulse?.routing?.[role]?.maxConcurrent;
|
|
2250
|
+
if (Number.isInteger(roleConcurrency)) {
|
|
2251
|
+
const activeForRole = activeClaims.filter((claim) => claim.role === role).length;
|
|
2252
|
+
if (activeForRole >= roleConcurrency) {
|
|
2253
|
+
return { ok: false, code: "policy-role-concurrency-refused", reason: `the automation policy allows role "${role}" at most ${roleConcurrency} concurrent claim(s); ${activeForRole} are active` };
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
let registries;
|
|
2257
|
+
try { registries = workspaceRegistriesFor(boardPath); }
|
|
2258
|
+
catch (error) { return { ok: false, code: error?.code ?? "capability-registry-invalid", reason: error?.message ?? String(error) }; }
|
|
2259
|
+
const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), registries);
|
|
2173
2260
|
if (!validatedBoard.ok) {
|
|
2174
2261
|
return { ok: false, code: "board-invalid", errors: validatedBoard.errors };
|
|
2175
2262
|
}
|
|
@@ -2898,11 +2985,12 @@ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPat
|
|
|
2898
2985
|
// reason), never raw throws — the model sees the governance reason.
|
|
2899
2986
|
let result;
|
|
2900
2987
|
try {
|
|
2988
|
+
const registries = workspaceRegistriesFor(activeBoardPath, input?.capabilities);
|
|
2901
2989
|
result = writeCard({
|
|
2902
2990
|
boardPath: activeBoardPath,
|
|
2903
2991
|
input: writerInput,
|
|
2904
2992
|
authority: input?.authority,
|
|
2905
|
-
registries
|
|
2993
|
+
registries,
|
|
2906
2994
|
surface: "tasks",
|
|
2907
2995
|
// No requireExistingBoard: the write tool bootstraps a fresh
|
|
2908
2996
|
// board when none exists. Recreation is safe — fresh content only,
|
|
@@ -3028,9 +3116,10 @@ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPat
|
|
|
3028
3116
|
}
|
|
3029
3117
|
let result;
|
|
3030
3118
|
try {
|
|
3119
|
+
const registries = workspaceRegistriesFor(activeBoardPath, operation === "update" ? input?.capabilities : undefined);
|
|
3031
3120
|
result = operation === "update"
|
|
3032
|
-
? updateCard({ boardPath: activeBoardPath, cardId: input.cardId, changes, authority: input?.authority, registries
|
|
3033
|
-
: deleteCard({ boardPath: activeBoardPath, cardId: input.cardId, authority: input?.authority, registries
|
|
3121
|
+
? updateCard({ boardPath: activeBoardPath, cardId: input.cardId, changes, authority: input?.authority, registries, surface: "tasks" })
|
|
3122
|
+
: deleteCard({ boardPath: activeBoardPath, cardId: input.cardId, authority: input?.authority, registries, surface: "tasks" });
|
|
3034
3123
|
} catch (error) {
|
|
3035
3124
|
const code = typeof error?.code === "string" ? error.code : "writer-error";
|
|
3036
3125
|
const value = { ok: false, persisted: false, code, reason: String(error?.message || error).slice(0, 512), errors: [String(error?.message || error).slice(0, 512)] };
|