@evoclock/pi-agentic-driver 0.9.2 → 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.
- package/PROVENANCE.md +9 -0
- package/README.md +178 -73
- package/extensions/tasks.ts +570 -0
- package/janus/README.md +196 -0
- package/janus/janus/audit.ts +213 -0
- package/janus/janus/budget.ts +101 -0
- package/janus/janus/budget_breaker.ts +105 -0
- package/janus/janus/config.ts +332 -0
- package/janus/janus/errors.ts +90 -0
- package/janus/janus/evaluate.ts +230 -0
- package/janus/janus/keychain.ts +119 -0
- package/janus/janus/providers.ts +544 -0
- package/janus/janus/schema.ts +344 -0
- package/janus/janus/server.ts +267 -0
- package/janus/janus/single_flight.ts +99 -0
- package/janus/janus/verdict.ts +192 -0
- package/janus/package-lock.json +616 -0
- package/janus/package.json +29 -0
- package/janus/tests/accounting.test.ts +229 -0
- package/janus/tests/allowlist.test.ts +97 -0
- package/janus/tests/config.test.ts +151 -0
- package/janus/tests/errors.test.ts +147 -0
- package/janus/tests/evaluate_pipeline.test.ts +358 -0
- package/janus/tests/helpers.ts +217 -0
- package/janus/tests/providers.test.ts +292 -0
- package/janus/tests/redaction_budget.test.ts +227 -0
- package/janus/tests/schema.test.ts +356 -0
- package/janus/tests/server.test.ts +288 -0
- package/janus/tests/single_flight.test.ts +84 -0
- package/janus/tsconfig.json +19 -0
- package/janus/user.janus.plist +51 -0
- package/package.json +7 -2
- package/scripts/enforcement/router_schemas_pi.js +63 -13
- package/scripts/enforcement/session_tasks_core_pi.js +605 -0
- package/scripts/enforcement/session_tasks_validate_pi.js +235 -0
- package/skills/tasks/SKILL.md +110 -0
- package/templates/AGENTS.md +11 -0
|
@@ -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";
|