@evoclock/pi-agentic-driver 0.8.4 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -27
- package/extensions/herdr-communication.ts +8 -1
- package/extensions/herdr-lifecycle.ts +4 -3
- package/extensions/pulse.ts +74 -0
- package/package.json +6 -2
- package/scripts/enforcement/herdr_async_dispatch_pi.js +152 -18
- package/scripts/enforcement/herdr_communication_pi.js +42 -11
- package/scripts/enforcement/pulse_core_pi.js +268 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +920 -0
- package/scripts/enforcement/task_board_core_pi.js +1331 -3
- package/skills/pulse/SKILL.md +39 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
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
|
+
export function modelUsable(availability) {
|
|
53
|
+
return availability === "available" || availability === "full";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Composed free capacity for one role/model route (§4.2):
|
|
57
|
+
// free = min(configured route ceiling, role ceiling, policy-wide ceiling)
|
|
58
|
+
// - unique active work for role/model
|
|
59
|
+
// Active work is deduplicated by attempt identity across claims and verified
|
|
60
|
+
// sessions; a session bound to an active claim counts once.
|
|
61
|
+
export function freeCapacity({ role, model, pulsePolicy, activeClaims = [], activeSessions = [], providerLimit = null }) {
|
|
62
|
+
const routing = pulsePolicy?.routing ?? {};
|
|
63
|
+
const roleEntry = routing[role];
|
|
64
|
+
const routes = [...(roleEntry?.preferred ?? []), ...(roleEntry?.fallback ?? [])];
|
|
65
|
+
const route = routes.find((entry) => entry?.model === model) ?? null;
|
|
66
|
+
if (!route) return { free: 0, configured: 0, reason: `model "${model}" is not a declared route for role "${role}"` };
|
|
67
|
+
|
|
68
|
+
// Deduplicate active work by attempt identity (envelope id when present,
|
|
69
|
+
// else session id). A session matching an active claim counts once.
|
|
70
|
+
const claimIdentities = new Set(
|
|
71
|
+
(activeClaims ?? [])
|
|
72
|
+
.filter((claim) => claim?.role === role && claimModel(claim) === model)
|
|
73
|
+
.map((claim) => claim?.envelopeId ?? claim?.envelope?.envelopeId ?? `claim:${claim?.cardId}`)
|
|
74
|
+
.filter((id) => typeof id === "string"),
|
|
75
|
+
);
|
|
76
|
+
const boundSessionIds = new Set(
|
|
77
|
+
(activeClaims ?? []).map((claim) => claim?.sessionId).filter((id) => typeof id === "string"),
|
|
78
|
+
);
|
|
79
|
+
const orphanSessions = (activeSessions ?? []).filter((session) => {
|
|
80
|
+
if (session?.role !== role || session?.model !== model) return false;
|
|
81
|
+
if (typeof session?.sessionId !== "string") return false;
|
|
82
|
+
if (boundSessionIds.has(session.sessionId)) return false;
|
|
83
|
+
// An observed orphan session counts separately only after reconciliation
|
|
84
|
+
// cannot bind it to an active attempt; ambiguity consumes capacity (§4.2).
|
|
85
|
+
return session.reconciled === true || session.reconciled === undefined;
|
|
86
|
+
});
|
|
87
|
+
const active = claimIdentities.size + orphanSessions.length;
|
|
88
|
+
|
|
89
|
+
const routeCeiling = route.maxConcurrent;
|
|
90
|
+
const roleCeiling = roleEntry?.maxConcurrent ?? routeCeiling;
|
|
91
|
+
const policyCeiling = Number.isInteger(pulsePolicy?.globalMaxConcurrent)
|
|
92
|
+
? pulsePolicy.globalMaxConcurrent
|
|
93
|
+
: null;
|
|
94
|
+
const configured = Math.min(routeCeiling, roleCeiling, policyCeiling ?? routeCeiling);
|
|
95
|
+
const providerCap = Number.isInteger(providerLimit) && providerLimit >= 0 ? providerLimit : null;
|
|
96
|
+
const ceiling = providerCap === null ? configured : Math.min(configured, providerCap);
|
|
97
|
+
return { free: Math.max(0, ceiling - active), configured, reason: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The model a claim is running under. Claims carry the exact model on their
|
|
101
|
+
// envelope when Pulse created them; legacy claims without one count against
|
|
102
|
+
// no specific model route — we attribute them to nothing rather than guess
|
|
103
|
+
// (no silent substitution).
|
|
104
|
+
function claimModel(claim) {
|
|
105
|
+
return typeof claim?.envelope?.model === "string" ? claim.envelope.model : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Ordered candidate routes for a role: preferred first, then fallback in
|
|
109
|
+
// declaration order (§3.1). Fallback is considered only when preferred
|
|
110
|
+
// routes are unavailable or full.
|
|
111
|
+
export function routesForRole(role, pulsePolicy) {
|
|
112
|
+
const entry = pulsePolicy?.routing?.[role];
|
|
113
|
+
if (!entry) return [];
|
|
114
|
+
return [
|
|
115
|
+
...entry.preferred.map((route) => ({ ...route, tier: "preferred" })),
|
|
116
|
+
...entry.fallback.map((route) => ({ ...route, tier: "fallback" })),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Evaluate one card and return exactly one Pulse result plus a reason (§5).
|
|
121
|
+
// Inputs are already trusted: the card comes from parseBoard/validateBoard,
|
|
122
|
+
// the policy from checkAutomationPolicy. Capacity never changes a card
|
|
123
|
+
// result — it only changes `capacity` and `proposedDispatches`.
|
|
124
|
+
export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, acceptedRepositories = null }) {
|
|
125
|
+
// Provenance verification is the caller adapter's responsibility: it
|
|
126
|
+
// supplies cards with `statePath` only when the trusted writer state is
|
|
127
|
+
// available. The core stays side-effect-free and file-free.
|
|
128
|
+
const failed = isDispatchable(card, boardIndex).failedConditions ?? [];
|
|
129
|
+
const claimed = (activeClaims ?? []).some((claim) => claim?.cardId === card.cardId);
|
|
130
|
+
if (claimed) failed.push("card is already claimed by an active authenticated attempt");
|
|
131
|
+
if (failed.length > 0) {
|
|
132
|
+
const text = failed.join("; ");
|
|
133
|
+
if (claimed) {
|
|
134
|
+
return { result: "BLOCKED", reason: "card is already claimed by an active authenticated attempt" };
|
|
135
|
+
}
|
|
136
|
+
if (/hash|provenance|authority|tampered/.test(text)) {
|
|
137
|
+
return { result: "STALE", reason: text };
|
|
138
|
+
}
|
|
139
|
+
if (/expired|revoked|prohibited|not declared|placement|risk/i.test(text)) {
|
|
140
|
+
return { result: "DENIED", reason: text };
|
|
141
|
+
}
|
|
142
|
+
if (/dependenc|lane|blocked|cancelled|flag/i.test(text)) {
|
|
143
|
+
return { result: "BLOCKED", reason: text };
|
|
144
|
+
}
|
|
145
|
+
return { result: "REVIEW_REQUIRED", reason: text };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Policy gate (§5 DENIED): no policy, expired/revoked policy, or a role the
|
|
149
|
+
// policy does not declare and route.
|
|
150
|
+
if (!isPlainObject(pulsePolicy) || pulsePolicy.enabled !== true) {
|
|
151
|
+
return { result: "REVIEW_REQUIRED", reason: "Pulse is not enabled for this board" };
|
|
152
|
+
}
|
|
153
|
+
const role = typeof card.role === "string" && card.role !== "" ? card.role : "implementer";
|
|
154
|
+
const routes = routesForRole(role, pulsePolicy);
|
|
155
|
+
if (routes.length === 0) {
|
|
156
|
+
return { result: "DENIED", reason: `no Pulse route is declared for role "${role}" (fails closed)` };
|
|
157
|
+
}
|
|
158
|
+
// Repository gate (§9): the card must identify a repository the policy
|
|
159
|
+
// accepts. A card naming exactly one repository is unambiguous; otherwise
|
|
160
|
+
// a single-repository policy supplies the default. Ambiguity fails closed.
|
|
161
|
+
if (Array.isArray(acceptedRepositories) && acceptedRepositories.length > 0) {
|
|
162
|
+
const named = Array.isArray(card.repositories) ? card.repositories.filter((r) => typeof r === "string" && r !== "") : [];
|
|
163
|
+
const repository = named.length === 1 ? named[0]
|
|
164
|
+
: named.length === 0 && acceptedRepositories.length === 1 ? acceptedRepositories[0]
|
|
165
|
+
: null;
|
|
166
|
+
if (repository === null || !acceptedRepositories.includes(repository)) {
|
|
167
|
+
return { result: "DENIED", reason: "card repository is ambiguous or not in the policy acceptedRepositories (fails closed)" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Route availability: at least one declared route must be usable. Without
|
|
171
|
+
// an authorised fallback this is REVIEW_REQUIRED (§5), never a substitution.
|
|
172
|
+
const usable = routes.filter((route) => modelUsable(modelAvailability({
|
|
173
|
+
model: route.model, observations: modelObservations, declaredModels,
|
|
174
|
+
})));
|
|
175
|
+
if (usable.length === 0) {
|
|
176
|
+
return { result: "REVIEW_REQUIRED", reason: `no usable declared route for role "${role}" (preferred unavailable without authorised fallback)` };
|
|
177
|
+
}
|
|
178
|
+
return { result: "READY_FOR_NEXT", reason: null, role, routes: usable };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// One full board scan (§4.3, §5). Deterministic: cards in board P0→P3 then
|
|
182
|
+
// card-ID order; capacity in role-then-route declaration order; proposals in
|
|
183
|
+
// 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 }) {
|
|
185
|
+
const index = new Map(cards.map((card) => [card.cardId, card]));
|
|
186
|
+
const ordered = [...cards].sort((a, b) =>
|
|
187
|
+
(PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99)
|
|
188
|
+
|| String(a.cardId).localeCompare(String(b.cardId)));
|
|
189
|
+
|
|
190
|
+
const cardResults = [];
|
|
191
|
+
const capacity = [];
|
|
192
|
+
const proposedDispatches = [];
|
|
193
|
+
const policyWideRemaining = Number.isInteger(pulsePolicy?.globalMaxConcurrent)
|
|
194
|
+
? pulsePolicy.globalMaxConcurrent - (activeClaims ?? []).length
|
|
195
|
+
: null;
|
|
196
|
+
|
|
197
|
+
const roleOrder = pulsePolicy?.routing ? Object.keys(pulsePolicy.routing) : [];
|
|
198
|
+
for (const role of roleOrder) {
|
|
199
|
+
for (const route of routesForRole(role, pulsePolicy)) {
|
|
200
|
+
const availability = modelAvailability({ model: route.model, observations: modelObservations, declaredModels });
|
|
201
|
+
const free = freeCapacity({ role, model: route.model, pulsePolicy, activeClaims });
|
|
202
|
+
capacity.push({
|
|
203
|
+
role, model: route.model, configured: free.configured,
|
|
204
|
+
activeClaims: (activeClaims ?? []).filter((claim) => claim?.role === role && claimModel(claim) === route.model).length,
|
|
205
|
+
startingOrRunning: 0,
|
|
206
|
+
providerLimit: null,
|
|
207
|
+
free: availability === "available" || availability === "full" ? free.free : 0,
|
|
208
|
+
availability,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const remainingByRoute = new Map(capacity.map((entry) => [`${entry.role}\u0000${entry.model}`, entry.free]));
|
|
214
|
+
let globalRemaining = policyWideRemaining;
|
|
215
|
+
// Placement for proposals is supplied by the scheduler adapter (the pulse
|
|
216
|
+
// object does not carry it; the policy's placement field governs). In
|
|
217
|
+
// automated mode containment is the default when available (§3.2); the core
|
|
218
|
+
// never decides containment availability.
|
|
219
|
+
const scanPlacement = placement;
|
|
220
|
+
|
|
221
|
+
for (const card of ordered) {
|
|
222
|
+
const evaluation = evaluateCard({
|
|
223
|
+
card, boardIndex: index, boardPath, activeClaims, pulsePolicy,
|
|
224
|
+
modelObservations, declaredModels,
|
|
225
|
+
acceptedRepositories: pulsePolicy?.acceptedRepositories ?? null,
|
|
226
|
+
});
|
|
227
|
+
const entry = { title: card.title, role: evaluation.role ?? (typeof card.role === "string" && card.role !== "" ? card.role : "implementer"), result: evaluation.result, reason: evaluation.reason };
|
|
228
|
+
cardResults.push(entry);
|
|
229
|
+
if (evaluation.result !== "READY_FOR_NEXT") continue;
|
|
230
|
+
|
|
231
|
+
// Propose at most free capacity and policy-wide remaining capacity (§4.3
|
|
232
|
+
// step 5): preferred routes first, then declared fallback, in order.
|
|
233
|
+
let proposed = false;
|
|
234
|
+
for (const route of evaluation.routes) {
|
|
235
|
+
const key = `${entry.role}\u0000${route.model}`;
|
|
236
|
+
const remaining = remainingByRoute.get(key) ?? 0;
|
|
237
|
+
if (remaining <= 0) continue;
|
|
238
|
+
if (globalRemaining !== null && globalRemaining <= 0) break;
|
|
239
|
+
remainingByRoute.set(key, remaining - 1);
|
|
240
|
+
if (globalRemaining !== null) globalRemaining -= 1;
|
|
241
|
+
proposedDispatches.push({ title: card.title, role: entry.role, model: route.model, placement: scanPlacement });
|
|
242
|
+
proposed = true;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
if (!proposed && evaluation.routes.length > 0) {
|
|
246
|
+
// Ready but no free slot: the card result stays READY_FOR_NEXT (§5);
|
|
247
|
+
// capacity alone gates the proposal.
|
|
248
|
+
entry.reason = "ready; no free capacity on any declared route this tick";
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const scan = {
|
|
253
|
+
schema: PULSE_SCAN_SCHEMA,
|
|
254
|
+
board: boardPath,
|
|
255
|
+
boardRevision,
|
|
256
|
+
policyRevision,
|
|
257
|
+
observedAt: observedAt ?? null,
|
|
258
|
+
cards: cardResults,
|
|
259
|
+
capacity,
|
|
260
|
+
proposedDispatches,
|
|
261
|
+
authorityCreated: false,
|
|
262
|
+
};
|
|
263
|
+
// Internal diagnostic hash only (§5): canonical input is the closed public
|
|
264
|
+
// result, serialized by the existing canonical JSON helper and SHA-256
|
|
265
|
+
// hashed. It grants no authority and is not part of the public shape.
|
|
266
|
+
const diagnosticHash = sha256Hex(canonicalJsonString(scan));
|
|
267
|
+
return { scan, diagnosticHash };
|
|
268
|
+
}
|