@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.
Files changed (37) hide show
  1. package/PROVENANCE.md +9 -0
  2. package/README.md +178 -73
  3. package/extensions/tasks.ts +570 -0
  4. package/janus/README.md +196 -0
  5. package/janus/janus/audit.ts +213 -0
  6. package/janus/janus/budget.ts +101 -0
  7. package/janus/janus/budget_breaker.ts +105 -0
  8. package/janus/janus/config.ts +332 -0
  9. package/janus/janus/errors.ts +90 -0
  10. package/janus/janus/evaluate.ts +230 -0
  11. package/janus/janus/keychain.ts +119 -0
  12. package/janus/janus/providers.ts +544 -0
  13. package/janus/janus/schema.ts +344 -0
  14. package/janus/janus/server.ts +267 -0
  15. package/janus/janus/single_flight.ts +99 -0
  16. package/janus/janus/verdict.ts +192 -0
  17. package/janus/package-lock.json +616 -0
  18. package/janus/package.json +29 -0
  19. package/janus/tests/accounting.test.ts +229 -0
  20. package/janus/tests/allowlist.test.ts +97 -0
  21. package/janus/tests/config.test.ts +151 -0
  22. package/janus/tests/errors.test.ts +147 -0
  23. package/janus/tests/evaluate_pipeline.test.ts +358 -0
  24. package/janus/tests/helpers.ts +217 -0
  25. package/janus/tests/providers.test.ts +292 -0
  26. package/janus/tests/redaction_budget.test.ts +227 -0
  27. package/janus/tests/schema.test.ts +356 -0
  28. package/janus/tests/server.test.ts +288 -0
  29. package/janus/tests/single_flight.test.ts +84 -0
  30. package/janus/tsconfig.json +19 -0
  31. package/janus/user.janus.plist +51 -0
  32. package/package.json +7 -2
  33. package/scripts/enforcement/router_schemas_pi.js +63 -13
  34. package/scripts/enforcement/session_tasks_core_pi.js +605 -0
  35. package/scripts/enforcement/session_tasks_validate_pi.js +235 -0
  36. package/skills/tasks/SKILL.md +110 -0
  37. package/templates/AGENTS.md +11 -0
@@ -0,0 +1,235 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // `tasks validate` (rule 30): a read-only setup-time validator in the shape
5
+ // of nudge_validate_pi.js and router_validate_pi.js. It loads everything
6
+ // exactly as the runtime would, checks it, and exits 0 only when the tasks
7
+ // surface is operational — otherwise nonzero with a machine-readable error
8
+ // list. It never mutates state, never writes the board, never promotes.
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import { pathToFileURL } from "node:url";
14
+ import {
15
+ SESSION_TASKS_SCHEMA,
16
+ resolveTaskListId, tasksMirrorPath, loadSnapshotFile,
17
+ validateSnapshot, validateSessionTask,
18
+ promotionIdempotencyKey, findExistingPromotedCard, resolveCallerOrigin, hasTasksCapability,
19
+ TASKS_CAPABILITY_ENV,
20
+ } from "./session_tasks_core_pi.js";
21
+ import { validateBoard } from "./task_board_core_pi.js";
22
+
23
+ function readJsonIfExists(path) {
24
+ if (!existsSync(path)) return null;
25
+ try {
26
+ return JSON.parse(readFileSync(path, "utf8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function readTasksConfig(repoRoot) {
33
+ const candidates = [
34
+ join(repoRoot, ".agentic-driver", "tasks.json"),
35
+ join(homedir(), ".config", "agentic-driver", "tasks.json"),
36
+ ];
37
+ for (const candidate of candidates) {
38
+ const value = readJsonIfExists(candidate);
39
+ if (value !== null) return { path: candidate, value };
40
+ }
41
+ return { path: null, value: null };
42
+ }
43
+
44
+ /**
45
+ * Validate the tasks surface. Pure observation: no writes, no promotion.
46
+ * Returns { valid, operational, checks, errors, warnings }.
47
+ */
48
+ export function validateTasksSetup({
49
+ repoRoot = process.cwd(),
50
+ session = null,
51
+ boardPath = null,
52
+ tasksRoot = null,
53
+ capabilityEnv = process.env,
54
+ resolveOrigin = null,
55
+ } = {}) {
56
+ const errors = [];
57
+ const warnings = [];
58
+ const checks = {};
59
+ const root = typeof repoRoot === "string" && repoRoot !== "" ? resolve(repoRoot) : process.cwd();
60
+
61
+ // 1. taskListId resolution (the same chain the runtime uses).
62
+ const taskListId = resolveTaskListId({ session });
63
+ checks.taskListId = taskListId ? { ok: true, taskListId } : { ok: false, reason: "no PI_TASK_LIST_ID, CLAUDE_CODE_TASK_LIST_ID, or session id" };
64
+ if (!taskListId) errors.push("taskListId could not be resolved (PI_TASK_LIST_ID, then CLAUDE_CODE_TASK_LIST_ID, then the session id)");
65
+
66
+ // 2. Session snapshot shape (picc-tasks mirror file).
67
+ let snapshot = null;
68
+ if (taskListId) {
69
+ const mirrorPath = tasksMirrorPath(taskListId, { tasksRoot });
70
+ checks.snapshot = { ok: true, path: mirrorPath, present: existsSync(mirrorPath) };
71
+ if (existsSync(mirrorPath)) {
72
+ snapshot = loadSnapshotFile(mirrorPath);
73
+ if (snapshot === null) {
74
+ checks.snapshot = { ok: false, path: mirrorPath, present: true, reason: "the snapshot does not satisfy agentic-driver.session-tasks.v1" };
75
+ errors.push(`session snapshot at ${mirrorPath} does not satisfy ${SESSION_TASKS_SCHEMA}`);
76
+ } else {
77
+ const ids = snapshot.tasks.map((task) => task.id);
78
+ if (new Set(ids).size !== ids.length) {
79
+ errors.push("session snapshot has duplicate task ids");
80
+ }
81
+ const hwmMax = snapshot.tasks.reduce((max, task) => {
82
+ const n = Number.parseInt(task.id, 10);
83
+ return Number.isNaN(n) ? max : Math.max(max, n);
84
+ }, 0);
85
+ if (snapshot.highWaterMark < hwmMax) {
86
+ errors.push(`highWaterMark ${snapshot.highWaterMark} is below the largest task id ${hwmMax}`);
87
+ }
88
+ for (const task of snapshot.tasks) {
89
+ if (!validateSessionTask(task)) {
90
+ errors.push(`task ${task.id} is outside the closed session-task shape`);
91
+ }
92
+ if (task.metadata?.origin !== undefined && task.metadata?.origin !== null
93
+ && !["coordinator", "worker", "legacy-unknown"].includes(task.metadata.origin)) {
94
+ errors.push(`task ${task.id} metadata.origin "${task.metadata.origin}" is outside the closed origin values`);
95
+ }
96
+ }
97
+ }
98
+ } else {
99
+ warnings.push(`no session snapshot present at ${mirrorPath} (a fresh session has none; not an error)`);
100
+ }
101
+ }
102
+
103
+ // 3. Board availability + validity (TASKS.md first; board.md never).
104
+ let resolvedBoardPath = boardPath;
105
+ if (!resolvedBoardPath) {
106
+ // TASKS.md first; board.md is a derived projection and is never the
107
+ // promotion target. When only a projection exists, the canonical path
108
+ // is reported so the error names the right fix.
109
+ const canonical = join(root, "TASKS.md");
110
+ resolvedBoardPath = existsSync(canonical) ? canonical : canonical;
111
+ }
112
+ if (resolvedBoardPath.endsWith("board.md")) {
113
+ checks.board = { ok: false, path: resolvedBoardPath, reason: "board.md is a projection; it is never the promotion target" };
114
+ errors.push("board.md must never be the promotion target; the canonical TASKS.md is required");
115
+ } else if (existsSync(resolvedBoardPath)) {
116
+ const markdown = readFileSync(resolvedBoardPath, "utf8");
117
+ const validated = validateBoard(markdown);
118
+ checks.board = { ok: validated.ok, path: resolvedBoardPath, cards: validated.cards.length };
119
+ if (!validated.ok) errors.push(...validated.errors.map((e) => `board invalid: ${e}`));
120
+ // 4. Linkage integrity: every promotedCardId on session tasks must
121
+ // resolve to a card whose importedId points back at the task.
122
+ if (snapshot && taskListId) {
123
+ const brokenLinks = [];
124
+ for (const task of snapshot.tasks) {
125
+ const cardLink = task.metadata?.promotedCardId ?? task.metadata?.promotedcardid;
126
+ if (typeof cardLink !== "string" || cardLink === "") continue;
127
+ const card = validated.cards.find((c) => c.cardId === cardLink);
128
+ if (!card) { brokenLinks.push(`task ${task.id} -> card ${cardLink}: card not found`); continue; }
129
+ const expectedImported = findExistingPromotedCard(markdown, promotionIdempotencyKey(taskListId, task.id));
130
+ if (!expectedImported || expectedImported.cardId !== cardLink) {
131
+ brokenLinks.push(`task ${task.id} -> card ${cardLink}: card importedId does not link back`);
132
+ }
133
+ }
134
+ checks.linkage = { ok: brokenLinks.length === 0, brokenLinks };
135
+ errors.push(...brokenLinks);
136
+ }
137
+ } else {
138
+ checks.board = { ok: false, path: resolvedBoardPath, reason: "no TASKS.md in this workspace" };
139
+ warnings.push("no TASKS.md found for the workspace; promotion is unavailable until a board exists (a write tool call bootstraps one)");
140
+ }
141
+ // 5. Promotion target configuration.
142
+ const config = readTasksConfig(root);
143
+ if (config.value === null) {
144
+ checks.promotionConfig = { ok: true, path: null, defaults: true };
145
+ checks.promotionConfig.defaults = true;
146
+ } else {
147
+ const value = config.value;
148
+ const problems = [];
149
+ if (value.schema !== undefined && value.schema !== "agentic-driver.tasks.v1") {
150
+ problems.push(`schema "${value.schema}" is not recognized`);
151
+ }
152
+ if (value.promotion !== undefined) {
153
+ const p = value.promotion;
154
+ if (p === null || typeof p !== "object" || Array.isArray(p)) {
155
+ problems.push("promotion must be an object");
156
+ } else {
157
+ for (const [key, val] of Object.entries(p)) {
158
+ if (key === "completedTasks" && !["skip", "promote-as-done"].includes(val)) {
159
+ problems.push(`promotion.completedTasks "${val}" must be skip or promote-as-done`);
160
+ }
161
+ if (key === "batchAtSessionEnd" && !["offered", "off"].includes(val)) {
162
+ problems.push(`promotion.batchAtSessionEnd "${val}" must be offered or off`);
163
+ }
164
+ }
165
+ }
166
+ }
167
+ checks.promotionConfig = { ok: problems.length === 0, path: config.path, problems };
168
+ errors.push(...problems.map((p) => `promotion config: ${p}`));
169
+ }
170
+
171
+ // 6. Origin provenance + worker capability (observation only).
172
+ const origin = typeof resolveOrigin === "function" ? resolveOrigin() : resolveCallerOrigin();
173
+ checks.origin = { ok: true, origin: origin.origin, proven: origin.proven, reason: origin.reason };
174
+ checks.capability = {
175
+ ok: true,
176
+ loaded: hasTasksCapability({ env: capabilityEnv }),
177
+ marker: TASKS_CAPABILITY_ENV,
178
+ };
179
+ if (!checks.capability.loaded) {
180
+ warnings.push(`tasks skill capability not declared (${TASKS_CAPABILITY_ENV}); session tools still work, worker briefs must set the marker explicitly`);
181
+ }
182
+
183
+ const valid = errors.length === 0;
184
+ // Operational means ready end-to-end: valid state AND a resolvable board
185
+ // AND linkage clean. A missing snapshot is tolerated (fresh session).
186
+ const operational = valid
187
+ && Boolean(taskListId)
188
+ && Boolean(checks.board?.ok);
189
+ return { valid, operational, checks, errors, warnings };
190
+ }
191
+
192
+ function formatTasksValidateReport(report) {
193
+ const lines = [];
194
+ lines.push("tasks validate — unified session tasks + promotion (v1)");
195
+ for (const [name, check] of Object.entries(report.checks)) {
196
+ const ok = check.ok ? "ok" : "FAIL";
197
+ const detail = check.reason ?? check.taskListId ?? check.path ?? check.origin ?? "";
198
+ lines.push(` [${ok}] ${name}${detail ? `: ${detail}` : ""}`);
199
+ }
200
+ for (const warning of report.warnings) lines.push(` [warn] ${warning}`);
201
+ for (const error of report.errors) lines.push(` [error] ${error}`);
202
+ lines.push(`result: ${report.operational ? "operational" : "not operational"}`);
203
+ return lines.join("\n");
204
+ }
205
+
206
+ function isEntrypoint() {
207
+ try {
208
+ return pathToFileURL(process.argv[1] ?? "").href === import.meta.url;
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+
214
+ if (isEntrypoint()) {
215
+ const args = process.argv.slice(2);
216
+ const take = (flag) => {
217
+ const idx = args.indexOf(flag);
218
+ return idx >= 0 ? args[idx + 1] : null;
219
+ };
220
+ const report = validateTasksSetup({
221
+ repoRoot: take("--repo-root") ?? process.cwd(),
222
+ session: take("--session"),
223
+ boardPath: take("--board"),
224
+ tasksRoot: take("--tasks-root"),
225
+ });
226
+ process.stdout.write(`${formatTasksValidateReport(report)}\n`);
227
+ if (!report.valid || !report.operational) {
228
+ process.stderr.write(`${JSON.stringify({
229
+ valid: report.valid, operational: report.operational,
230
+ errors: report.errors, warnings: report.warnings,
231
+ }, null, 2)}\n`);
232
+ process.exit(1);
233
+ }
234
+ process.exit(0);
235
+ }
@@ -0,0 +1,110 @@
1
+ # tasks — unified session task list + board promotion (v1)
2
+
3
+ Owner-facing skill for the pi-agentic-driver session task list and
4
+ owner-initiated promotion to the durable `TASKS.md` board.
5
+
6
+ ## Prior art and credit
7
+
8
+ The session task tools (`TaskCreate`, `TaskGet`, `TaskList`, `TaskUpdate`)
9
+ reimplement the documented behavior of
10
+ [`@ladbabynpm/picc-tasks`](https://www.npmjs.com/package/@ladbabynpm/picc-tasks)
11
+ v0.2.0 (MIT, Copyright (c) 2026 Ladbaby) as prior art: the same tool names,
12
+ the same storage model (session JSONL snapshot entries plus a disk mirror at
13
+ `~/.pi/tasks/{taskListId}/tasks.json`), and the same result text. No code is
14
+ copied; this is a clean AGPL-3.0-only reimplementation to the same behavior
15
+ spec. picc-tasks is an independent project by its author, used as reference;
16
+ it is not affiliated with, nor does it endorse, pi-agentic-driver.
17
+
18
+ ## What this skill owns
19
+
20
+ 1. The **session task list** — working memory for the current session. Every
21
+ task carries `metadata.origin` (`coordinator` or `worker`, with
22
+ `legacy-unknown` for unprovable runtime identity).
23
+ 2. **Promotion** — `TaskPromote`, an explicit owner-initiated action that
24
+ turns selected session tasks into `TASKS.md` cards through the trusted
25
+ board writer (`scripts/enforcement/task_board_core_pi.js`). The skill is
26
+ a writer CLIENT: it never serializes or writes board bytes itself, and it
27
+ never creates a second authority store.
28
+
29
+ ## Promotion rules (non-negotiable)
30
+
31
+ - **Owner-initiated only.** Promotion is never automatic, never a
32
+ session-shutdown hook, and never worker-authorized.
33
+ - **Fresh authority.** Every promotion requires the owner's actual
34
+ instruction quoted verbatim (`quotedInstruction`) supplied through the
35
+ owner-facing tool call. A digest alone is not accepted.
36
+ - **Preview before mutation.** The preview shows the target repository root
37
+ and canonical `TASKS.md` path, title/description, specification,
38
+ definitionOfDone, stoppingPoint, scopePaths, lane, dependencies,
39
+ session-qualified source identity, and the resulting idempotency key.
40
+ Missing required board fields are errors — owner-authorized fields are
41
+ never invented from task text.
42
+ - **Workers cannot promote.** Worker origin is observed (never trusted) and
43
+ recorded as audit metadata; a proven worker pane is refused. Unproven
44
+ identity is recorded as `legacy-unknown` and still requires fresh owner
45
+ authority.
46
+ - **Completed tasks are skipped by default** in v1. Surviving
47
+ pending/in_progress tasks default to the `backlog` lane so they remain
48
+ dispatchable. `in-progress` lane placement does not make a card
49
+ dispatchable by itself.
50
+
51
+ ## Idempotency
52
+
53
+ The promotion key binds `taskListId` + `taskId`
54
+ (`p1-<sha256(taskListId + NUL + taskId).hex32>`): slash-free,
55
+ collision-resistant across sessions. Retrying the same promotion returns the
56
+ existing card and link; another session's same numeric task id never
57
+ collides. The forward link is stored on the card as `importedId`; the
58
+ reverse link (`metadata.promotedCardId`) is set on the session task after
59
+ the writer confirms the write.
60
+
61
+ ## Batch promotion
62
+
63
+ A batch is a sequence of independent writer calls. **Atomicity is not
64
+ claimed.** Partial success is reported per task and reconciled
65
+ deterministically using the idempotency key and the card↔session link.
66
+ Dependency edges are mapped to writer-minted card IDs only after all cards
67
+ in the batch exist; edges pointing outside the batch are dropped and
68
+ recorded, never written dangling.
69
+
70
+ ## Worker loading contract
71
+
72
+ The tasks tools ship in the package, so the session tools are present in any
73
+ driver-equipped workspace — but presence is not authorization. The
74
+ `AGENTIC_DRIVER_TASKS_CAPABILITY` marker is the per-session loading contract:
75
+ worker briefs set it when the coordinator intends the worker to plan with the
76
+ session tools, and `TaskPromote` commit **enforces** it — a session without
77
+ the marker cannot commit a promotion regardless of what authority text it
78
+ supplies. The marker never substitutes for the owner confirmation boundary;
79
+ it is an additional fail-closed gate on the authority-bearing surface.
80
+ Spawned workers receive briefs through prompts (Herdr `agent start` exposes
81
+ no environment mechanism), so the marker must be carried in the worker brief
82
+ text and exported by the worker's own shell setup; a worker that cannot set
83
+ it cannot promote, which is the intended default.
84
+
85
+ ## Migration from picc-tasks
86
+
87
+ The driver skill reads the existing picc-tasks mirror file on first run; the
88
+ shape is identical, so snapshots replay as-is. Tasks missing
89
+ `metadata.origin` are NEVER inferred: there is no trusted runtime source for
90
+ a legacy task's creation context, so they backfill to `legacy-unknown` audit
91
+ metadata on import. Existing `worker` origin values are preserved unchanged.
92
+
93
+ **Rollback is not symmetric.** The file layout is preserved (re-installing
94
+ picc-tasks finds its data intact), but custom session entries and
95
+ UI/reminder behavior changes are retained by the driver skill and are not
96
+ reverted by re-installing picc-tasks.
97
+
98
+ ## Validation (rule 30)
99
+
100
+ `node scripts/enforcement/session_tasks_validate_pi.js --repo-root <root>`
101
+ is read-only. It validates session snapshot shape, taskListId resolution,
102
+ promotion metadata, linkage integrity, board availability (canonical
103
+ `TASKS.md` first; `board.md` never), and promotion target configuration.
104
+ Exit 0 only when ready; nonzero with a machine-readable error list
105
+ otherwise. It never mutates state, never writes the board, never promotes.
106
+
107
+ ## Out of scope in v1
108
+
109
+ No Obsidian work. No automatic worker proposal store. No session-end prompt.
110
+ No completed-task promotion. No Vogelkop writer-authority changes.
@@ -35,6 +35,17 @@ acceptance result, and exact next step in the existing task and report. Create
35
35
  a follow-up task only when no existing task represents the remaining work. The
36
36
  next session or worker resumes that task instead of rediscovering scope.
37
37
 
38
+ Session task tools (`TaskCreate`, `TaskGet`, `TaskList`, `TaskUpdate`) are
39
+ available when the tasks capability is loaded for the session — declared by
40
+ the `AGENTIC_DRIVER_TASKS_CAPABILITY` marker in the worker brief. Package
41
+ presence is not the loading contract; a worker without the marker must not
42
+ assume the session tools exist. Worker task lists are session-scoped:
43
+ promotion to the durable board (`TaskPromote`) is owner-initiated only and is
44
+ refused in worker sessions — the commit path additionally requires the
45
+ capability marker, so a session that cannot declare it can never promote. A
46
+ worker that believes a task deserves a board card states that in its report
47
+ text; it never promotes and never invents an authority record.
48
+
38
49
  Every worker report should state:
39
50
 
40
51
  - repository and revision;