prismatica 0.2.0 → 0.4.0
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/README.md +20 -8
- package/dist/assurance/mainpush.js +383 -8
- package/dist/assurance/mainpush.js.map +1 -1
- package/dist/assurance/proofinputs.js +62 -34
- package/dist/assurance/proofinputs.js.map +1 -1
- package/dist/atlas/labels.js +2 -2
- package/dist/atlas/labels.js.map +1 -1
- package/dist/atlas/next.js +120 -18
- package/dist/atlas/next.js.map +1 -1
- package/dist/board/html.js +37 -9
- package/dist/board/html.js.map +1 -1
- package/dist/board/model.js +58 -14
- package/dist/board/model.js.map +1 -1
- package/dist/cli.js +70 -6
- package/dist/cli.js.map +1 -1
- package/dist/commands/delta.js +84 -1
- package/dist/commands/delta.js.map +1 -1
- package/dist/commands/doctor.js +55 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/frame.js +13 -1
- package/dist/commands/frame.js.map +1 -1
- package/dist/commands/guide.js +17 -5
- package/dist/commands/guide.js.map +1 -1
- package/dist/commands/next.js +196 -26
- package/dist/commands/next.js.map +1 -1
- package/dist/commands/release.js +950 -0
- package/dist/commands/release.js.map +1 -0
- package/dist/commands/ship.js +150 -15
- package/dist/commands/ship.js.map +1 -1
- package/dist/commands/start.js +5 -2
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/update.js +435 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/detect.js +40 -10
- package/dist/detect.js.map +1 -1
- package/dist/executor.js +15 -0
- package/dist/executor.js.map +1 -1
- package/dist/git.js +27 -0
- package/dist/git.js.map +1 -1
- package/dist/guide.js +254 -33
- package/dist/guide.js.map +1 -1
- package/dist/paths.js +7 -0
- package/dist/paths.js.map +1 -1
- package/dist/planning/pack.js +12 -7
- package/dist/planning/pack.js.map +1 -1
- package/dist/planning/schema.js +17 -1
- package/dist/planning/schema.js.map +1 -1
- package/dist/prepush.js +52 -0
- package/dist/prepush.js.map +1 -1
- package/dist/records/store.js +17 -1
- package/dist/records/store.js.map +1 -1
- package/dist/records/types.js +122 -7
- package/dist/records/types.js.map +1 -1
- package/dist/releases.js +287 -0
- package/dist/releases.js.map +1 -0
- package/dist/render.js +23 -0
- package/dist/render.js.map +1 -1
- package/dist/title.js +69 -0
- package/dist/title.js.map +1 -0
- package/package.json +5 -1
- package/dist/agent-runner.js +0 -656
- package/dist/agent-runner.js.map +0 -1
- package/dist/atlas/packs.js +0 -169
- package/dist/atlas/packs.js.map +0 -1
package/dist/agent-runner.js
DELETED
|
@@ -1,656 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The safe runner for an INSTALLED agent CLI.
|
|
3
|
-
*
|
|
4
|
-
* This is not an LLM client. Prismatica imports no LLM SDK, holds no API key,
|
|
5
|
-
* and reads no transcript (src/safety/no-llm-imports.test.ts enforces that
|
|
6
|
-
* forever). What this module does is exactly what `open` already does — shell
|
|
7
|
-
* out to a CLI the owner installed and is already paying for — with three
|
|
8
|
-
* additions that the Planner role needs and an interactive session does not:
|
|
9
|
-
*
|
|
10
|
-
* 1. a read-only, non-persisting mode, so a planning run cannot edit the repo
|
|
11
|
-
* or leave a resumable session behind;
|
|
12
|
-
* 2. a declared JSON Schema, so the answer arrives as one structured document
|
|
13
|
-
* instead of prose we would have to parse loosely;
|
|
14
|
-
* 3. capability detection that FAILS CLOSED — if the installed version does
|
|
15
|
-
* not advertise the exact flags we depend on, we refuse and hand the owner
|
|
16
|
-
* the copy/import fallback rather than inventing brittle flags.
|
|
17
|
-
*
|
|
18
|
-
* Everything the agent returns is UNTRUSTED. This module only guarantees the
|
|
19
|
-
* bytes came back and parse as JSON; `planning/validate.ts` decides whether
|
|
20
|
-
* their CONTENT may be acted on. Nothing here ever executes a string the agent
|
|
21
|
-
* produced.
|
|
22
|
-
*
|
|
23
|
-
* The flags below were read from the live CLIs' own `--help` output, not from
|
|
24
|
-
* memory — see DECISIONS.md "Installed-agent adapters".
|
|
25
|
-
*/
|
|
26
|
-
import fs from 'node:fs';
|
|
27
|
-
import os from 'node:os';
|
|
28
|
-
import path from 'node:path';
|
|
29
|
-
import { execa } from 'execa';
|
|
30
|
-
import { agentExecutorEnv } from './executor.js';
|
|
31
|
-
import { getAdapter, resolveTuning } from './skills.js';
|
|
32
|
-
export const PLANNER_AGENTS = ['claude', 'codex'];
|
|
33
|
-
export function isPlannerAgent(value) {
|
|
34
|
-
return PLANNER_AGENTS.includes(value);
|
|
35
|
-
}
|
|
36
|
-
/** The real executor. `shell` is never enabled — args are passed as a vector. */
|
|
37
|
-
export const spawnExecutor = async (file, args, options) => {
|
|
38
|
-
const result = await execa(file, args, {
|
|
39
|
-
cwd: options.cwd,
|
|
40
|
-
timeout: options.timeoutMs,
|
|
41
|
-
// execa refuses both at once, so exactly one is ever passed.
|
|
42
|
-
...(options.input === undefined ? { stdin: options.stdin } : { input: options.input }),
|
|
43
|
-
reject: false,
|
|
44
|
-
// Explicitly not a shell: no string is ever interpreted, so nothing the
|
|
45
|
-
// agent (or a pack) contains can become a command.
|
|
46
|
-
shell: false,
|
|
47
|
-
// The Planner is read-only and non-persisting and could not amend anyway,
|
|
48
|
-
// but every agent Prismatica launches carries the same marker — a launch
|
|
49
|
-
// site that forgets it is how the guard quietly stops covering something.
|
|
50
|
-
env: agentExecutorEnv(),
|
|
51
|
-
extendEnv: true,
|
|
52
|
-
});
|
|
53
|
-
return {
|
|
54
|
-
exitCode: result.exitCode ?? 1,
|
|
55
|
-
stdout: result.stdout ?? '',
|
|
56
|
-
stderr: result.stderr ?? '',
|
|
57
|
-
timedOut: result.timedOut === true,
|
|
58
|
-
};
|
|
59
|
-
};
|
|
60
|
-
/**
|
|
61
|
-
* The Planner's budget. Unchanged, and deliberately so: raising a default
|
|
62
|
-
* because a run once took longer is how a ten-minute wait becomes a
|
|
63
|
-
* twenty-five-minute one for everybody. `--agent-timeout` is the honest
|
|
64
|
-
* alternative — the owner who knows this particular request is large says so
|
|
65
|
-
* for that run, and nobody else pays for it.
|
|
66
|
-
*/
|
|
67
|
-
export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
68
|
-
const DURATION = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/;
|
|
69
|
-
const DURATION_UNIT_MS = {
|
|
70
|
-
ms: 1,
|
|
71
|
-
s: 1000,
|
|
72
|
-
m: 60 * 1000,
|
|
73
|
-
h: 60 * 60 * 1000,
|
|
74
|
-
};
|
|
75
|
-
/**
|
|
76
|
-
* Parses `25m`, `900s`, `600000ms`, `2h` into milliseconds, or returns null.
|
|
77
|
-
*
|
|
78
|
-
* A UNIT is required. A bare `600` is genuinely ambiguous — ten minutes to one
|
|
79
|
-
* reader, six-tenths of a second to another — and silently choosing either
|
|
80
|
-
* meaning would produce a budget the owner did not ask for, which is the one
|
|
81
|
-
* outcome this option exists to prevent.
|
|
82
|
-
*/
|
|
83
|
-
export function parseDuration(value) {
|
|
84
|
-
const match = DURATION.exec(value.trim().toLowerCase());
|
|
85
|
-
if (!match) {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
const raw = Number.parseFloat(match[1]) * DURATION_UNIT_MS[match[2]];
|
|
89
|
-
if (!Number.isFinite(raw) || raw <= 0) {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
const ms = Math.round(raw);
|
|
93
|
-
return ms >= 1 ? ms : null;
|
|
94
|
-
}
|
|
95
|
-
/** A duration as the owner would say it: `10m`, `1m 30s`, `45s`. */
|
|
96
|
-
export function formatDuration(ms) {
|
|
97
|
-
if (ms < 1000) {
|
|
98
|
-
return `${Math.round(ms)}ms`;
|
|
99
|
-
}
|
|
100
|
-
const seconds = Math.round(ms / 1000);
|
|
101
|
-
if (seconds < 60) {
|
|
102
|
-
return `${seconds}s`;
|
|
103
|
-
}
|
|
104
|
-
const minutes = Math.floor(seconds / 60);
|
|
105
|
-
const rest = seconds % 60;
|
|
106
|
-
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
|
|
107
|
-
}
|
|
108
|
-
// ── Capability detection ─────────────────────────────────────────────────────
|
|
109
|
-
/**
|
|
110
|
-
* The flags each adapter genuinely depends on. Detection asks the installed
|
|
111
|
-
* binary for its own help and requires every one of these to appear; a version
|
|
112
|
-
* that lacks any of them is `unsupported`, never "try it and hope".
|
|
113
|
-
*/
|
|
114
|
-
const REQUIRED_FLAGS = {
|
|
115
|
-
claude: {
|
|
116
|
-
helpArgs: ['--help'],
|
|
117
|
-
flags: [
|
|
118
|
-
'--print',
|
|
119
|
-
'--output-format',
|
|
120
|
-
'--json-schema',
|
|
121
|
-
'--permission-mode',
|
|
122
|
-
'--no-session-persistence',
|
|
123
|
-
],
|
|
124
|
-
},
|
|
125
|
-
codex: {
|
|
126
|
-
helpArgs: ['exec', '--help'],
|
|
127
|
-
// Every flag the codex invocation below actually passes — including
|
|
128
|
-
// `--color`, which was previously invoked without being detected. "Fails
|
|
129
|
-
// closed" is only true if the list here and the argv there are the same
|
|
130
|
-
// list; `-C` is absent from both because the process `cwd` already sets it.
|
|
131
|
-
flags: [
|
|
132
|
-
'--sandbox',
|
|
133
|
-
'--output-schema',
|
|
134
|
-
'--output-last-message',
|
|
135
|
-
'--ephemeral',
|
|
136
|
-
'--skip-git-repo-check',
|
|
137
|
-
'--color',
|
|
138
|
-
],
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
export async function detectAgentCapability(agent, options) {
|
|
142
|
-
const execute = options.execute ?? spawnExecutor;
|
|
143
|
-
const base = REQUIRED_FLAGS[agent];
|
|
144
|
-
const spec = {
|
|
145
|
-
helpArgs: base.helpArgs,
|
|
146
|
-
flags: [...new Set([...base.flags, ...(options.extraFlags ?? [])])],
|
|
147
|
-
};
|
|
148
|
-
let result;
|
|
149
|
-
try {
|
|
150
|
-
result = await execute(agent, spec.helpArgs, {
|
|
151
|
-
cwd: options.cwd,
|
|
152
|
-
timeoutMs: options.timeoutMs ?? 30_000,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
catch (error) {
|
|
156
|
-
return {
|
|
157
|
-
agent,
|
|
158
|
-
status: 'not-installed',
|
|
159
|
-
detail: `could not run "${agent}" (${error instanceof Error ? error.message.split('\n')[0] : String(error)})`,
|
|
160
|
-
missingFlags: spec.flags,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
if (result.exitCode !== 0) {
|
|
164
|
-
return {
|
|
165
|
-
agent,
|
|
166
|
-
status: 'not-installed',
|
|
167
|
-
detail: `"${agent} ${spec.helpArgs.join(' ')}" exited ${result.exitCode}`,
|
|
168
|
-
missingFlags: spec.flags,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
const help = `${result.stdout}\n${result.stderr}`;
|
|
172
|
-
const missingFlags = spec.flags.filter((flag) => !help.includes(flag));
|
|
173
|
-
if (missingFlags.length > 0) {
|
|
174
|
-
return {
|
|
175
|
-
agent,
|
|
176
|
-
status: 'unsupported',
|
|
177
|
-
detail: `the installed ${agent} does not advertise ${missingFlags.join(', ')}`,
|
|
178
|
-
missingFlags,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
return {
|
|
182
|
-
agent,
|
|
183
|
-
status: 'ready',
|
|
184
|
-
detail: `${agent} supports structured read-only planning`,
|
|
185
|
-
missingFlags: [],
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* Claude's `--output-format json` wraps the answer in a result envelope. We
|
|
190
|
-
* accept either the envelope's `result` (a JSON string or an object, depending
|
|
191
|
-
* on version) or a bare document — and treat anything else as malformed rather
|
|
192
|
-
* than guessing.
|
|
193
|
-
*/
|
|
194
|
-
function unwrapClaudeOutput(stdout) {
|
|
195
|
-
const trimmed = stdout.trim();
|
|
196
|
-
if (trimmed === '') {
|
|
197
|
-
return { error: 'the agent printed nothing' };
|
|
198
|
-
}
|
|
199
|
-
let envelope;
|
|
200
|
-
try {
|
|
201
|
-
envelope = JSON.parse(trimmed);
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
return { error: 'the agent did not print valid JSON' };
|
|
205
|
-
}
|
|
206
|
-
if (envelope !== null && typeof envelope === 'object' && 'result' in envelope) {
|
|
207
|
-
const inner = envelope.result;
|
|
208
|
-
if (typeof inner === 'string') {
|
|
209
|
-
try {
|
|
210
|
-
return { value: JSON.parse(inner) };
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
return { error: 'the agent returned prose where a structured document was required' };
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
if (inner !== null && typeof inner === 'object') {
|
|
217
|
-
return { value: inner };
|
|
218
|
-
}
|
|
219
|
-
return { error: 'the agent returned an empty result' };
|
|
220
|
-
}
|
|
221
|
-
if (typeof envelope === 'object' && envelope !== null) {
|
|
222
|
-
return { value: envelope };
|
|
223
|
-
}
|
|
224
|
-
return { error: 'the agent returned a JSON value that is not a document' };
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Claude's `--json-schema` rejects the top-level Draft 2020-12 `$schema`
|
|
228
|
-
* declaration Zod emits, while accepting everything under it. This drops that
|
|
229
|
-
* ONE key into a shallow copy: the caller's object is never mutated, and a
|
|
230
|
-
* shallow rest cannot reach — let alone remove — a nested `$schema` or any
|
|
231
|
-
* other constraint. Codex keeps the original, untouched.
|
|
232
|
-
*/
|
|
233
|
-
function claudeSchema(schema) {
|
|
234
|
-
if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
235
|
-
return schema;
|
|
236
|
-
}
|
|
237
|
-
const { $schema: _dropped, ...rest } = schema;
|
|
238
|
-
return rest;
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* Codex's structured-output transport requires EVERY declared property of every
|
|
242
|
-
* object to appear in `required`. The planning schemas are full of genuinely
|
|
243
|
-
* optional and defaulted fields (11 such object nodes in Start, 9 in Refresh),
|
|
244
|
-
* so the real generated schemas were refused before planning even began.
|
|
245
|
-
*
|
|
246
|
-
* The fix belongs here and only here. `planning/schema.ts` stays authoritative
|
|
247
|
-
* and untouched — tailoring the semantic schema to one provider would make the
|
|
248
|
-
* shape we ACCEPT a function of the shape one CLI happens to want, and a second
|
|
249
|
-
* hand-written schema would drift from the first the week after it was written.
|
|
250
|
-
* This is transport, not meaning: what comes back is re-parsed against the
|
|
251
|
-
* unchanged zod schema, and that parse is still the only thing that decides.
|
|
252
|
-
*
|
|
253
|
-
* The conversion is: require every declared property, and express the ones that
|
|
254
|
-
* were NOT required as "or null". Every other constraint is carried through
|
|
255
|
-
* untouched, and the caller's object is never mutated.
|
|
256
|
-
*/
|
|
257
|
-
function codexSchema(schema) {
|
|
258
|
-
const convert = (node) => {
|
|
259
|
-
if (node === null || typeof node !== 'object') {
|
|
260
|
-
return node;
|
|
261
|
-
}
|
|
262
|
-
if (Array.isArray(node)) {
|
|
263
|
-
return node.map(convert);
|
|
264
|
-
}
|
|
265
|
-
const source = node;
|
|
266
|
-
const declared = source.properties;
|
|
267
|
-
const required = new Set(Array.isArray(source.required) ? source.required : []);
|
|
268
|
-
const out = {};
|
|
269
|
-
for (const [key, value] of Object.entries(source)) {
|
|
270
|
-
if (key === 'properties') {
|
|
271
|
-
// A property MAP, not a schema: its values are subschemas and its keys
|
|
272
|
-
// are names, so it is never fed to `convert` as a node of its own.
|
|
273
|
-
const converted = {};
|
|
274
|
-
for (const [name, sub] of Object.entries(declared)) {
|
|
275
|
-
converted[name] = required.has(name) ? convert(sub) : nullable(convert(sub));
|
|
276
|
-
}
|
|
277
|
-
out.properties = converted;
|
|
278
|
-
}
|
|
279
|
-
else {
|
|
280
|
-
out[key] = convert(value);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
if (declared) {
|
|
284
|
-
out.required = Object.keys(declared);
|
|
285
|
-
}
|
|
286
|
-
return out;
|
|
287
|
-
};
|
|
288
|
-
return convert(schema);
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* "This, or null" — as an `anyOf`, never as `type: ['string', 'null']`.
|
|
292
|
-
*
|
|
293
|
-
* The type-union form looks equivalent and is not: `enum` and `const` are
|
|
294
|
-
* equality tests that reject null whatever `type` says. Both real schemas carry
|
|
295
|
-
* exactly that case — `variations[].status` is an optional `FlowStatus` enum —
|
|
296
|
-
* so the type-union form would hand Codex a property it MUST emit and that no
|
|
297
|
-
* value it could emit satisfies. One mechanism for every kind of node is also
|
|
298
|
-
* one mechanism to get right.
|
|
299
|
-
*
|
|
300
|
-
* `default` is hoisted out to sit beside the union: it is an annotation about
|
|
301
|
-
* the property, not a constraint on either branch.
|
|
302
|
-
*/
|
|
303
|
-
function nullable(node) {
|
|
304
|
-
const { default: fallback, ...rest } = (node ?? {});
|
|
305
|
-
const union = { anyOf: [rest, { type: 'null' }] };
|
|
306
|
-
return fallback === undefined ? union : { default: fallback, ...union };
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Whether any node in the schema gives `null` a meaning of its OWN.
|
|
310
|
-
*
|
|
311
|
-
* This is the precondition for everything above. The conversion spends `null`
|
|
312
|
-
* as the wire spelling of "absent", and `dropTransportNulls` spends it again on
|
|
313
|
-
* the way back — both of which are only safe while `null` means nothing else.
|
|
314
|
-
* The moment a semantic schema declares a legitimate null, a returned null is
|
|
315
|
-
* ambiguous, and the honest answer is to refuse the provider rather than guess
|
|
316
|
-
* which of the two meanings was intended. Neither real schema declares one
|
|
317
|
-
* today; this is what keeps that true rather than assuming it.
|
|
318
|
-
*
|
|
319
|
-
* A deep walk over every value, so a null tucked inside `items`, a branch of an
|
|
320
|
-
* `anyOf`, or any keyword added later is still found.
|
|
321
|
-
*/
|
|
322
|
-
function declaresNull(node) {
|
|
323
|
-
if (node === null || typeof node !== 'object') {
|
|
324
|
-
return false;
|
|
325
|
-
}
|
|
326
|
-
if (Array.isArray(node)) {
|
|
327
|
-
return node.some(declaresNull);
|
|
328
|
-
}
|
|
329
|
-
const source = node;
|
|
330
|
-
if (source.type === 'null' || (Array.isArray(source.type) && source.type.includes('null'))) {
|
|
331
|
-
return true;
|
|
332
|
-
}
|
|
333
|
-
if ('const' in source && source.const === null) {
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
if (Array.isArray(source.enum) && source.enum.includes(null)) {
|
|
337
|
-
return true;
|
|
338
|
-
}
|
|
339
|
-
return Object.values(source).some(declaresNull);
|
|
340
|
-
}
|
|
341
|
-
/**
|
|
342
|
-
* Undoes the transport, and nothing else: a property `codexSchema` made
|
|
343
|
-
* nullable — declared on THIS object node and absent from ITS `required` list —
|
|
344
|
-
* whose value came back `null` is the wire spelling of "I am not sending this
|
|
345
|
-
* one", so the KEY is dropped and the unchanged zod schema then applies its own
|
|
346
|
-
* `.optional()` or `.default()` exactly as it would for a claude answer that
|
|
347
|
-
* simply omitted it.
|
|
348
|
-
*
|
|
349
|
-
* SCHEMA-AWARE, and that is the whole guard. Dropping every null-valued key
|
|
350
|
-
* regardless of the schema deletes keys the schema never declared, so a hostile
|
|
351
|
-
* `{ invented: null }` was normalised away and the strict zod parse — whose one
|
|
352
|
-
* job at that boundary is to REJECT an unknown key — never saw it. A `null` at
|
|
353
|
-
* an undeclared key is nobody's transport encoding; it is precisely the
|
|
354
|
-
* malformed answer the local parse exists to refuse, and it now travels through
|
|
355
|
-
* unaltered at every depth. `Object.hasOwn`, never `key in`: the lookup is
|
|
356
|
-
* against the SCHEMA's `properties` map, an ordinary object whose prototype
|
|
357
|
-
* chain carries `toString` and `constructor`, so `in` would report those two
|
|
358
|
-
* names as declared and hide an answer's invented `toString: null` the same way.
|
|
359
|
-
*
|
|
360
|
-
* The schema walked is the ORIGINAL, never `codexSchema`'s conversion. The
|
|
361
|
-
* conversion puts EVERY declared property into `required`, so "declared and not
|
|
362
|
-
* required" — the one fact that made a `null` legal on the wire — is readable
|
|
363
|
-
* only here. The two halves must therefore agree, and `agent-runner.test.ts`
|
|
364
|
-
* pins that agreement by OBSERVING both over the real schemas: nullability is
|
|
365
|
-
* read off the wire schema codex was actually handed, the drop is the real
|
|
366
|
-
* decode running, and one declared property is nulled per run so every one is
|
|
367
|
-
* exercised. A single wholly-null document could not do it — sending
|
|
368
|
-
* `plannedFlow: null` destroys the node you must descend through to reach
|
|
369
|
-
* `plannedFlow.truth.steps[0].assumes`.
|
|
370
|
-
*
|
|
371
|
-
* Deliberately narrow everywhere else, and it fails closed. A `null` on a
|
|
372
|
-
* REQUIRED property is kept — the conversion never made that one nullable, so
|
|
373
|
-
* it is the agent contradicting the schema. A `null` ELEMENT of an array is
|
|
374
|
-
* left exactly where it is, because no optional-property encoding put it there
|
|
375
|
-
* and removing it would be repairing a malformed answer rather than decoding a
|
|
376
|
-
* well-formed one. Where the schema says nothing about a position — no node, no
|
|
377
|
-
* `properties`, no `items` — the value is returned untouched for the parse to
|
|
378
|
-
* judge. An unknown key, a wrong type and a malformed value all travel through
|
|
379
|
-
* unaltered to be rejected locally, which is where rejection belongs.
|
|
380
|
-
*
|
|
381
|
-
* The object is copied by SPREAD and then pruned, never rebuilt into a fresh
|
|
382
|
-
* `{}`: `JSON.parse('{"__proto__":null}')` yields `__proto__` as a real own
|
|
383
|
-
* property, and writing that key into a fresh object literal hits
|
|
384
|
-
* `Object.prototype`'s setter and makes the key disappear — hiding an unknown
|
|
385
|
-
* key exactly as the null-blind drop did.
|
|
386
|
-
*/
|
|
387
|
-
function dropTransportNulls(value, schema) {
|
|
388
|
-
const node = schema !== null && typeof schema === 'object' && !Array.isArray(schema)
|
|
389
|
-
? schema
|
|
390
|
-
: undefined;
|
|
391
|
-
if (Array.isArray(value)) {
|
|
392
|
-
return node?.items === undefined
|
|
393
|
-
? value
|
|
394
|
-
: value.map((item) => dropTransportNulls(item, node.items));
|
|
395
|
-
}
|
|
396
|
-
if (value === null || typeof value !== 'object') {
|
|
397
|
-
return value;
|
|
398
|
-
}
|
|
399
|
-
const declared = node?.properties;
|
|
400
|
-
if (declared === null || typeof declared !== 'object') {
|
|
401
|
-
return value;
|
|
402
|
-
}
|
|
403
|
-
const required = new Set(Array.isArray(node?.required) ? node.required : []);
|
|
404
|
-
const out = { ...value };
|
|
405
|
-
for (const [key, item] of Object.entries(out)) {
|
|
406
|
-
if (!Object.hasOwn(declared, key)) {
|
|
407
|
-
continue;
|
|
408
|
-
}
|
|
409
|
-
if (item === null) {
|
|
410
|
-
if (!required.has(key)) {
|
|
411
|
-
delete out[key];
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
else {
|
|
415
|
-
out[key] = dropTransportNulls(item, declared[key]);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
return out;
|
|
419
|
-
}
|
|
420
|
-
export async function runAgentJson(request) {
|
|
421
|
-
const { agent, cwd, prompt } = request;
|
|
422
|
-
const execute = request.execute ?? spawnExecutor;
|
|
423
|
-
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
424
|
-
// Resolved before detection, so the flags this run will really pass are the
|
|
425
|
-
// flags detection insists the installed binary advertises.
|
|
426
|
-
const tuning = resolveTuning(agent, getAdapter(agent), request.tuning ?? {});
|
|
427
|
-
if (!tuning.ok) {
|
|
428
|
-
return { ok: false, agent, reason: 'unsupported', detail: `${tuning.problem} ${tuning.why}` };
|
|
429
|
-
}
|
|
430
|
-
// BEFORE LAUNCH — before even the capability probe, because no answer this
|
|
431
|
-
// provider could give would be decodable. Copy/import is unaffected: it runs
|
|
432
|
-
// no transport, and the unchanged zod schema is what validates it.
|
|
433
|
-
if (agent === 'codex' && declaresNull(request.schema)) {
|
|
434
|
-
return {
|
|
435
|
-
ok: false,
|
|
436
|
-
agent,
|
|
437
|
-
reason: 'schema-unsupported',
|
|
438
|
-
detail: 'this schema gives null a meaning of its own, and codex\'s transport spends null on "this optional property is absent" — a returned null could be either, and guessing is not an option',
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
const capability = await detectAgentCapability(agent, {
|
|
442
|
-
cwd,
|
|
443
|
-
execute,
|
|
444
|
-
extraFlags: tuning.detectFlags,
|
|
445
|
-
});
|
|
446
|
-
if (capability.status !== 'ready') {
|
|
447
|
-
return { ok: false, agent, reason: capability.status, detail: capability.detail };
|
|
448
|
-
}
|
|
449
|
-
const started = Date.now();
|
|
450
|
-
/**
|
|
451
|
-
* A finished-but-failed process, classified. TIMEOUT IS TESTED FIRST and on
|
|
452
|
-
* execa's own `timedOut`, never on the exit code: a killed process reports a
|
|
453
|
-
* non-zero exit like any other failure, so reading the code first told every
|
|
454
|
-
* owner whose Planner ran out of time that it "exited 1" — a message that
|
|
455
|
-
* suggests a broken install and hides the one fact that would have fixed it.
|
|
456
|
-
* Both branches carry the provider's actual output, so a real error is still
|
|
457
|
-
* in front of the owner either way.
|
|
458
|
-
*/
|
|
459
|
-
const classifyFailure = (result) => result.timedOut
|
|
460
|
-
? {
|
|
461
|
-
reason: 'timeout',
|
|
462
|
-
detail: `${agent} did not finish within its ${formatDuration(timeoutMs)} budget (stopped after ${formatDuration(Date.now() - started)}); last output: ${outputTail(result)}`,
|
|
463
|
-
}
|
|
464
|
-
: { reason: 'exit-code', detail: `exited ${result.exitCode}: ${outputTail(result)}` };
|
|
465
|
-
// One throwaway directory per run; removed in `finally` whatever happens, so
|
|
466
|
-
// no schema or answer file survives the command.
|
|
467
|
-
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), `prismatica-${agent}-`));
|
|
468
|
-
try {
|
|
469
|
-
const schemaFile = path.join(scratch, 'schema.json');
|
|
470
|
-
// Only codex reads this file, and only codex needs the transport rewrite;
|
|
471
|
-
// claude is handed `request.schema` inline, minus its top-level `$schema`.
|
|
472
|
-
fs.writeFileSync(schemaFile, JSON.stringify(agent === 'codex' ? codexSchema(request.schema) : request.schema));
|
|
473
|
-
let result;
|
|
474
|
-
let raw;
|
|
475
|
-
if (agent === 'codex') {
|
|
476
|
-
const outputFile = path.join(scratch, 'answer.json');
|
|
477
|
-
try {
|
|
478
|
-
result = await execute('codex', [
|
|
479
|
-
'exec',
|
|
480
|
-
// read-only: the planner may look, never write.
|
|
481
|
-
'--sandbox',
|
|
482
|
-
'read-only',
|
|
483
|
-
// no session files left behind.
|
|
484
|
-
'--ephemeral',
|
|
485
|
-
'--skip-git-repo-check',
|
|
486
|
-
'--color',
|
|
487
|
-
'never',
|
|
488
|
-
'--output-schema',
|
|
489
|
-
schemaFile,
|
|
490
|
-
'--output-last-message',
|
|
491
|
-
outputFile,
|
|
492
|
-
// Which model, and how hard it thinks — each value its own argv
|
|
493
|
-
// element, resolved by the adapter that owns this provider's
|
|
494
|
-
// dialect (`skills.ts`). Empty unless the owner asked.
|
|
495
|
-
...tuning.args,
|
|
496
|
-
// No `-C`: the process is already spawned with this `cwd`, so a
|
|
497
|
-
// second way of saying it would be one more flag to depend on for
|
|
498
|
-
// nothing.
|
|
499
|
-
prompt,
|
|
500
|
-
], { cwd, timeoutMs, stdin: 'ignore' });
|
|
501
|
-
}
|
|
502
|
-
catch (error) {
|
|
503
|
-
return { ok: false, agent, reason: 'not-installed', detail: describe(error) };
|
|
504
|
-
}
|
|
505
|
-
if (result.exitCode !== 0) {
|
|
506
|
-
return { ok: false, agent, ...classifyFailure(result) };
|
|
507
|
-
}
|
|
508
|
-
if (!fs.existsSync(outputFile)) {
|
|
509
|
-
return { ok: false, agent, reason: 'empty', detail: 'the agent wrote no answer file' };
|
|
510
|
-
}
|
|
511
|
-
raw = fs.readFileSync(outputFile, 'utf8').trim();
|
|
512
|
-
if (raw === '') {
|
|
513
|
-
return {
|
|
514
|
-
ok: false,
|
|
515
|
-
agent,
|
|
516
|
-
reason: 'empty',
|
|
517
|
-
detail: 'the agent wrote an empty answer file',
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
|
-
try {
|
|
521
|
-
// `value` is decoded; `raw` stays exactly the bytes the provider wrote,
|
|
522
|
-
// so nothing downstream can be shown an answer the agent did not give.
|
|
523
|
-
return { ok: true, agent, value: dropTransportNulls(JSON.parse(raw), request.schema), raw };
|
|
524
|
-
}
|
|
525
|
-
catch {
|
|
526
|
-
return {
|
|
527
|
-
ok: false,
|
|
528
|
-
agent,
|
|
529
|
-
reason: 'malformed',
|
|
530
|
-
detail: 'the answer file was not valid JSON',
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
try {
|
|
535
|
-
result = await execute('claude', [
|
|
536
|
-
'--print',
|
|
537
|
-
'--output-format',
|
|
538
|
-
'json',
|
|
539
|
-
'--json-schema',
|
|
540
|
-
JSON.stringify(claudeSchema(request.schema)),
|
|
541
|
-
// plan mode: no WRITES — no edit, no file creation, no committed
|
|
542
|
-
// change. It is not a general execution block, and describing it as
|
|
543
|
-
// one would overstate it; what the Planner role needs is exactly
|
|
544
|
-
// that the repository cannot be modified, and that is what this
|
|
545
|
-
// gives.
|
|
546
|
-
'--permission-mode',
|
|
547
|
-
'plan',
|
|
548
|
-
'--no-session-persistence',
|
|
549
|
-
...tuning.args,
|
|
550
|
-
prompt,
|
|
551
|
-
], { cwd, timeoutMs, stdin: 'ignore' });
|
|
552
|
-
}
|
|
553
|
-
catch (error) {
|
|
554
|
-
return { ok: false, agent, reason: 'not-installed', detail: describe(error) };
|
|
555
|
-
}
|
|
556
|
-
if (result.exitCode !== 0) {
|
|
557
|
-
return { ok: false, agent, ...classifyFailure(result) };
|
|
558
|
-
}
|
|
559
|
-
raw = result.stdout.trim();
|
|
560
|
-
if (raw === '') {
|
|
561
|
-
return { ok: false, agent, reason: 'empty', detail: 'the agent printed nothing' };
|
|
562
|
-
}
|
|
563
|
-
const unwrapped = unwrapClaudeOutput(raw);
|
|
564
|
-
if ('error' in unwrapped) {
|
|
565
|
-
return { ok: false, agent, reason: 'malformed', detail: unwrapped.error };
|
|
566
|
-
}
|
|
567
|
-
return { ok: true, agent, value: unwrapped.value, raw };
|
|
568
|
-
}
|
|
569
|
-
finally {
|
|
570
|
-
fs.rmSync(scratch, { recursive: true, force: true });
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
function describe(error) {
|
|
574
|
-
return error instanceof Error ? error.message.split('\n')[0] : String(error);
|
|
575
|
-
}
|
|
576
|
-
/**
|
|
577
|
-
* The number of trailing characters of provider output a failure keeps.
|
|
578
|
-
* Generous enough for a real stack or usage message, small enough that a
|
|
579
|
-
* chatty CLI cannot flood the terminal.
|
|
580
|
-
*/
|
|
581
|
-
const MAX_OUTPUT_TAIL = 600;
|
|
582
|
-
/**
|
|
583
|
-
* The ACTIONABLE end of what the provider printed.
|
|
584
|
-
*
|
|
585
|
-
* The tail, not the head: CLIs open with banners, update notices and progress
|
|
586
|
-
* chatter, so the first line was reliably the least useful one on the screen —
|
|
587
|
-
* an owner debugging a failed Planner run was shown a version string. The real
|
|
588
|
-
* error is what the process said last.
|
|
589
|
-
*
|
|
590
|
-
* `stderr` is preferred and `stdout` is the fallback, because a provider that
|
|
591
|
-
* writes its diagnostics to stdout would otherwise report "(no output)" while
|
|
592
|
-
* holding the explanation. Nothing is filtered by phrase: a list of known
|
|
593
|
-
* noise strings is a list that rots, and the one time it is wrong it discards
|
|
594
|
-
* the message the owner needed.
|
|
595
|
-
*/
|
|
596
|
-
function outputTail(result) {
|
|
597
|
-
const text = result.stderr.trim() || result.stdout.trim();
|
|
598
|
-
if (text === '') {
|
|
599
|
-
return '(no output)';
|
|
600
|
-
}
|
|
601
|
-
return text.length > MAX_OUTPUT_TAIL ? `…${text.slice(-MAX_OUTPUT_TAIL)}` : text;
|
|
602
|
-
}
|
|
603
|
-
// ── Waiting, honestly ────────────────────────────────────────────────────────
|
|
604
|
-
/** How often the "still running" line repeats. Restrained on purpose — this is a wait, not a show. */
|
|
605
|
-
const PROGRESS_INTERVAL_MS = 30_000;
|
|
606
|
-
/**
|
|
607
|
-
* One start line, then a periodic elapsed-time line, while a provider runs.
|
|
608
|
-
* Returns the STOP function — call it in a `finally`, always.
|
|
609
|
-
*
|
|
610
|
-
* Three rules, all of them deliberate:
|
|
611
|
-
*
|
|
612
|
-
* - **stderr, and only when stderr is a TTY.** Progress is for a person
|
|
613
|
-
* watching a terminal. On a pipe, in CI, or with output being captured, it
|
|
614
|
-
* is noise that corrupts whatever the caller is really collecting — and
|
|
615
|
-
* stdout in particular must stay clean, since a caller may be reading it.
|
|
616
|
-
* - **Process facts only.** Elapsed time and the budget. Nothing here reads,
|
|
617
|
-
* streams, summarises or characterises anything the agent said; Prismatica
|
|
618
|
-
* does not read agent output, and a progress line is not the place to start.
|
|
619
|
-
* - **The timer is cleared unconditionally.** Success, failure, or a thrown
|
|
620
|
-
* exception — a `finally` covers all three. A surviving interval would keep
|
|
621
|
-
* printing over whatever the command wrote next, and keep the process alive
|
|
622
|
-
* after its work is done; `unref` is a second belt for the same trousers.
|
|
623
|
-
*/
|
|
624
|
-
export function startAgentProgress(options) {
|
|
625
|
-
const stream = options.stream ?? process.stderr;
|
|
626
|
-
if (!stream.isTTY) {
|
|
627
|
-
return () => { };
|
|
628
|
-
}
|
|
629
|
-
const now = options.now ?? Date.now;
|
|
630
|
-
const started = now();
|
|
631
|
-
const budget = formatDuration(options.timeoutMs);
|
|
632
|
-
stream.write(` … ${options.agent} is planning (read-only, no session kept) — up to ${budget}\n`);
|
|
633
|
-
const timer = setInterval(() => {
|
|
634
|
-
stream.write(` … still running — ${formatDuration(now() - started)} of ${budget}\n`);
|
|
635
|
-
}, PROGRESS_INTERVAL_MS);
|
|
636
|
-
timer.unref?.();
|
|
637
|
-
return () => clearInterval(timer);
|
|
638
|
-
}
|
|
639
|
-
/**
|
|
640
|
-
* The wording every caller uses when an adapter cannot run. Deliberately one
|
|
641
|
-
* sentence plus a fallback — an unavailable agent is a routine, recoverable
|
|
642
|
-
* state, not an error the owner has to debug.
|
|
643
|
-
*/
|
|
644
|
-
export function fallbackAdvice(agent, reason, detail) {
|
|
645
|
-
const cause = reason === 'not-installed'
|
|
646
|
-
? `${agent} is not installed or could not run`
|
|
647
|
-
: reason === 'unsupported'
|
|
648
|
-
? `this version of ${agent} cannot run a structured read-only plan`
|
|
649
|
-
: reason === 'schema-unsupported'
|
|
650
|
-
? `${agent}'s structured-output transport cannot safely carry this planning schema`
|
|
651
|
-
: reason === 'timeout'
|
|
652
|
-
? `${agent} ran out of time before it finished planning`
|
|
653
|
-
: `${agent} did not return a usable plan`;
|
|
654
|
-
return `${cause} (${detail}). Use the copy/import fallback instead.`;
|
|
655
|
-
}
|
|
656
|
-
//# sourceMappingURL=agent-runner.js.map
|