From 9c3f80fc66a31a7bfc4a5a98e1a7d06321f10d96 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Sat, 19 Sep 2026 21:55:50 +0200 Subject: [PATCH] feat: compose decisions and support project packs --- README.md | 15 ++++++++-- package-lock.json | 4 +-- package.json | 4 +-- src/cli.ts | 14 +++++---- src/cli/formatters.ts | 3 +- src/cli/help.ts | 25 +++++++++++----- src/cli/parseArgs.ts | 24 ++++++++++++--- src/cli/records.ts | 15 +++++++++- src/commands/decide.ts | 38 ++++++++++++++++++++++++ src/commands/gate.ts | 21 +++++-------- src/commands/ops.ts | 2 +- src/commands/packs.ts | 56 ++++++++++++++++++++++++----------- src/commands/requests.ts | 64 +++++++++++++++------------------------- src/index.ts | 4 +-- 14 files changed, 191 insertions(+), 98 deletions(-) create mode 100644 src/commands/decide.ts diff --git a/README.md b/README.md index 4bd141e..b68cb4a 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,25 @@ Requires Node.js >= 22.0.0. ## The decision pipeline -Inference and policy are separate steps, so a judgment is reproducible and auditable: +Inference and policy remain separate primitives. `decide` composes them when a caller wants one request, one inspectable envelope, and the existing gate exit codes: + +```bash +jev decide --pack verify --state-file claim.json --state-format json --record decisions/claim-1.json +jev decide --pack-file .jev/claim-pack.json --state-file claim.json --format decision +``` + +JSON output distinguishes `response`, `gate`, `provenance`, and correlation IDs. `--format decision` prints only `accept`, `review`, `deny`, or `abstain`; diagnostics remain on stderr. `decide` never executes a follow-up command. `ask` and `gate` remain independently usable. + +`--pack-file` loads one project-owned pack at the exact path supplied. It uses the same question/policy validation and content hash as bundled packs; there is no registry or implicit search. + +For the lower-level workflow: ```bash # 1. Judge — questions from a bundled pack, state from you, record for the audit trail jev ask --pack verify --state-file claim.json --state-format json --record decisions/claim-1.json # 2. Decide — offline, no API call, exit code is the decision -jev gate --input decisions/claim-1.json --pack verify; echo "exit $?" # 0 2 3 4 +jev gate --input decisions/claim-1.json --pack verify; echo "exit $?" # 3. Show your work — re-emit the stored answers without paying again jev replay --record decisions/claim-1.json diff --git a/package-lock.json b/package-lock.json index a72b2e0..268ac83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fiale-plus/jev-cli", - "version": "0.1.3", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fiale-plus/jev-cli", - "version": "0.1.3", + "version": "0.2.0", "license": "MIT", "dependencies": { "@typesafe-ai/sdk": "^0.6.0" diff --git a/package.json b/package.json index d632c04..a69ace2 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dist/", "packs/", "examples/", - "README.md", + "tools/", "LICENSE" ], "keywords": [ @@ -52,7 +52,7 @@ }, "type": "module", "types": "dist/index.d.ts", - "version": "0.1.3", + "version": "0.2.0", "dependencies": { "@typesafe-ai/sdk": "^0.6.0" } diff --git a/src/cli.ts b/src/cli.ts index 1bd4b12..3857a2e 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import { APIError, TypeSafeError } from "@typesafe-ai/sdk"; import { parseGlobal, extractGlobalOpts } from "./cli/parseArgs.js"; import type { OutputFormat } from "./cli/formatters.js"; import { formatJsonError } from "./cli/formatters.js"; -import { ASK_HELP, BATCH_HELP, DOCTOR_HELP, GATE_HELP, MAIN_HELP, MODELS_HELP, PACKS_HELP, REPLAY_HELP } from "./cli/help.js"; +import { ASK_HELP, BATCH_HELP, DECIDE_HELP, DOCTOR_HELP, GATE_HELP, MAIN_HELP, MODELS_HELP, PACKS_HELP, REPLAY_HELP } from "./cli/help.js"; import { resolveApiKey } from "./utils/validation.js"; import { cliVersion } from "./utils/package.js"; import { handleBatch, handleLint, handleModels } from "./commands/ops.js"; @@ -12,19 +12,20 @@ import type { ClientOpts } from "./api/client.js"; import { clientOpts, handleAsk, handleChoice, handleNoul, handleScore } from "./commands/requests.js"; import { handleGate } from "./commands/gate.js"; import { handleReplay } from "./commands/replay.js"; -import { handlePacks } from "./commands/packs.js"; import { handleDoctor } from "./commands/doctor.js"; +import { handlePacks } from "./commands/packs.js"; +import { handleDecide } from "./commands/decide.js"; const HELP_BY_COMMAND: Record = { ask: ASK_HELP, batch: BATCH_HELP, + decide: DECIDE_HELP, models: MODELS_HELP, gate: GATE_HELP, replay: REPLAY_HELP, packs: PACKS_HELP, doctor: DOCTOR_HELP, }; - async function main(): Promise { const argv = process.argv.slice(2); @@ -59,8 +60,11 @@ async function main(): Promise { await handleLint(global); return; - // Offline commands: no API key required, so policies and records can be - // evaluated in CI and in environments that must not hold a key. + case "decide": { + const opts = clientOpts(global, resolveApiKey(global.apiKey)); + await handleDecide(global, opts, format); + return; + } case "gate": await handleGate(global, format); return; diff --git a/src/cli/formatters.ts b/src/cli/formatters.ts index 261bfb3..375cdd1 100644 --- a/src/cli/formatters.ts +++ b/src/cli/formatters.ts @@ -3,7 +3,7 @@ import { estimateCostUsd } from "../api/client.js"; import type { DecisionRecord } from "./records.js"; import type { GateResult } from "./policy.js"; -export type OutputFormat = "json" | "table"; +export type OutputFormat = "json" | "table" | "decision"; export interface DoctorCheck { name: string; @@ -99,6 +99,7 @@ function formatTable(response: SystemOneResult): string { } export function formatGate(result: GateResult, provenance: GateProvenance, format: OutputFormat): string { + if (format === "decision") return result.decision; if (format === "json") return JSON.stringify({ gate: result, provenance }, null, 2); const lines = [ `decision: ${result.decision} (exit ${result.exit_code})`, diff --git a/src/cli/help.ts b/src/cli/help.ts index 508dfd0..d85cbff 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -8,7 +8,7 @@ COMMANDS choice Pick one option (--option name="desc" ×2+) score Rate along levels (--level "desc" ×2+, lowest first) ask Batch questions over one state in a single call - batch Many requests from a JSONL file, bounded concurrency + decide Judge and apply a pack policy in one call gate Apply a policy to a saved judgment — no API call replay Re-emit a stored record — no API call packs List the bundled question packs @@ -26,9 +26,12 @@ STATE (noul/choice/score/ask) REQUEST FILE (ask) --request Full request {"state","questions","model?"} --questions Questions map (with --state/--state-file/--stdin) - --pack Bundled questions (jev packs) — "verify", "screen", "route" + --pack Bundled questions/policy pack + --pack-file Project-owned questions/policy pack --record Write a decision record (state hashed, not stored) - + --run-id Correlate related operations + --decision-id Set the decision correlation ID + --parent-id Link this decision to a parent operation EXIT CODES (execution status) 0 success — inference completed, answers on stdout 1 usage, transport, or API error @@ -75,6 +78,16 @@ DISCLAIMER Unofficial, community-maintained. Not affiliated with TypeSafe. Typed output guarantees the interface, not the truth — validate on your data. `; +export const DECIDE_HELP = `jev decide — Run inference and policy together + +USAGE + jev decide --pack --state-file [--record ] + jev decide --pack-file --state-file [--format decision] + +The policy is explicit in the selected pack. JSON output separates response, +gate result, provenance, and correlation IDs. Exit codes are accept 0, +review 2, deny 3, abstain 4; execution failures remain 1. +`; export const ASK_HELP = `jev ask — Questions over one state in a single call @@ -124,11 +137,9 @@ USAGE jev models NOTE - The list carries aliases. Versioned IDs (e.g. jev-1.13.0) are accepted by - the model field whether or not they appear in the list. The response "model" - field reports the versioned ID that answered — log it. + The list carries aliases. Versioned IDs are accepted by the model field; the + response model field reports the versioned ID that answered — log it. `; - export const GATE_HELP = `jev gate — Apply a policy to a saved judgment (no API call) USAGE diff --git a/src/cli/parseArgs.ts b/src/cli/parseArgs.ts index 46447f1..377f897 100644 --- a/src/cli/parseArgs.ts +++ b/src/cli/parseArgs.ts @@ -5,7 +5,7 @@ export interface GlobalOptions { model?: string; baseUrl?: string; logLevel?: string; - format: "json" | "table"; + format: "json" | "table" | "decision"; request?: string; state?: string; stateFile?: string; @@ -20,9 +20,14 @@ export interface GlobalOptions { retries?: string; concurrency?: string; pack?: string; + packFile?: string; policy?: string; input?: string; record?: string; + recordJsonl?: string; + runId?: string; + decisionId?: string; + parentId?: string; live: boolean; help: boolean; version: boolean; @@ -48,22 +53,28 @@ const OPTIONS = { retries: { type: "string" as const }, concurrency: { type: "string" as const }, pack: { type: "string" as const }, + "pack-file": { type: "string" as const }, policy: { type: "string" as const }, input: { type: "string" as const }, record: { type: "string" as const }, + "record-jsonl": { type: "string" as const }, + "run-id": { type: "string" as const }, + "decision-id": { type: "string" as const }, + "parent-id": { type: "string" as const }, live: { type: "boolean" as const, default: false }, help: { type: "boolean" as const, default: false }, version: { type: "boolean" as const, default: false }, }; + export function parseGlobal(argv: string[]) { return parseArgs({ args: argv, options: OPTIONS, strict: true, allowPositionals: true }); } export function extractGlobalOpts(values: Record): GlobalOptions { const format = (values.format as string) || "json"; - if (format !== "json" && format !== "table") { - throw new Error(`Invalid --format: "${format}". Expected json|table.`); + if (format !== "json" && format !== "table" && format !== "decision") { + throw new Error(`Invalid --format: "${format}". Expected json|table|decision.`); } const stateFormat = (values["state-format"] as string) || "text"; if (stateFormat !== "text" && stateFormat !== "json") { @@ -79,7 +90,7 @@ export function extractGlobalOpts(values: Record): GlobalOption model: values.model as string | undefined, baseUrl: values["base-url"] as string | undefined, logLevel, - format: format as "json" | "table", + format: format as "json" | "table" | "decision", request: values.request as string | undefined, state: values.state as string | undefined, stateFile: values["state-file"] as string | undefined, @@ -94,9 +105,14 @@ export function extractGlobalOpts(values: Record): GlobalOption retries: values.retries as string | undefined, concurrency: values.concurrency as string | undefined, pack: values.pack as string | undefined, + packFile: values["pack-file"] as string | undefined, policy: values.policy as string | undefined, input: values.input as string | undefined, record: values.record as string | undefined, + recordJsonl: values["record-jsonl"] as string | undefined, + runId: values["run-id"] as string | undefined, + decisionId: values["decision-id"] as string | undefined, + parentId: values["parent-id"] as string | undefined, live: (values.live as boolean) || false, help: (values.help as boolean) || false, version: (values.version as boolean) || false, diff --git a/src/cli/records.ts b/src/cli/records.ts index 4c9b3b6..6813b26 100644 --- a/src/cli/records.ts +++ b/src/cli/records.ts @@ -1,4 +1,5 @@ -import { writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { appendFileSync, writeFileSync } from "node:fs"; import type { Questions, SystemOneResult } from "@typesafe-ai/sdk"; import { estimateCostUsd } from "../api/client.js"; import { hashValue, sha256Hex } from "../utils/hash.js"; @@ -16,6 +17,9 @@ export interface RecordPackRef { // confidential text, and the hash is enough to commit to which input was judged. export interface DecisionRecord { record_version: number; + run_id?: string; + decision_id?: string; + parent_id?: string | null; created_at: string; cli_version: string; pack: RecordPackRef | null; @@ -28,6 +32,9 @@ export interface DecisionRecord { } export interface RecordInputs { + runId?: string; + decisionId?: string; + parentId?: string; pack: RecordPackRef | null; modelRequested: string | undefined; state: unknown; @@ -45,6 +52,9 @@ function stateHash(state: unknown): string | null { export function buildRecord(inputs: RecordInputs, response: SystemOneResult): DecisionRecord { return { record_version: RECORD_VERSION, + run_id: inputs.runId ?? randomUUID(), + decision_id: inputs.decisionId ?? randomUUID(), + parent_id: inputs.parentId ?? null, created_at: new Date().toISOString(), cli_version: cliVersion(), pack: inputs.pack, @@ -60,6 +70,9 @@ export function buildRecord(inputs: RecordInputs, response: SystemOneResult { + if (global.pack === undefined && global.packFile === undefined) throw new Error("Missing policy pack: decide requires --pack or --pack-file ."); + if (global.policy !== undefined) throw new Error("Conflicting inputs: decide uses the policy in --pack or --pack-file."); + const loaded = global.pack !== undefined ? loadPack(global.pack) : loadPackFile(global.packFile as string); + const state = await readState({ state: global.state, stateFile: global.stateFile, stateFormat: global.stateFormat, stdin: global.stdin, extra: [] }); + const started = Date.now(); + const response = await createClient(opts).systemOne({ state: state as never, questions: loaded.pack.questions, ...(global.model !== undefined ? { model: global.model } : {}) }, { signal: abortSignal() }); + const record = buildRecord({ runId: global.runId, decisionId: global.decisionId, parentId: global.parentId, pack: { name: loaded.pack.name, pack_version: loaded.pack.pack_version, hash: loaded.hash }, modelRequested: global.model, state, questions: loaded.pack.questions, latencyMs: Date.now() - started }, response); + if (global.recordJsonl !== undefined) { + mkdirSync(dirname(resolve(global.recordJsonl)), { recursive: true }); + appendRecordJsonl(global.recordJsonl, record); + } + if (global.record !== undefined) { + writeRecord(global.record, record); + process.stderr.write(`record written: ${global.record} (${record.decision_id})\n`); + } + const { policy, pack, questionsHash, source } = resolvePolicy(global); + const gate = evaluatePolicy(policy, response); + const provenance = { policy_source: source, pack, model: response.model ?? null, record_version: record.record_version, pack_hash_matches_record: true, questions_match_record: true, response_answers_hash: `sha256:${hashValue(response.answers ?? {})}` }; + if (format === "decision") process.stdout.write(`${gate.decision}\n`); + else if (format === "json") process.stdout.write(JSON.stringify({ response, gate, provenance, record: { run_id: record.run_id, decision_id: record.decision_id } }, null, 2) + "\n"); + else process.stdout.write(formatGate(gate, provenance, format) + "\n"); + process.exitCode = gate.exit_code; +} diff --git a/src/commands/gate.ts b/src/commands/gate.ts index b830eb9..8f4b2d2 100644 --- a/src/commands/gate.ts +++ b/src/commands/gate.ts @@ -4,9 +4,9 @@ import { formatGate } from "../cli/formatters.js"; import type { GatePolicy } from "../cli/policy.js"; import { GATE_EXIT, coercePolicy, evaluatePolicy } from "../cli/policy.js"; import { extractResponse, isRecord, readRecord } from "../cli/records.js"; -import { loadPack } from "./packs.js"; -import { hashValue } from "../utils/hash.js"; +import { loadPack, loadPackFile } from "./packs.js"; import { readJsonFile } from "../utils/io.js"; +import { hashValue } from "../utils/hash.js"; export interface ResolvedPolicy { policy: GatePolicy; @@ -19,23 +19,16 @@ export interface ResolvedPolicy { // Exactly one policy source. A pack carries its own policy, so `--pack verify` is // enough; `--policy file.json` is for policies tuned on your own data. export function resolvePolicy(global: GlobalOptions): ResolvedPolicy { - if (global.policy !== undefined && global.pack !== undefined) { - throw new Error("Conflicting inputs: --policy and --pack are mutually exclusive."); - } - if (global.pack !== undefined) { - const loaded = loadPack(global.pack); - return { - policy: loaded.pack.policy, - pack: { name: loaded.pack.name, pack_version: loaded.pack.pack_version, hash: loaded.hash }, - questionsHash: `sha256:${hashValue(loaded.pack.questions)}`, - source: loaded.path, - }; + if (global.policy !== undefined && (global.pack !== undefined || global.packFile !== undefined)) throw new Error("Conflicting inputs: --policy and pack options are mutually exclusive."); + if (global.pack !== undefined || global.packFile !== undefined) { + const loaded = global.pack !== undefined ? loadPack(global.pack) : loadPackFile(global.packFile as string); + return { policy: loaded.pack.policy, pack: { name: loaded.pack.name, pack_version: loaded.pack.pack_version, hash: loaded.hash }, questionsHash: `sha256:${hashValue(loaded.pack.questions)}`, source: loaded.path }; } if (global.policy !== undefined) { const policy = coercePolicy(readRecordOrJson(global.policy)); return { policy, pack: null, questionsHash: null, source: global.policy }; } - throw new Error("Missing policy: pass --pack or --policy . Run `jev packs` to list packs."); + throw new Error("Missing policy: pass --pack, --pack-file, or --policy. Run `jev packs` to list bundled packs."); } // A policy file may hold the policy itself or an object with a "policy" field. diff --git a/src/commands/ops.ts b/src/commands/ops.ts index d171a89..2da2990 100644 --- a/src/commands/ops.ts +++ b/src/commands/ops.ts @@ -34,8 +34,8 @@ export async function handleBatch(global: GlobalOptions, opts: ClientOpts, forma if (concurrency < 1) throw new Error(`Invalid --concurrency: "${global.concurrency}". Expected an integer in [1, 32].`); const signal = abortSignal(); const client = createClient(opts); - const inFlight = new Map>(); + const buffered = new Map(); let nextIndex = 0; let failures = 0; diff --git a/src/commands/packs.ts b/src/commands/packs.ts index 6a4c1c5..3f8bc93 100644 --- a/src/commands/packs.ts +++ b/src/commands/packs.ts @@ -43,6 +43,35 @@ export function packNames(): string[] { } } +function validateRawPack(raw: unknown, expectedName?: string): Pack { + const label = expectedName ?? "file"; + if (typeof raw !== "object" || raw === null) throw new Error(`Invalid pack "${label}": expected a JSON object.`); + const record = raw as Record; + if (record.pack_version !== 1) throw new Error(`Invalid pack "${label}": pack_version must be 1.`); + if (typeof record.name !== "string") { + throw new Error(`Invalid pack "${label}": name is required.`); + } + if (expectedName !== undefined && record.name !== expectedName) { + throw new Error(`Invalid pack "${expectedName}": name must match the file name.`); + } + if (typeof record.description !== "string" || record.description.trim().length === 0) { + throw new Error(`Invalid pack "${label}": description is required.`); + } + + const questionsLint = lintQuestions(record.questions); + if (!questionsLint.ok) { + const first = questionsLint.issues.find((i) => i.severity === "error"); + throw new Error(`Invalid pack "${label}": questions ${first?.qid ? `(${first.qid}) ` : ""}[${first?.code}]: ${first?.message}`); + } + const policy = coercePolicy(record.policy); + return record as unknown as Pack; +} + +function loadedPack(pack: Pack, path: string): LoadedPack { + const policy = coercePolicy(pack.policy); + return { pack, hash: `sha256:${hashValue({ questions: pack.questions, policy })}`, path }; +} + export function loadPack(name: string): LoadedPack { const path = packFile(name); let raw: unknown; @@ -54,25 +83,18 @@ export function loadPack(name: string): LoadedPack { `Unknown pack "${name}" (${err instanceof Error ? err.message : String(err)}). Available: ${available.length > 0 ? available.join(", ") : "none"}.`, ); } - if (typeof raw !== "object" || raw === null) throw new Error(`Invalid pack "${name}": expected a JSON object.`); - const record = raw as Record; - if (record.pack_version !== 1) throw new Error(`Invalid pack "${name}": pack_version must be 1.`); - if (typeof record.name !== "string" || record.name !== name) { - throw new Error(`Invalid pack "${name}": name must match the file name.`); - } - if (typeof record.description !== "string" || record.description.trim().length === 0) { - throw new Error(`Invalid pack "${name}": description is required.`); - } + return loadedPack(validateRawPack(raw, name), path); +} - const questionsLint = lintQuestions(record.questions); - if (!questionsLint.ok) { - const first = questionsLint.issues.find((i) => i.severity === "error"); - throw new Error(`Invalid pack "${name}": questions ${first?.qid ? `(${first.qid}) ` : ""}[${first?.code}]: ${first?.message}`); +/** Load and validate a pack from exactly the supplied JSON file path. */ +export function loadPackFile(path: string): LoadedPack { + let raw: unknown; + try { + raw = JSON.parse(readFileSync(path, "utf8")) as unknown; + } catch (err) { + throw new Error(`Unable to load pack file "${path}": ${err instanceof Error ? err.message : String(err)}.`); } - const policy = coercePolicy(record.policy); - - const pack = record as unknown as Pack; - return { pack, hash: `sha256:${hashValue({ questions: pack.questions, policy })}`, path }; + return loadedPack(validateRawPack(raw), path); } export function listPacks(): Array<{ name: string; pack_version: number; description: string; hash: string; ruleCount: number; questionCount: number }> { diff --git a/src/commands/requests.ts b/src/commands/requests.ts index 612f1a9..27169bc 100644 --- a/src/commands/requests.ts +++ b/src/commands/requests.ts @@ -11,7 +11,7 @@ import { readJsonFile, readState } from "../utils/io.js"; import { coerceQuestions } from "../cli/lint.js"; import type { RecordPackRef } from "../cli/records.js"; import { buildRecord, writeRecord } from "../cli/records.js"; -import { loadPack } from "./packs.js"; +import { loadPack, loadPackFile } from "./packs.js"; export function clientOpts(global: GlobalOptions, apiKey: string): ClientOpts { return { @@ -32,16 +32,17 @@ interface EmitOptions { questions: Questions; model: string | undefined; format: OutputFormat; - /** Pack revision this call used, when the questions came from a pack. */ pack: RecordPackRef | null; - /** --record : write a decision record alongside the printed response. */ recordPath: string | undefined; + runId?: string; + decisionId?: string; + parentId?: string; signal?: AbortSignal; } // Successful inference always exits 0. Confidence is data, not authorization: // callers decide which answers matter and apply their own policy. -async function emit({ opts, state, questions, model, format, pack, recordPath, signal }: EmitOptions): Promise { +async function emit({ opts, state, questions, model, format, pack, recordPath, runId, decisionId, parentId, signal }: EmitOptions): Promise { if (recordPath !== undefined) { // Prepared before the request: a bad record path must fail before paying for // an answer that would then have nowhere to go. @@ -60,11 +61,10 @@ async function emit({ opts, state, questions, model, format, pack, recordPath, s { ...(signal !== undefined ? { signal } : {}) }, ); const latencyMs = Date.now() - started; - if (recordPath !== undefined) { - const record = buildRecord({ pack, modelRequested: model, state, questions, latencyMs }, response); + const record = buildRecord({ runId, decisionId, parentId, pack, modelRequested: model, state, questions, latencyMs }, response); writeRecord(recordPath, record); - process.stderr.write(`record written: ${recordPath}\n`); + process.stderr.write(`record written: ${recordPath} (${record.decision_id ?? "un-correlated"})\n`); } // Broken pipe surfaces via the process error handler; stdout stays the only @@ -94,11 +94,9 @@ function stateSource(global: GlobalOptions) { return { state: global.state, stateFile: global.stateFile, stateFormat: global.stateFormat, stdin: global.stdin }; } -// --pack applies to `ask`, where the caller brings their own state. The single-question -// commands build their questions from flags, so a pack there would be unreachable. function rejectPack(global: GlobalOptions, command: string): void { - if (global.pack !== undefined) { - throw new Error(`Conflicting inputs: --pack applies to "ask" (state plus pack questions), not "${command}".`); + if (global.pack !== undefined || global.packFile !== undefined) { + throw new Error(`Conflicting inputs: --pack/--pack-file applies to "ask" (state plus pack questions), not "${command}".`); } } @@ -117,7 +115,7 @@ export async function handleNoul(positionals: string[], global: GlobalOptions, o model: global.model, format, pack: null, - recordPath: global.record, + recordPath: global.record, runId: global.runId, decisionId: global.decisionId, parentId: global.parentId, signal: abortSignal(), }); } @@ -143,7 +141,7 @@ export async function handleChoice(positionals: string[], global: GlobalOptions, model: global.model, format, pack: null, - recordPath: global.record, + recordPath: global.record, runId: global.runId, decisionId: global.decisionId, parentId: global.parentId, signal: abortSignal(), }); } @@ -163,18 +161,23 @@ export async function handleScore(positionals: string[], global: GlobalOptions, model: global.model, format, pack: null, - recordPath: global.record, + recordPath: global.record, runId: global.runId, decisionId: global.decisionId, parentId: global.parentId, signal: abortSignal(), }); } export async function handleAsk(global: GlobalOptions, opts: ClientOpts, format: OutputFormat): Promise { const hasStateFlags = global.state !== undefined || global.stateFile !== undefined || global.stdin; - // Full request file: {state, model?, questions} — mirrors the API shape. - // --request is mutually exclusive with --state/--state-file/--stdin/--model/--questions. + const packSource = global.pack !== undefined ? loadPack(global.pack) : global.packFile !== undefined ? loadPackFile(global.packFile) : null; + if (packSource !== null) { + if (global.questions !== undefined) throw new Error("Conflicting inputs: a pack and --questions both supply questions. Use one."); + const state = await readState({ ...stateSource(global), extra: [] }); + await emit({ opts, state, questions: packSource.pack.questions, model: global.model, format, pack: { name: packSource.pack.name, pack_version: packSource.pack.pack_version, hash: packSource.hash }, recordPath: global.record, runId: global.runId, decisionId: global.decisionId, parentId: global.parentId, signal: abortSignal() }); + return; + } if (global.request) { - if (hasStateFlags || global.model !== undefined || global.questions !== undefined || global.pack !== undefined) { - throw new Error("Conflicting inputs: --request is mutually exclusive with --state, --state-file, --stdin, --model, --questions, and --pack. Put state/model/questions in the request file."); + if (hasStateFlags || global.model !== undefined || global.questions !== undefined || global.pack !== undefined || global.packFile !== undefined) { + throw new Error("Conflicting inputs: --request is mutually exclusive with state, model, questions, and pack options. Put them in the request file."); } const raw = readJsonFile(global.request) as { state?: unknown; model?: string; questions?: unknown }; if (typeof raw !== "object" || raw === null || raw.state === undefined || raw.questions === undefined) { @@ -182,34 +185,15 @@ export async function handleAsk(global: GlobalOptions, opts: ClientOpts, format: } const questions = coerceQuestions(raw.questions); const model = typeof raw.model === "string" ? raw.model : undefined; - await emit({ opts, state: raw.state, questions, model, format, pack: null, recordPath: global.record, signal: abortSignal() }); + await emit({ opts, state: raw.state, questions, model, format, pack: null, recordPath: global.record, runId: global.runId, decisionId: global.decisionId, parentId: global.parentId, signal: abortSignal() }); return; } - // Pack mode: questions from the bundled pack, state from the caller. - if (global.pack !== undefined) { - if (global.questions !== undefined) { - throw new Error("Conflicting inputs: --pack and --questions both supply questions. Use one."); - } - const loaded = loadPack(global.pack); - const state = await readState({ ...stateSource(global), extra: [] }); - await emit({ - opts, - state, - questions: loaded.pack.questions, - model: global.model, - format, - pack: { name: loaded.pack.name, pack_version: loaded.pack.pack_version, hash: loaded.hash }, - recordPath: global.record, - signal: abortSignal(), - }); - return; - } if (!global.questions) { - throw new Error("Missing questions: jev ask --request | --questions --state ... | --pack --state ... ."); + throw new Error("Missing questions: jev ask --request | --questions --state ... | --pack/--pack-file --state ... ."); } const questions = coerceQuestions(readJsonFile(global.questions)); const state = await readState({ ...stateSource(global), extra: [] }); - await emit({ opts, state, questions, model: global.model, format, pack: null, recordPath: global.record, signal: abortSignal() }); + await emit({ opts, state, questions, model: global.model, format, pack: null, recordPath: global.record, runId: global.runId, decisionId: global.decisionId, parentId: global.parentId, signal: abortSignal() }); } diff --git a/src/index.ts b/src/index.ts index 37f6e58..dcffc90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,9 +6,9 @@ export type { LintIssue, LintResult } from "./cli/lint.js"; // thresholds on their own records needs the same decision function the CLI uses. export { lintPolicy, coercePolicy, evaluatePolicy, policyHash, GATE_EXIT } from "./cli/policy.js"; export type { GateDecision, GatePolicy, GateResult, GateRule, RuleOutcome } from "./cli/policy.js"; -export { buildRecord, extractResponse, isRecord, readRecord, recordCost, RECORD_VERSION } from "./cli/records.js"; +export { appendRecordJsonl, buildRecord, extractResponse, isRecord, readRecord, recordCost, RECORD_VERSION } from "./cli/records.js"; export type { DecisionRecord, RecordPackRef } from "./cli/records.js"; -export { listPacks, loadPack } from "./commands/packs.js"; +export { listPacks, loadPack, loadPackFile } from "./commands/packs.js"; export type { Pack } from "./commands/packs.js"; export { canonicalJson, hashValue, sha256Hex } from "./utils/hash.js"; export { cliVersion, packageRoot, packsDir } from "./utils/package.js";