@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.
- package/PROVENANCE.md +9 -0
- package/README.md +100 -19
- package/extensions/tasks.ts +570 -0
- package/package.json +5 -1
- 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
- package/janus/README.draft.md +0 -210
|
@@ -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.
|
package/templates/AGENTS.md
CHANGED
|
@@ -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;
|
package/janus/README.draft.md
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
# janus — Jev evaluation wrapper service
|
|
2
|
-
|
|
3
|
-
janus is the local wrapper service for the Jev decision layer (design of
|
|
4
|
-
record: `.private-planning/JEV_DECISION_LAYER_DESIGN.md` §5–§7, Revision 2.1).
|
|
5
|
-
It wraps the official `@typesafe-ai/sdk` (TypeSafe Direct — the sole
|
|
6
|
-
provider; the Vercel AI Gateway and merge gateway were dropped by owner
|
|
7
|
-
decision) and exposes one evaluation endpoint plus health/readiness/version
|
|
8
|
-
probes on loopback.
|
|
9
|
-
|
|
10
|
-
## What janus does
|
|
11
|
-
|
|
12
|
-
- `POST /evaluate` — evaluates typed questions (boolean / choice / score)
|
|
13
|
-
against one shared state via TypeSafe Direct (`typesafe-ai`, model `jev-latest`).
|
|
14
|
-
- `GET /healthz` — liveness.
|
|
15
|
-
- `GET /readyz` — readiness (gateway key resolvable from the Keychain).
|
|
16
|
-
- `GET /version` — janus version, AI SDK version, schema versions, allowlist.
|
|
17
|
-
|
|
18
|
-
Guarantees:
|
|
19
|
-
|
|
20
|
-
- **Model allowlist** (server-side config; request-level model overrides are
|
|
21
|
-
rejected with `model_unauthorized`).
|
|
22
|
-
- **Loopback only** — binds `127.0.0.1` and nothing else. No auth beyond
|
|
23
|
-
loopback; it is a local tool. There is deliberately no bind-address option.
|
|
24
|
-
- **Single-flight** — identical requests (same evalInputDigest) coalesce;
|
|
25
|
-
distinct in-flight evaluations are capped (default 4) and rejected with
|
|
26
|
-
`busy`.
|
|
27
|
-
- **30k-token request budget** — oversized requests are rejected with
|
|
28
|
-
`state_too_large`, never truncated silently.
|
|
29
|
-
- **Redaction** — a general secrets/PII pass runs over the state before any
|
|
30
|
-
external call.
|
|
31
|
-
- **Audit log** — append-only JSONL of request id, timestamps, question
|
|
32
|
-
names, verdict probabilities, and token usage. **State contents never
|
|
33
|
-
reach the audit log.** 90-day default retention, size-capped with
|
|
34
|
-
oldest-first rotation.
|
|
35
|
-
- **Daily cost breaker** — token/cost ceilings in config; on exceed,
|
|
36
|
-
`/evaluate` returns `busy` with `budget_exhausted` detail until the UTC
|
|
37
|
-
day window resets.
|
|
38
|
-
- **Timeouts** — 30s service-side cap; callers may pass a shorter
|
|
39
|
-
per-request `timeoutMs` (capped at 30s).
|
|
40
|
-
|
|
41
|
-
## Provisioning the Keychain item
|
|
42
|
-
|
|
43
|
-
The TypeSafe Direct key is read from the macOS Keychain at runtime — janus
|
|
44
|
-
does not rely on inherited environment variables. The `typesafe-ai` /
|
|
45
|
-
`Typesafe AI` generic-password item does not exist by default; create it
|
|
46
|
-
once (interactive provisioning):
|
|
47
|
-
|
|
48
|
-
```sh
|
|
49
|
-
security add-generic-password -U -a typesafe-ai -s "Typesafe AI" -w
|
|
50
|
-
# prompts for the secret; nothing is echoed
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
janus reads it with `security find-generic-password -w` under guarded
|
|
54
|
-
capture: stdout is piped directly into memory, never echoed, never logged,
|
|
55
|
-
never passed to spawned subprocesses. If the item is missing, janus returns
|
|
56
|
-
`model_unauthorized` with `detail.key_unavailable: true` and `GET /readyz`
|
|
57
|
-
reports 503 with the provisioning hint.
|
|
58
|
-
|
|
59
|
-
Verify the item is present (no secret material is printed):
|
|
60
|
-
|
|
61
|
-
```sh
|
|
62
|
-
/usr/bin/security find-generic-password -s "Typesafe AI" -a typesafe-ai >/dev/null 2>&1 && echo present
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
To rotate the secret, re-run the `security add-generic-password -U` command
|
|
66
|
-
above and enter the new value at the prompt (`-U` updates the existing item).
|
|
67
|
-
To remove the item entirely:
|
|
68
|
-
|
|
69
|
-
```sh
|
|
70
|
-
security delete-generic-password -a typesafe-ai -s "Typesafe AI"
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
## Running
|
|
74
|
-
|
|
75
|
-
Dev (from `janus/`):
|
|
76
|
-
|
|
77
|
-
```sh
|
|
78
|
-
npx tsx janus/server.ts # listens on 127.0.0.1:8787
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
Config is read from `~/.config/typesafe-ai/jev/janus-config.json`
|
|
82
|
-
(override with `JANUS_CONFIG_PATH`). Defaults work with no file:
|
|
83
|
-
|
|
84
|
-
```json
|
|
85
|
-
{
|
|
86
|
-
"port": 8787,
|
|
87
|
-
"modelAllowlist": ["typesafe-ai/jev"],
|
|
88
|
-
"defaultModel": "typesafe-ai/jev",
|
|
89
|
-
"maxConcurrent": 4,
|
|
90
|
-
"dailyTokenCeiling": 2000000,
|
|
91
|
-
"dailyCostCeilingUsd": 10,
|
|
92
|
-
"audit": { "path": "~/.local/share/janus/audit", "maxBytes": 10485760, "retentionDays": 90 }
|
|
93
|
-
}
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
launchd — template at `janus/user.janus.plist`. The template is a starting
|
|
97
|
-
point: every user-specific value (the label, the checkout path, log
|
|
98
|
-
locations) is marked ADJUST in the file. Copy it out of the repository,
|
|
99
|
-
adjust it, then load it:
|
|
100
|
-
|
|
101
|
-
```sh
|
|
102
|
-
cp janus/user.janus.plist ~/Library/LaunchAgents/
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
```sh
|
|
106
|
-
launchctl load ~/Library/LaunchAgents/user.janus.plist
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
Tests (no live gateway calls — the SDK is mocked):
|
|
110
|
-
|
|
111
|
-
```sh
|
|
112
|
-
cd janus && npm test
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
## Endpoint contract
|
|
116
|
-
|
|
117
|
-
### POST /evaluate
|
|
118
|
-
|
|
119
|
-
Request:
|
|
120
|
-
|
|
121
|
-
```json
|
|
122
|
-
{
|
|
123
|
-
"state": "string | object | array (message histories pass directly)",
|
|
124
|
-
"questions": [
|
|
125
|
-
{ "type": "boolean", "question": "Was a refund issued?", "threshold": 0.9 },
|
|
126
|
-
{ "type": "choice", "question": "Route this ticket", "choices": ["billing", "tech", "other"] },
|
|
127
|
-
{ "type": "score", "question": "Rate this PR" }
|
|
128
|
-
],
|
|
129
|
-
"model": "optional; must be on the server allowlist, overrides rejected",
|
|
130
|
-
"timeoutMs": 10000
|
|
131
|
-
}
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
Question schema v1 (§6), enforced atomically — any malformed, absent, or
|
|
135
|
-
contradictory element fails the whole request with `validation_error`:
|
|
136
|
-
|
|
137
|
-
- **boolean** — `threshold` optional, default `0.9`, comparison
|
|
138
|
-
`probability >= threshold`. Optional `criteria: {true?, false?}` labels.
|
|
139
|
-
- **choice** — `choices` array REQUIRED; the verdict's `choice` is guaranteed
|
|
140
|
-
to be a member.
|
|
141
|
-
- **score** — numeric 0..5, one decimal. Ordinal vocabularies must be
|
|
142
|
-
**choice** questions, not scores.
|
|
143
|
-
|
|
144
|
-
§6 → SDK mapping (verified against ai@7.0.106 at launch step 1): the SDK's
|
|
145
|
-
`experimental_evaluate` has **no top-level `criteria` argument**; the
|
|
146
|
-
candidate set lives inside each question as a REQUIRED `criteria` field
|
|
147
|
-
(choice: `Record<option, description | null>`; score: ordered level array;
|
|
148
|
-
boolean: optional `{true?, false?}`). janus accepts §6's `choices` array at
|
|
149
|
-
the transport layer and maps it to `criteria: {a: null, b: null, ...}`. The
|
|
150
|
-
score vocabulary is passed as the ordered level array `["0".."5"]`.
|
|
151
|
-
|
|
152
|
-
Success response (200):
|
|
153
|
-
|
|
154
|
-
```json
|
|
155
|
-
{
|
|
156
|
-
"request_id": "uuid",
|
|
157
|
-
"verdict": {
|
|
158
|
-
"Was a refund issued?": { "type": "boolean", "probability": 0.97, "passed": true },
|
|
159
|
-
"Route this ticket": { "type": "choice", "choice": "billing", "probabilities": {} },
|
|
160
|
-
"Rate this PR": { "type": "score", "score": 3.0 }
|
|
161
|
-
},
|
|
162
|
-
"usage": { "inputTokens": 0, "outputTokens": 0, "totalTokens": 0 },
|
|
163
|
-
"model": "typesafe-ai/jev",
|
|
164
|
-
"digest": "sha256 evalInputDigest"
|
|
165
|
-
}
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
"Model returned false" is a **200** with `passed: false` / low probability —
|
|
169
|
-
not an error.
|
|
170
|
-
|
|
171
|
-
### Error taxonomy
|
|
172
|
-
|
|
173
|
-
All errors: `{ "error": code, "message": ..., "request_id": ..., "detail"?: {...} }`
|
|
174
|
-
|
|
175
|
-
| Code | HTTP | Meaning |
|
|
176
|
-
|---|---|---|
|
|
177
|
-
| `validation_error` | 400 | bad request (schema, JSON, unknown route) |
|
|
178
|
-
| `state_too_large` | 413 | over the 30k-token budget; never truncated |
|
|
179
|
-
| `unknown_question` | 400 | reserved for router/schema-version mismatches |
|
|
180
|
-
| `model_unauthorized` | 403/502/503 | not on allowlist, credential rejected, or key unavailable (`detail.key_unavailable`) |
|
|
181
|
-
| `gateway_unavailable` | 503 | gateway unreachable / model missing / rate limit |
|
|
182
|
-
| `timeout` | 504 | evaluation exceeded the budget |
|
|
183
|
-
| `busy` | 429 | concurrency cap or `detail.budget_exhausted` |
|
|
184
|
-
| `degraded` | 503 | internal error |
|
|
185
|
-
|
|
186
|
-
Callers distinguish **"model returned false"** (200, low probability) from
|
|
187
|
-
**"no verdict"** (`timeout` / `gateway_unavailable`) from **"bad request"**
|
|
188
|
-
(`validation_error`).
|
|
189
|
-
|
|
190
|
-
## Layout
|
|
191
|
-
|
|
192
|
-
```
|
|
193
|
-
janus/
|
|
194
|
-
janus/
|
|
195
|
-
config.ts config load/validate (allowlist, ceilings, retention)
|
|
196
|
-
schema.ts §6 question schema v1 enforcement (atomic fail-closed)
|
|
197
|
-
verdict.ts verdict enforcement (membership, ranges, thresholds)
|
|
198
|
-
budget.ts token estimation, 30k budget, evalInputDigest
|
|
199
|
-
redaction pass: shared library typesafe-secure
|
|
200
|
-
lib/redaction (MIT), wired via a file: dependency
|
|
201
|
-
keychain.ts guarded `security find-generic-password -w` capture
|
|
202
|
-
single_flight.ts digest coalescing + concurrency cap
|
|
203
|
-
budget_breaker.ts daily token/cost breaker
|
|
204
|
-
audit.ts append-only JSONL audit log, rotation + retention
|
|
205
|
-
sdk.ts THE ONLY AI SDK import site (pinned versions)
|
|
206
|
-
evaluate.ts /evaluate pipeline + SDK error classification
|
|
207
|
-
server.ts HTTP surface, loopback bind, entrypoint
|
|
208
|
-
tests/ node:test suites (mocked SDK, no live calls)
|
|
209
|
-
user.janus.plist launchd template
|
|
210
|
-
```
|