@evoclock/pi-agentic-driver 0.9.1 → 0.9.3

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 (67) hide show
  1. package/.agentic-driver/router.defaults.json +64 -0
  2. package/LICENSE +11 -8
  3. package/README.md +116 -32
  4. package/extensions/attended-authority-guard.ts +1 -1
  5. package/extensions/code-phage.js +1 -1
  6. package/extensions/herdr-communication.ts +1 -1
  7. package/extensions/herdr-dispatch.ts +1 -1
  8. package/extensions/herdr-lifecycle.ts +1 -1
  9. package/extensions/linux-microvm.ts +1 -1
  10. package/extensions/pulse.ts +33 -2
  11. package/extensions/task-board.ts +1 -1
  12. package/janus/README.draft.md +210 -0
  13. package/janus/README.md +196 -0
  14. package/janus/janus/audit.ts +213 -0
  15. package/janus/janus/budget.ts +101 -0
  16. package/janus/janus/budget_breaker.ts +105 -0
  17. package/janus/janus/config.ts +332 -0
  18. package/janus/janus/errors.ts +90 -0
  19. package/janus/janus/evaluate.ts +230 -0
  20. package/janus/janus/keychain.ts +119 -0
  21. package/janus/janus/providers.ts +544 -0
  22. package/janus/janus/schema.ts +344 -0
  23. package/janus/janus/server.ts +267 -0
  24. package/janus/janus/single_flight.ts +99 -0
  25. package/janus/janus/verdict.ts +192 -0
  26. package/janus/package-lock.json +616 -0
  27. package/janus/package.json +29 -0
  28. package/janus/tests/accounting.test.ts +229 -0
  29. package/janus/tests/allowlist.test.ts +97 -0
  30. package/janus/tests/config.test.ts +151 -0
  31. package/janus/tests/errors.test.ts +147 -0
  32. package/janus/tests/evaluate_pipeline.test.ts +358 -0
  33. package/janus/tests/helpers.ts +217 -0
  34. package/janus/tests/providers.test.ts +292 -0
  35. package/janus/tests/redaction_budget.test.ts +227 -0
  36. package/janus/tests/schema.test.ts +356 -0
  37. package/janus/tests/server.test.ts +288 -0
  38. package/janus/tests/single_flight.test.ts +84 -0
  39. package/janus/tsconfig.json +19 -0
  40. package/janus/user.janus.plist +51 -0
  41. package/lib/adapters/diff-scope.mjs +1 -1
  42. package/lib/adapters/evidence.mjs +1 -1
  43. package/lib/adapters/narrative.mjs +1 -1
  44. package/lib/adapters/review-feedback.mjs +1 -1
  45. package/lib/adapters/visualization.mjs +1 -1
  46. package/lib/code-phage-core.mjs +1 -1
  47. package/lib/python_ast_metrics.py +1 -1
  48. package/lib/typescript_ast_metrics.mjs +1 -1
  49. package/package.json +14 -3
  50. package/scripts/aidr_writing_review.js +1 -1
  51. package/scripts/enforcement/attended_authority_guard.js +1 -1
  52. package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
  53. package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
  54. package/scripts/enforcement/herdr_communication_pi.js +13 -8
  55. package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
  56. package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
  57. package/scripts/enforcement/native_tui_context.js +1 -1
  58. package/scripts/enforcement/pulse_core_pi.js +38 -14
  59. package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
  60. package/scripts/enforcement/router_engine_pi.js +483 -0
  61. package/scripts/enforcement/router_ranking_pi.js +146 -0
  62. package/scripts/enforcement/router_replacement_pi.js +98 -0
  63. package/scripts/enforcement/router_runtime_pi.js +299 -0
  64. package/scripts/enforcement/router_schemas_pi.js +654 -0
  65. package/scripts/enforcement/router_store_pi.js +438 -0
  66. package/scripts/enforcement/task_board_core_pi.js +255 -44
  67. package/skills/pulse/SKILL.md +1 -1
@@ -0,0 +1,230 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // The /evaluate pipeline: schema enforcement → budget → redaction
5
+ // (unconditional) → budget breaker → digest + single-flight → provider chain
6
+ // (direct TypeSafe primary, configured fallbacks, exactly one identical-
7
+ // request failover on eligible transport failures) → verdict enforcement →
8
+ // audit. Every failure path lands in the §5 error taxonomy with a request id
9
+ // and a FIXED public message — provider/SDK/Keychain/internal error text
10
+ // never reaches an HTTP caller (review fix #5).
11
+
12
+ import { randomUUID } from "node:crypto";
13
+ import type { JanusConfig, ProviderConfig } from "./config.js";
14
+ import { MAX_STATE_BYTES, MAX_TIMEOUT_MS } from "./config.js";
15
+ import { JanusError, timeoutError } from "./errors.js";
16
+ import { enforceRequest, type EnforcedQuestion } from "./schema.js";
17
+ import { estimateRequestTokens, evalInputDigest } from "./budget.js";
18
+ // Shared redaction library (typesafe-secure/lib/redaction, MIT — the canonical
19
+ // implementation). Unconditional, no disable knob (SPEC A3.1); markers are
20
+ // typed per SPEC A2.2 (`[REDACTED:<type>]`, `[REDACTED:sensitive_key]`).
21
+ import { redactValue } from "@evoclock/redaction";
22
+ import { SingleFlight } from "./single_flight.js";
23
+ import { DailyBudget, type UsageRecord } from "./budget_breaker.js";
24
+ import { AuditLog } from "./audit.js";
25
+ import { enforceAnswers, type EnforcedVerdict } from "./verdict.js";
26
+ import {
27
+ ProviderChain,
28
+ ProviderChainError,
29
+ defaultAdapters,
30
+ defaultKeyReader,
31
+ type TransportQuestion,
32
+ type TransportUsage,
33
+ } from "./providers.js";
34
+
35
+ export interface EvaluateResponse {
36
+ status: number;
37
+ payload: Record<string, unknown>;
38
+ }
39
+
40
+ export interface EvaluatorDeps {
41
+ config: JanusConfig;
42
+ singleFlight: SingleFlight;
43
+ budget: DailyBudget;
44
+ audit: AuditLog;
45
+ /** Overridable for tests; defaults to the real provider chain. */
46
+ chain?: ProviderChain;
47
+ /** Overridable for tests; defaults to the real Keychain read. */
48
+ readKey?: ProviderChain extends never ? never : (typeof defaultKeyReader);
49
+ }
50
+
51
+ /** Build the production provider chain from config. */
52
+ export function buildProviderChain(config: JanusConfig): ProviderChain {
53
+ return new ProviderChain(config.providers, defaultAdapters(), defaultKeyReader);
54
+ }
55
+
56
+ function toTransportQuestions(questions: readonly EnforcedQuestion[]): TransportQuestion[] {
57
+ return questions.map((question) => ({
58
+ name: question.name,
59
+ kind: question.kind,
60
+ // The question TEXT is always sent verbatim (review fix #1); schema
61
+ // enforcement guarantees it is nonempty.
62
+ question: question.question,
63
+ instructions: question.instructions,
64
+ ...(question.kind === "choice" ? { choices: question.choices } : {}),
65
+ ...(question.kind === "boolean" ? { sdkCriteria: question.sdkCriteria } : {}),
66
+ }));
67
+ }
68
+
69
+ /**
70
+ * Map an unknown error onto the janus taxonomy with a FIXED public message.
71
+ * Only the abort shape is distinguished (timeout); every other unknown error
72
+ * — including raw provider/SDK text — becomes a fixed gateway_unavailable
73
+ * message (review fix #5).
74
+ */
75
+ export function classifyUnknownError(error: unknown): JanusError {
76
+ if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
77
+ return timeoutError("evaluation timed out");
78
+ }
79
+ const text = error instanceof Error ? `${error.name} ${error.message}`.toLowerCase() : String(error);
80
+ if (/abort/.test(text) && /timeout|timed out|signal is aborted/.test(text)) {
81
+ return timeoutError("evaluation timed out");
82
+ }
83
+ return new JanusError("gateway_unavailable", "provider evaluation failed", 503);
84
+ }
85
+
86
+ export class Evaluator {
87
+ private readonly chain: ProviderChain;
88
+
89
+ constructor(private readonly deps: EvaluatorDeps) {
90
+ this.chain = deps.chain ?? buildProviderChain(deps.config);
91
+ }
92
+
93
+ async handle(body: unknown): Promise<EvaluateResponse> {
94
+ const requestId = randomUUID();
95
+ const { config } = this.deps;
96
+ const startedAt = Date.now();
97
+
98
+ try {
99
+ // 1. Schema enforcement (§6, atomic fail-closed). Question text is
100
+ // required here; a missing/blank question is validation_error —
101
+ // no synthetic `question[i]` is ever constructed (review fix #1).
102
+ const enforced = enforceRequest(body);
103
+
104
+ // 2. Request budget (§2): 30k tokens authoritative; never truncate.
105
+ estimateRequestTokens(
106
+ enforced.state,
107
+ enforced.questions,
108
+ config.requestBudgetTokens,
109
+ MAX_STATE_BYTES,
110
+ );
111
+
112
+ // 3. Redaction pass before any external call (§7, M10). Unconditional:
113
+ // there is no disable knob (review fix #4).
114
+ const state = redactValue(enforced.state);
115
+
116
+ // 4. Budget breaker check (M10).
117
+ this.deps.budget.assertWithinBudget();
118
+
119
+ // 5. Digest + single-flight (M9). Identical requests coalesce onto one
120
+ // provider call; the usage is charged exactly once.
121
+ const digest = evalInputDigest(state, enforced.questions);
122
+
123
+ const timeoutMs = Math.min(enforced.timeoutMs ?? config.timeoutMs, MAX_TIMEOUT_MS);
124
+ const questionNames = enforced.questions.map((q) => q.name);
125
+ const transportQuestions = toTransportQuestions(enforced.questions);
126
+ let coalescedWaiters = 0;
127
+
128
+ const outcome = await this.deps.singleFlight.run(
129
+ digest,
130
+ async () => {
131
+ const controller = new AbortController();
132
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
133
+ try {
134
+ const flightOutcome = await this.chain.evaluate(
135
+ { state: state as string | Record<string, unknown> | unknown[], questions: transportQuestions },
136
+ controller.signal,
137
+ );
138
+ // Usage accounting (review fixes #11/#12) is owned by the flight
139
+ // OWNER, inside the single-flight operation: each actual provider
140
+ // attempt is charged exactly once, independent of waiter count
141
+ // and independent of answer-validation outcome (success usage is
142
+ // recorded BEFORE verdict enforcement, so a provider result that
143
+ // fails enforceAnswers is still charged).
144
+ this.recordUsage(flightOutcome.result.usage);
145
+ for (const failedUsage of flightOutcome.failedAttemptUsage) {
146
+ this.recordUsage(failedUsage);
147
+ }
148
+ return flightOutcome;
149
+ } catch (error) {
150
+ // Terminal chain failure: the ProviderChainError carries every
151
+ // failed attempt's usage (including the terminal attempt). The
152
+ // owner charges it once, then surfaces the unchanged taxonomy
153
+ // error — waiters share the rejection and never re-charge.
154
+ if (error instanceof ProviderChainError) {
155
+ for (const failedUsage of error.failedAttemptUsage) {
156
+ this.recordUsage(failedUsage);
157
+ }
158
+ throw error.failure;
159
+ }
160
+ throw error;
161
+ } finally {
162
+ clearTimeout(timer);
163
+ }
164
+ },
165
+ ({ waiters }) => {
166
+ coalescedWaiters = waiters;
167
+ },
168
+ );
169
+
170
+ // 6. Verdict enforcement (§6, review fix #8): closed answer-shape
171
+ // checks; any violation fails the whole request validation_error.
172
+ const verdict: EnforcedVerdict = enforceAnswers(
173
+ enforced.questions,
174
+ outcome.result.answers as Record<string, unknown>,
175
+ outcome.result.usage,
176
+ );
177
+
178
+ // (Usage accounting happened inside the flight owner above; waiters
179
+ // consume the shared outcome without recording.)
180
+ const probabilities: Record<string, number> = {};
181
+ for (const [name, answer] of Object.entries(verdict.answers)) {
182
+ if (answer.type === "boolean") probabilities[name] = answer.probability;
183
+ if (answer.type === "score") probabilities[name] = answer.score;
184
+ }
185
+
186
+ this.deps.audit.append({
187
+ requestId,
188
+ timestamp: new Date().toISOString(),
189
+ event: "evaluate_result",
190
+ questionNames,
191
+ verdictProbabilities: probabilities,
192
+ tokenUsage: verdict.usage,
193
+ durationMs: Date.now() - startedAt,
194
+ providerAttempts: outcome.attempts,
195
+ provider: outcome.provider,
196
+ coalesced: coalescedWaiters > 0,
197
+ });
198
+
199
+ return {
200
+ status: 200,
201
+ payload: {
202
+ request_id: requestId,
203
+ verdict: verdict.answers,
204
+ usage: verdict.usage,
205
+ provider: outcome.provider,
206
+ digest,
207
+ },
208
+ };
209
+ } catch (error) {
210
+ const janusError =
211
+ error instanceof JanusError
212
+ ? error
213
+ : classifyUnknownError(error);
214
+ this.deps.audit.append({
215
+ requestId,
216
+ timestamp: new Date().toISOString(),
217
+ event: "evaluate_error",
218
+ questionNames: [],
219
+ errorCode: janusError.code,
220
+ durationMs: Date.now() - startedAt,
221
+ });
222
+ return { status: janusError.status, payload: janusError.body(requestId) };
223
+ }
224
+ }
225
+
226
+ /** Charge the breaker for one provider call's usage. */
227
+ private recordUsage(usage: TransportUsage): void {
228
+ this.deps.budget.record(usage as UsageRecord);
229
+ }
230
+ }
@@ -0,0 +1,119 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // Keychain key handling (§5, M10).
5
+ //
6
+ // Each provider's key is read from the macOS Keychain at runtime via
7
+ // `security find-generic-password -a <account> -s <service> -w` with guarded
8
+ // capture:
9
+ // - stdout is captured in-memory only; the value is never echoed, never
10
+ // logged, never included in errors, and never passed to subprocesses.
11
+ // - stderr is captured ONLY to distinguish exit codes; it never enters
12
+ // public errors or the audit log (review fix #5).
13
+ // - the secret lives in a Buffer, not a JS string, for as long as possible.
14
+ //
15
+ // Memory honesty (review fix #6): JS strings are immutable and copied by
16
+ // engines in uncontrolled ways (GC movement, substring sharing, JSON
17
+ // encoding). Zeroing `chunks.length = 0` drops references so GC can reclaim
18
+ // the memory, but janus does NOT claim guaranteed erasure of every
19
+ // intermediate copy. The claims are: no disk persistence, no logging, no
20
+ // subprocess/env forwarding, and reference release as early as possible.
21
+ //
22
+ // The bashrc env var remains for interactive shells; janus does not rely on
23
+ // inherited env — it reads the Keychain directly.
24
+
25
+ import { spawn } from "node:child_process";
26
+ import type { KeychainRef } from "./config.js";
27
+
28
+ export type KeyResolution =
29
+ | { status: "resolved"; key: string }
30
+ | { status: "missing"; reason: string }
31
+ | { status: "failed"; reason: string };
32
+
33
+ /**
34
+ * Public, fixed reason strings. They never embed stderr text, OS messages,
35
+ * or any value fragment (review fix #5).
36
+ */
37
+ export const KEYCHAIN_PUBLIC_REASONS = {
38
+ notFound: (ref: KeychainRef) =>
39
+ `Keychain item not found. Provision it with: security add-generic-password -U -a ${ref.account} -s ${ref.service} -w`,
40
+ denied: "Keychain access denied or cancelled by the user",
41
+ failed: "Keychain read failed; see local janus diagnostics",
42
+ } as const;
43
+
44
+ /**
45
+ * Read a provider key from the Keychain. `security` output is piped directly
46
+ * into memory; neither the command line nor any log ever sees the value.
47
+ * The account and service names are argv elements, not shell strings.
48
+ */
49
+ export function readGatewayKey(ref: KeychainRef): Promise<KeyResolution> {
50
+ return new Promise((resolvePromise) => {
51
+ // -w prints the password on stdout. argv is fixed (no shell
52
+ // interpolation); the secret is NEVER a command-line argument — the
53
+ // provisioning flow uses `-w` with the interactive prompt instead.
54
+ const child = spawn(
55
+ "/usr/bin/security",
56
+ ["find-generic-password", "-a", ref.account, "-s", ref.service, "-w"],
57
+ { stdio: ["ignore", "pipe", "pipe"] },
58
+ );
59
+
60
+ const chunks: Buffer[] = [];
61
+ const errChunks: Buffer[] = [];
62
+ let settled = false;
63
+
64
+ const settle = (resolution: KeyResolution) => {
65
+ if (settled) return;
66
+ settled = true;
67
+ // Drop references promptly so GC can reclaim the secret buffers.
68
+ // (Honesty note: this is reference release, not guaranteed erasure —
69
+ // see the module header.)
70
+ chunks.length = 0;
71
+ errChunks.length = 0;
72
+ resolvePromise(resolution);
73
+ };
74
+
75
+ child.stdout.on("data", (chunk: Buffer) => {
76
+ chunks.push(chunk);
77
+ });
78
+ child.stderr.on("data", (chunk: Buffer) => {
79
+ errChunks.push(chunk);
80
+ });
81
+ child.on("error", () => {
82
+ // The OS error message (e.g. spawn failure details) is sanitized away.
83
+ settle({ status: "failed", reason: KEYCHAIN_PUBLIC_REASONS.failed });
84
+ });
85
+ child.on("close", (code) => {
86
+ const key = Buffer.concat(chunks).toString("utf8").replace(/\n+$/, "");
87
+ const stderr = Buffer.concat(errChunks).toString("utf8");
88
+ if (code === 0 && key.length > 0) {
89
+ settle({ status: "resolved", key });
90
+ return;
91
+ }
92
+ if (code === 44) {
93
+ // 44 = item not found in the keychain. Public hint only.
94
+ settle({ status: "missing", reason: KEYCHAIN_PUBLIC_REASONS.notFound(ref) });
95
+ return;
96
+ }
97
+ if (code === 45 || code === 51 || /user.*denied|cancel/i.test(stderr)) {
98
+ settle({ status: "missing", reason: KEYCHAIN_PUBLIC_REASONS.denied });
99
+ return;
100
+ }
101
+ // stderr content is inspected for the exit-code class only and is
102
+ // never included in the public reason.
103
+ settle({ status: "failed", reason: KEYCHAIN_PUBLIC_REASONS.failed });
104
+ });
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Readiness state for a provider key. Resolves only when the key exists and
110
+ * is readable; never exposes the value.
111
+ */
112
+ export async function keyReadiness(ref: KeychainRef): Promise<{
113
+ ready: boolean;
114
+ reason?: string;
115
+ }> {
116
+ const resolution = await readGatewayKey(ref);
117
+ if (resolution.status === "resolved") return { ready: true };
118
+ return { ready: false, reason: resolution.reason };
119
+ }