@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,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;
@@ -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
- ```