@evoclock/pi-agentic-driver 0.9.3 → 0.9.4

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.
@@ -0,0 +1,605 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // Session task list + owner-initiated board promotion (tasks #60 + #63,
5
+ // revised v1 scope). Core module: closed schemas, snapshot persistence,
6
+ // idempotency keys, dependency reconciliation, and promotion previews.
7
+ //
8
+ // Authority model:
9
+ // - The session task list is working memory. Any session may write it.
10
+ // - TASKS.md is written by the trusted writer ONLY
11
+ // (task_board_core_pi.js writeCard/updateCard). This module is a writer
12
+ // CLIENT: it never serializes or writes board bytes itself.
13
+ // - Promotion is owner-initiated only. It requires a fresh, verbatim
14
+ // owner quotedInstruction supplied through the owner-facing tool call.
15
+ // It is never automatic, never a session-shutdown hook, and never
16
+ // worker-authorized. A worker cannot promote (resolveCallerOrigin).
17
+ //
18
+ // Origin provenance (defense in depth, not authentication): caller origin
19
+ // is observed, not trusted. Unproven identity is recorded as
20
+ // "legacy-unknown" audit metadata. Origin metadata NEVER substitutes for
21
+ // the fresh owner authority record.
22
+
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { spawnSync } from "node:child_process";
27
+ import { createHash } from "node:crypto";
28
+ import { isValidAuthoritySource, parseBoard } from "./task_board_core_pi.js";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Closed schemas (SPEC §6.3)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export const SESSION_TASKS_SCHEMA = "agentic-driver.session-tasks.v1";
35
+ export const TASK_PROMOTION_SCHEMA = "agentic-driver.task-promotion.v1";
36
+ export const TASK_STATE_ENTRY = "picc-tasks-state";
37
+ export const SESSION_TASK_STATUSES = Object.freeze(["pending", "in_progress", "completed"]);
38
+ export const TASK_LIST_STATUSES = Object.freeze(["pending", "in_progress", "completed"]);
39
+ export const ORIGIN_VALUES = Object.freeze(["coordinator", "worker"]);
40
+ export const LEGACY_UNKNOWN_ORIGIN = "legacy-unknown";
41
+ export const PROMOTED_TASK_STATUSES = Object.freeze(["pending", "in_progress"]);
42
+ export const PROMOTION_LANES = Object.freeze(["backlog", "in-progress"]);
43
+ export const COMPLETED_TASK_POLICY = Object.freeze(["skip", "promote-as-done"]);
44
+ export const BATCH_END_POLICY = Object.freeze(["offered", "off"]);
45
+ export const TRUSTED_HERDR_EXECUTABLE = "/opt/homebrew/bin/herdr";
46
+ export const DEFAULT_PROMOTION_LANE = "backlog";
47
+
48
+ /** picc-tasks task shape: the closed snapshot shape replay and migration read. */
49
+ export function validateSessionTask(value) {
50
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
51
+ if (typeof value.id !== "string" || value.id === "") return false;
52
+ if (!SESSION_TASK_STATUSES.includes(value.status)) return false;
53
+ if (typeof value.subject !== "string" || typeof value.description !== "string") return false;
54
+ for (const field of ["blocks", "blockedBy"]) {
55
+ if (!Array.isArray(value[field]) || value[field].some((entry) => typeof entry !== "string")) return false;
56
+ }
57
+ for (const field of ["activeForm", "owner"]) {
58
+ if (value[field] !== undefined && value[field] !== null && typeof value[field] !== "string") return false;
59
+ }
60
+ if (value.metadata !== undefined && value.metadata !== null
61
+ && (typeof value.metadata !== "object" || Array.isArray(value.metadata))) return false;
62
+ return true;
63
+ }
64
+
65
+ /**
66
+ * Exact-shape snapshot check. `exact: true` (promotion requests) rejects
67
+ * unknown keys; `exact: false` (snapshots from disk/branch) tolerates the
68
+ * fields other tooling may add, matching picc-tasks' tolerant loader.
69
+ */
70
+ export function validateSnapshot(value, { exact = false } = {}) {
71
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
72
+ if (!Number.isInteger(value.highWaterMark) || value.highWaterMark < 0) return false;
73
+ if (!Array.isArray(value.tasks)) return false;
74
+ if (exact) {
75
+ const keys = Object.keys(value);
76
+ for (const key of keys) {
77
+ if (key !== "tasks" && key !== "highWaterMark") return false;
78
+ }
79
+ }
80
+ return value.tasks.every((task) => validateSessionTask(task));
81
+ }
82
+
83
+ /** Promotion request: exact keys, closed enums, non-empty task list. */
84
+ export function validatePromotionRequest(value) {
85
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
86
+ const allowed = new Set(["schema", "taskIds", "batch", "authority", "laneOverrides", "completedPolicy"]);
87
+ for (const key of Object.keys(value)) {
88
+ if (!allowed.has(key)) return false;
89
+ }
90
+ if (value.schema !== TASK_PROMOTION_SCHEMA) return false;
91
+ if (!Array.isArray(value.taskIds) || value.taskIds.length === 0
92
+ || !value.taskIds.every((id) => typeof id === "string" && id !== "")) return false;
93
+ if (typeof value.batch !== "boolean") return false;
94
+ if (!isValidAuthoritySource(value.authority)) return false;
95
+ if (typeof value.authority.quotedInstruction !== "string"
96
+ || value.authority.quotedInstruction.trim() === "") return false;
97
+ if (value.laneOverrides !== undefined) {
98
+ if (value.laneOverrides === null || typeof value.laneOverrides !== "object"
99
+ || Array.isArray(value.laneOverrides)) return false;
100
+ for (const [id, lane] of Object.entries(value.laneOverrides)) {
101
+ if (typeof id !== "string" || id === "" || !PROMOTION_LANES.includes(lane)) return false;
102
+ }
103
+ }
104
+ if (value.completedPolicy !== undefined && !COMPLETED_TASK_POLICY.includes(value.completedPolicy)) return false;
105
+ return true;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // taskListId + persistence (picc-tasks-compatible)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** taskListId = PI_TASK_LIST_ID ?? CLAUDE_CODE_TASK_LIST_ID ?? session ID. */
113
+ export function resolveTaskListId({ session } = {}) {
114
+ const fromEnv = process.env.PI_TASK_LIST_ID ?? process.env.CLAUDE_CODE_TASK_LIST_ID;
115
+ if (typeof fromEnv === "string" && fromEnv.trim()) return fromEnv.trim();
116
+ const sessionId = typeof session === "string" ? session.trim() : "";
117
+ if (!sessionId) return null;
118
+ return sessionId;
119
+ }
120
+
121
+ export function tasksMirrorPath(taskListId, { tasksRoot = null } = {}) {
122
+ if (typeof taskListId !== "string" || taskListId.trim() === "") return null;
123
+ const root = tasksRoot ?? join(homedir(), ".pi", "tasks");
124
+ return join(root, taskListId, "tasks.json");
125
+ }
126
+
127
+ /** Load a picc-tasks mirror file tolerantly (the loader shape, not the writer). */
128
+ export function loadSnapshotFile(path) {
129
+ if (typeof path !== "string" || path === "" || !existsSync(path)) return null;
130
+ try {
131
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
132
+ if (!validateSnapshot(parsed)) return null;
133
+ return { tasks: parsed.tasks, highWaterMark: parsed.highWaterMark };
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /** Atomic temp+rename persist of the session snapshot mirror. */
140
+ export function persistSnapshot(path, snapshot) {
141
+ if (!validateSnapshot(snapshot, { exact: true })) {
142
+ throw Object.assign(new Error("snapshot does not satisfy the closed session-tasks schema"), {
143
+ code: "snapshot-schema-invalid",
144
+ });
145
+ }
146
+ mkdirSync(join(path, ".."), { recursive: true });
147
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
148
+ writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
149
+ renameSync(tmpPath, path);
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Origin provenance (observed, never trusted)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Best-effort caller-origin observation. A caller is treated as a worker
158
+ * only when a Herdr observation matches this pane's own session evidence;
159
+ * otherwise the origin is "legacy-unknown" audit metadata. The result is
160
+ * advisory context for audit trails — it never authorizes anything and
161
+ * never substitutes for a fresh owner authority record.
162
+ */
163
+ export function resolveCallerOrigin({
164
+ env = process.env,
165
+ herdrExecutable = TRUSTED_HERDR_EXECUTABLE,
166
+ runProcess = null,
167
+ } = {}) {
168
+ const unproven = Object.freeze({
169
+ origin: LEGACY_UNKNOWN_ORIGIN,
170
+ proven: false,
171
+ reason: "runtime worker identity could not be proven; origin recorded as audit metadata",
172
+ });
173
+ const paneId = typeof env.HERDR_PANE_ID === "string" ? env.HERDR_PANE_ID.trim() : "";
174
+ const sessionFile = typeof env.PI_SESSION_FILE === "string" ? env.PI_SESSION_FILE.trim() : "";
175
+ if (paneId === "" || sessionFile === "") return unproven;
176
+ const argv = [herdrExecutable, "agent", "list"];
177
+ let stdout;
178
+ if (typeof runProcess === "function") {
179
+ const out = runProcess({ argv: Object.freeze(argv) });
180
+ if (typeof out !== "string") return unproven;
181
+ stdout = out;
182
+ } else {
183
+ if (!existsSync(herdrExecutable)) return unproven;
184
+ const proc = spawnSync(herdrExecutable, argv.slice(1), {
185
+ shell: false, timeout: 5000, encoding: "utf8", maxBuffer: 128 * 1024,
186
+ });
187
+ if (proc.error || proc.status !== 0 || typeof proc.stdout !== "string") return unproven;
188
+ stdout = proc.stdout;
189
+ }
190
+ let parsed;
191
+ try {
192
+ parsed = JSON.parse(stdout);
193
+ } catch {
194
+ return unproven;
195
+ }
196
+ const agents = parsed?.result?.agents;
197
+ if (!Array.isArray(agents)) return unproven;
198
+ for (const agent of agents) {
199
+ if (agent?.pane_id !== paneId) continue;
200
+ const session = agent?.agent_session;
201
+ if (session?.kind === "path" && typeof session.value === "string" && session.value === sessionFile) {
202
+ const name = typeof agent.name === "string" && agent.name !== "" ? agent.name : null;
203
+ if (name === null) return unproven;
204
+ return Object.freeze({ origin: "worker", proven: true, workerName: name, reason: "Herdr observation matched this pane's session file" });
205
+ }
206
+ }
207
+ return unproven;
208
+ }
209
+
210
+ /**
211
+ * Whether this session may act as the owner for promotion. A proven worker
212
+ * pane cannot promote — the check is a precondition, not the authority
213
+ * itself (authority is the fresh quotedInstruction record).
214
+ */
215
+ export function canPromote(origin = null) {
216
+ const resolved = origin ?? resolveCallerOrigin();
217
+ return resolved.proven ? resolved.origin !== "worker" : true;
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Promotion idempotency
222
+ // ---------------------------------------------------------------------------
223
+
224
+ const IDEMPOTENCY_VERSION = "p1";
225
+ const IDEMPOTENCY_HASH_BITS = 32;
226
+ const IDEMPOTENCY_RE = /^[0-9a-f]+$/;
227
+
228
+ /**
229
+ * Slash-free, collision-resistant idempotency key binding taskListId and
230
+ * taskId. Format: `p1-<sha256(taskListId + "\u0000" + taskId).hex32>`.
231
+ * Two different sessions with the same numeric task id never collide.
232
+ */
233
+ export function promotionIdempotencyKey(taskListId, taskId) {
234
+ if (typeof taskListId !== "string" || taskListId.trim() === "") {
235
+ throw Object.assign(new Error("a non-empty taskListId is required for the idempotency key"), {
236
+ code: "idempotency-key-invalid",
237
+ });
238
+ }
239
+ if (typeof taskId !== "string" || taskId === "") {
240
+ throw Object.assign(new Error("a non-empty taskId is required for the idempotency key"), {
241
+ code: "idempotency-key-invalid",
242
+ });
243
+ }
244
+ const digest = createHash("sha256")
245
+ .update(`${taskListId}\u0000${taskId}`, "utf8")
246
+ .digest("hex")
247
+ .slice(0, IDEMPOTENCY_HASH_BITS / 4);
248
+ return `${IDEMPOTENCY_VERSION}-${digest}`;
249
+ }
250
+
251
+ /** The forward link stored on the card: safe for importedId (no "/"). */
252
+ export function forwardLinkFor(idempotencyKey) {
253
+ if (typeof idempotencyKey !== "string"
254
+ || !/^p1-[0-9a-f]+$/.test(idempotencyKey)) {
255
+ throw Object.assign(new Error("forwardLinkFor requires a promotion idempotency key"), {
256
+ code: "idempotency-key-invalid",
257
+ });
258
+ }
259
+ return idempotencyKey;
260
+ }
261
+
262
+ /** Backward-compat alias used by earlier drafts. */
263
+ export const sessionTaskImportedId = forwardLinkFor;
264
+
265
+ /**
266
+ * Find an existing card minted from this promotion key. Searches importedId
267
+ * first, then the writer-substituted form. Never parses board bytes itself
268
+ * beyond the writer's own parseBoard (read-only).
269
+ */
270
+ export function findExistingPromotedCard(boardMarkdown, idempotencyKey) {
271
+ if (typeof boardMarkdown !== "string") return null;
272
+ const forwardLink = forwardLinkFor(idempotencyKey);
273
+ const parsed = parseBoard(boardMarkdown, { surface: "auto" });
274
+ if (!parsed.ok) return null;
275
+ return parsed.cards.find((card) => card.importedId === forwardLink
276
+ || card.importedId === forwardLink.replace(/-/g, "--")) ?? null;
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Promotion preview (mandatory before any mutation)
281
+ // ---------------------------------------------------------------------------
282
+
283
+ const PREVIEW_TASK_FIELDS = ["id", "subject", "description", "status", "blocks", "blockedBy", "activeForm"];
284
+
285
+ function requireTaskFields(task) {
286
+ if (!validateSessionTask(task)) {
287
+ throw Object.assign(new Error("the task does not satisfy the closed session-task shape"), {
288
+ code: "task-shape-invalid",
289
+ });
290
+ }
291
+ return task;
292
+ }
293
+
294
+ /**
295
+ * Build one promotion preview. Pure: reads nothing, writes nothing, errors
296
+ * on every missing required board field. Owner-authorized fields
297
+ * (authority, priority, scope, capabilities…) are never invented from task
298
+ * text — absent inputs surface as `missing` entries the owner must supply.
299
+ */
300
+ export function buildPromotionPreview({
301
+ task,
302
+ taskListId,
303
+ boardPath,
304
+ sessionOrReportId,
305
+ laneOverride = null,
306
+ completedPolicy = "skip",
307
+ metadata = null,
308
+ }) {
309
+ requireTaskFields(task);
310
+ if (typeof taskListId !== "string" || taskListId.trim() === "") {
311
+ throw Object.assign(new Error("taskListId is required for a promotion preview"), {
312
+ code: "preview-invalid",
313
+ });
314
+ }
315
+ if (typeof boardPath !== "string" || boardPath === "") {
316
+ throw Object.assign(new Error("boardPath is required for a promotion preview"), {
317
+ code: "preview-invalid",
318
+ });
319
+ }
320
+ if (typeof sessionOrReportId !== "string" || sessionOrReportId.trim() === "") {
321
+ throw Object.assign(new Error("sessionOrReportId is required for a promotion preview"), {
322
+ code: "preview-invalid",
323
+ });
324
+ }
325
+ if (task.status === "completed" && completedPolicy !== "promote-as-done") {
326
+ throw Object.assign(
327
+ new Error(`task #${task.id} is completed; v1 skips completed tasks unless completedPolicy is "promote-as-done"`),
328
+ { code: "completed-task-skip" },
329
+ );
330
+ }
331
+ const idempotencyKey = promotionIdempotencyKey(taskListId, task.id);
332
+ // v1 default: surviving pending AND in_progress tasks promote into the
333
+ // backlog lane so they remain dispatchable. in-progress lane placement is
334
+ // an owner-explicit override only (laneOverride), never a status default.
335
+ const proposedLane = laneOverride ?? DEFAULT_PROMOTION_LANE;
336
+ if (!PROMOTION_LANES.includes(proposedLane)) {
337
+ throw Object.assign(new Error(`lane "${proposedLane}" is not a promotion lane; use backlog or in-progress`), {
338
+ code: "lane-not-promotable",
339
+ });
340
+ }
341
+ // Title/specification/DoD/stoppingPoint are board-required fields. The
342
+ // preview surfaces what the owner must supply; it never fabricates them.
343
+ const missing = [];
344
+ if (task.subject.trim() === "") missing.push("title");
345
+ if (task.description.trim() === "") missing.push("specification");
346
+ if (missing.length > 0) {
347
+ throw Object.assign(
348
+ new Error(`task #${task.id} cannot be promoted yet; missing required board field(s): ${missing.join(", ")}`),
349
+ { code: "preview-missing-required", missing },
350
+ );
351
+ }
352
+ const dependencies = task.blockedBy
353
+ .map((id) => promotionIdempotencyKey(taskListId, id))
354
+ .map((key) => key.replace(/-/g, "--"));
355
+ return Object.freeze({
356
+ schema: TASK_PROMOTION_SCHEMA,
357
+ preview: true,
358
+ taskId: task.id,
359
+ taskListId,
360
+ idempotencyKey,
361
+ target: Object.freeze({
362
+ repositoryRoot: boardPath.includes("/") ? boardPath.slice(0, boardPath.lastIndexOf("/")) : ".",
363
+ canonicalTasksMdPath: boardPath,
364
+ }),
365
+ source: Object.freeze({
366
+ taskListId,
367
+ taskId: task.id,
368
+ sessionOrReportId,
369
+ subject: task.subject,
370
+ status: task.status,
371
+ origin: metadata?.origin ?? LEGACY_UNKNOWN_ORIGIN,
372
+ }),
373
+ cardInput: Object.freeze({
374
+ title: task.subject,
375
+ description: task.description,
376
+ specification: task.description,
377
+ definitionOfDone: null,
378
+ stoppingPoint: null,
379
+ scopePaths: null,
380
+ lane: proposedLane,
381
+ dependencies,
382
+ importedId: forwardLinkFor(idempotencyKey),
383
+ }),
384
+ missingOwnerFields: Object.freeze(["definitionOfDone", "stoppingPoint", "scopePaths", "authority"]),
385
+ droppedPresentationFields: Object.freeze(["activeForm"]),
386
+ });
387
+ }
388
+
389
+ /**
390
+ * Build one preview per task for a batch. Skipped entries carry `skip`
391
+ * (completed-task default or already promoted) and never appear in
392
+ * `previews`. Non-atomicity is explicit: the batch is a sequence of
393
+ * independent writer calls.
394
+ */
395
+ export function buildBatchPromotionPreview({
396
+ tasks,
397
+ taskListId,
398
+ boardPath,
399
+ sessionOrReportId,
400
+ laneOverrides = {},
401
+ completedPolicy = "skip",
402
+ existingKeys = () => null,
403
+ metadataByTaskId = {},
404
+ }) {
405
+ if (!Array.isArray(tasks)) {
406
+ throw Object.assign(new Error("tasks must be an array"), { code: "preview-invalid" });
407
+ }
408
+ const previews = [];
409
+ const skipped = [];
410
+ for (const task of tasks) {
411
+ requireTaskFields(task);
412
+ if (task.status === "completed" && completedPolicy !== "promote-as-done") {
413
+ skipped.push({ taskId: task.id, reason: "completed-task-skip (v1 default)" });
414
+ continue;
415
+ }
416
+ const key = promotionIdempotencyKey(taskListId, task.id);
417
+ if (typeof existingKeys === "function" && existingKeys(key)) {
418
+ skipped.push({ taskId: task.id, reason: "already promoted (idempotency key present on the board)" });
419
+ continue;
420
+ }
421
+ try {
422
+ previews.push(buildPromotionPreview({
423
+ task, taskListId, boardPath, sessionOrReportId,
424
+ laneOverride: laneOverrides[task.id] ?? null,
425
+ completedPolicy, metadata: metadataByTaskId[task.id] ?? null,
426
+ }));
427
+ } catch (error) {
428
+ if (error?.code === "preview-missing-required") {
429
+ skipped.push({ taskId: task.id, reason: error.message, missing: error.missing });
430
+ continue;
431
+ }
432
+ throw error;
433
+ }
434
+ }
435
+ return Object.freeze({ previews: Object.freeze(previews), skipped: Object.freeze(skipped) });
436
+ }
437
+
438
+ /**
439
+ * Map a session blockedBy edge onto card IDs minted during the batch.
440
+ * Edges whose blocker did not promote are dropped (recorded), never kept
441
+ * dangling: the writer's validateBoard rejects dangling dependencies.
442
+ */
443
+ export function reconcileDependencies({ previews, writerResults, sessionTasks, resolveBlockerCardId = null }) {
444
+ const keyToCardId = new Map();
445
+ for (const result of writerResults ?? []) {
446
+ if (result?.ok && result?.idempotencyKey && result?.cardId) {
447
+ keyToCardId.set(result.idempotencyKey, result.cardId);
448
+ }
449
+ }
450
+ if (!Array.isArray(sessionTasks)) {
451
+ throw Object.assign(new Error("sessionTasks must be the batch's session task array"), {
452
+ code: "dependency-reconcile-invalid",
453
+ });
454
+ }
455
+ const blockedByById = new Map(sessionTasks.map((task) => [task.id, task.blockedBy ?? []]));
456
+ const edges = [];
457
+ const dropped = [];
458
+ for (const preview of previews ?? []) {
459
+ const blockers = blockedByById.get(preview.taskId) ?? [];
460
+ for (const blockerId of blockers) {
461
+ const blockerKey = promotionIdempotencyKey(preview.taskListId, blockerId);
462
+ let cardId = keyToCardId.get(blockerKey);
463
+ if (!cardId && typeof resolveBlockerCardId === "function") {
464
+ // The blocker promoted in an EARLIER run: resolve its minted cardId
465
+ // from the board's authoritative forward link instead of discarding
466
+ // the edge (never silently dropped when the card exists).
467
+ cardId = resolveBlockerCardId(blockerKey) ?? null;
468
+ }
469
+ if (cardId) {
470
+ edges.push({ taskId: preview.taskId, cardId: preview.cardInput.importedId, blockedByCardId: cardId });
471
+ } else {
472
+ dropped.push({ taskId: preview.taskId, blockerId, reason: "blocker was not promoted in this batch and no card carries its forward link" });
473
+ }
474
+ }
475
+ }
476
+ return Object.freeze({ edges: Object.freeze(edges), dropped: Object.freeze(dropped) });
477
+ }
478
+
479
+ /**
480
+ * Apply dependency edges through updateCard-shaped calls. Edges are
481
+ * AGGREGATED per dependent card: every resolved blocker lands in ONE
482
+ * update with the complete dependency list — blockers are never silently
483
+ * discarded, and a failed aggregate update fails loudly per card. Every
484
+ * update carries the same authority record (the writer requires one for
485
+ * every mutation). Edge updates are sequenced after all cards exist.
486
+ */
487
+ export function applyDependencyEdges({ edges, authority, updateCardFn, currentDependencies = () => [] }) {
488
+ if (typeof updateCardFn !== "function") {
489
+ throw Object.assign(new Error("applyDependencyEdges requires an updateCard function"), {
490
+ code: "dependency-reconcile-invalid",
491
+ });
492
+ }
493
+ if (!isValidAuthoritySource(authority) || typeof authority.quotedInstruction !== "string") {
494
+ throw Object.assign(new Error("dependency edge application requires a fresh verbatim owner authority record"), {
495
+ code: "authority-required",
496
+ });
497
+ }
498
+ // Aggregate edges by dependent card. The complete list for each card is
499
+ // the resolved batch blockers PLUS the card's current dependencies (the
500
+ // update tool's list fields are full replacement lists — omitting existing
501
+ // entries would silently discard them).
502
+ const byCard = new Map();
503
+ for (const edge of edges ?? []) {
504
+ const dependent = (edge.cardId ?? "").replace(/--/g, "-");
505
+ const targetCardId = edge.dependentCardId ?? edge.targetCardId ?? dependent;
506
+ if (!edge.blockedByCardId) continue;
507
+ if (!byCard.has(targetCardId)) {
508
+ byCard.set(targetCardId, { taskId: edge.taskId, cardId: targetCardId, blockers: new Set() });
509
+ }
510
+ byCard.get(targetCardId).blockers.add(edge.blockedByCardId);
511
+ }
512
+ const applied = [];
513
+ const failed = [];
514
+ for (const [targetCardId, entry] of byCard) {
515
+ let existing = [];
516
+ try { existing = currentDependencies(targetCardId) ?? []; } catch { existing = []; }
517
+ const completeList = [...new Set([...existing, ...entry.blockers])];
518
+ try {
519
+ const result = updateCardFn({
520
+ cardId: targetCardId,
521
+ changes: { dependencies: completeList },
522
+ authority,
523
+ });
524
+ if (result?.ok) {
525
+ applied.push({ taskId: entry.taskId, cardId: targetCardId, blockedBy: completeList });
526
+ } else {
527
+ failed.push({ taskId: entry.taskId, cardId: targetCardId, blockedBy: completeList, reason: (result?.errors ?? []).join("; ") });
528
+ }
529
+ } catch (error) {
530
+ failed.push({ taskId: entry.taskId, cardId: targetCardId, blockedBy: completeList, reason: String(error?.message ?? error).slice(0, 256) });
531
+ }
532
+ }
533
+ return Object.freeze({ applied: Object.freeze(applied), failed: Object.freeze(failed) });
534
+ }
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Preview token: commit consumes a matching prior preview
538
+ // ---------------------------------------------------------------------------
539
+
540
+ /**
541
+ * Compute the preview token: a deterministic digest binding the ENTIRE
542
+ * preview state — task IDs, task contents, target repo/TASKS.md path, all
543
+ * card fields, session identity, and the authority input. Commit MUST
544
+ * present a token that matches a freshly recomputed digest of the same
545
+ * state; a stale or mismatched token is refused. The token is NOT an
546
+ * owner authority: it proves the preview step happened for exactly this
547
+ * state, nothing more.
548
+ */
549
+ export function previewToken({ previews, skipped, recovered, target, sessionOrReportId, authorityInput }) {
550
+ const payload = {
551
+ schema: TASK_PROMOTION_SCHEMA,
552
+ previews: (previews ?? []).map((p) => ({
553
+ taskId: p.taskId,
554
+ taskListId: p.taskListId,
555
+ idempotencyKey: p.idempotencyKey,
556
+ cardInput: p.cardInput,
557
+ source: p.source,
558
+ })),
559
+ skipped: skipped ?? [],
560
+ recovered: recovered ?? [],
561
+ target: target ?? null,
562
+ sessionOrReportId: sessionOrReportId ?? null,
563
+ authorityInput: {
564
+ source: authorityInput?.source ?? null,
565
+ sessionOrReportId: authorityInput?.sessionOrReportId ?? null,
566
+ quotedInstructionSha256: typeof authorityInput?.quotedInstruction === "string"
567
+ ? createHash("sha256").update(authorityInput.quotedInstruction, "utf8").digest("hex")
568
+ : null,
569
+ },
570
+ };
571
+ return `pv1-${createHash("sha256").update(canonicalJsonStable(payload), "utf8").digest("hex").slice(0, 32)}`;
572
+ }
573
+
574
+ /** Deterministic JSON serialization (sorted keys, no locale issues). */
575
+ function canonicalJsonStable(value) {
576
+ if (Array.isArray(value)) return `[${value.map((v) => canonicalJsonStable(v)).join(",")}]`;
577
+ if (value !== null && typeof value === "object") {
578
+ const keys = Object.keys(value).sort();
579
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJsonStable(value[k])}`).join(",")}}`;
580
+ }
581
+ return JSON.stringify(value) ?? "null";
582
+ }
583
+
584
+ // ---------------------------------------------------------------------------
585
+ // Worker capability contract (documented loading, not package inclusion)
586
+ // ---------------------------------------------------------------------------
587
+
588
+ export const TASKS_SKILL_CAPABILITY = "tasks.skill.v1";
589
+ export const TASKS_CAPABILITY_ENV = "AGENTIC_DRIVER_TASKS_CAPABILITY";
590
+
591
+ /**
592
+ * Whether this session has loaded the tasks skill capability. Package file
593
+ * inclusion is NOT the contract: the capability is opt-in per session via
594
+ * the env marker (worker briefs set it) or an explicit capability list.
595
+ * Never coupled to any synchronous communication prompt.
596
+ */
597
+ export function hasTasksCapability({ env = process.env, capabilities = null } = {}) {
598
+ if (Array.isArray(capabilities)) return capabilities.includes(TASKS_SKILL_CAPABILITY);
599
+ const value = typeof env[TASKS_CAPABILITY_ENV] === "string" ? env[TASKS_CAPABILITY_ENV].trim().toLowerCase() : "";
600
+ return value === "1" || value === "true" || value === "yes" || value === "on" || value === TASKS_SKILL_CAPABILITY;
601
+ }
602
+
603
+ // Re-export the writer's authority-record validator so the extension can
604
+ // gate commit on a genuine record without importing the writer module twice.
605
+ export { isValidAuthoritySource } from "./task_board_core_pi.js";