muse-crew 0.17.2 → 0.17.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.
- package/API.md +11 -0
- package/docs/decisions/composition-machinery.md +180 -0
- package/docs/decisions/publish-path.md +6 -0
- package/docs/decisions/workflow-core.md +5 -4
- package/lib/AGENTS.md +2 -1
- package/lib/bugfix/phases/build.js +168 -0
- package/lib/bugfix/phases/capture.js +170 -0
- package/lib/bugfix/phases/integrate.js +165 -0
- package/lib/bugfix/phases/map.js +129 -0
- package/lib/bugfix/phases/publish.js +592 -0
- package/lib/bugfix/phases/qa.js +356 -0
- package/lib/bugfix/phases/reproduce.js +254 -0
- package/lib/bugfix/phases/review.js +292 -0
- package/lib/bugfix/phases/triage.js +69 -0
- package/lib/chore/CONTRACT.md +181 -0
- package/lib/chore/DISPOSITION.md +98 -0
- package/lib/chore/extract.js +204 -0
- package/lib/chore/phase-lib.js +885 -0
- package/lib/chore/phases/build.js +110 -0
- package/lib/chore/phases/capture.js +82 -0
- package/lib/chore/phases/integrate.js +109 -0
- package/lib/chore/phases/map.js +81 -0
- package/lib/chore/phases/publish.js +543 -0
- package/lib/chore/phases/review.js +255 -0
- package/lib/chore/phases/triage.js +57 -0
- package/lib/chore/prompts/evidence-gatherer.js +41 -0
- package/lib/chore/prompts/evidence-gatherer.schema.json +1 -0
- package/lib/chore/prompts/tool-check.js +15 -0
- package/lib/chore/prompts/trailers.js +56 -0
- package/lib/chore/prompts/verdict-reask.js +28 -0
- package/lib/chore/prompts/verdict-reask.schema.json +1 -0
- package/lib/chore/prompts/work-agent.js +52 -0
- package/lib/chore/prompts/work-agent.schema.json +1 -0
- package/lib/chore/spawn-keys.js +44 -0
- package/lib/chore/spawn-vocab.js +87 -0
- package/lib/chore-run.js +538 -0
- package/lib/chore-tick.js +289 -0
- package/lib/crew-api.js +273 -0
- package/lib/crew-dispatch-worker.js +27 -7
- package/lib/crew-release.sh +7 -2
- package/lib/extract.js +252 -0
- package/lib/prompts/tool-check.js +18 -0
- package/lib/prompts/trailers.js +59 -0
- package/lib/prompts/verdict-reask.js +31 -0
- package/lib/prompts/verdict-reask.schema.json +1 -0
- package/lib/prompts/work-agent.js +56 -0
- package/lib/prompts/work-agent.schema.json +1 -0
- package/lib/reap-spawns.js +407 -0
- package/lib/schema.sql +12 -1
- package/lib/spawn-keys.js +47 -0
- package/lib/spawn-step.js +572 -0
- package/lib/standard/phases/build.js +120 -0
- package/lib/standard/phases/capture.js +163 -0
- package/lib/standard/phases/integrate.js +172 -0
- package/lib/standard/phases/map.js +119 -0
- package/lib/standard/phases/publish.js +565 -0
- package/lib/standard/phases/qa.js +399 -0
- package/lib/standard/phases/review.js +281 -0
- package/lib/standard/phases/triage.js +64 -0
- package/lib/test-detached-integrate.sh +47 -0
- package/lib/workflow-driver.js +605 -0
- package/lib/workflow-lib.js +1012 -0
- package/lib/workflow-spec.js +187 -0
- package/lib/worktree-lifecycle.sh +55 -3
- package/package.json +1 -1
- package/seed/cron-body-template.md +61 -9
- package/workflows/bugfix.js +17 -17
- package/workflows/chore.js +16 -16
- package/workflows/docs.js +14 -11
- package/workflows/standard.js +16 -16
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/spawn-step.js — the production spawn wrapper (sandbox exit, chore
|
|
3
|
+
// pilot Piece 2, 2026-09-26).
|
|
4
|
+
//
|
|
5
|
+
// Shebang CLI. --help answers with usage on stdout, exit 0.
|
|
6
|
+
//
|
|
7
|
+
// The bridge between the deterministic driver (chore-run.js derive) and
|
|
8
|
+
// the tick worker's spawn tool. The tick worker never builds prompts or
|
|
9
|
+
// touches the ledger directly — everything crosses this boundary.
|
|
10
|
+
//
|
|
11
|
+
// pre --request '<json>'
|
|
12
|
+
// Validate bounds+request, enforce the frozen kind vocabulary, check
|
|
13
|
+
// the duplicate-pre guard (M2), derive attempt_n (F5), enforce the
|
|
14
|
+
// per-kind attempt cap and the consecutive transport-failure budget,
|
|
15
|
+
// open the ledger row (crew-api record-spawn-open), build the prompt
|
|
16
|
+
// from the registered pure builder, mint the session path. Prints one
|
|
17
|
+
// JSON line on stdout:
|
|
18
|
+
// {prompt,cwd,env,timeout_ms,session_path,key,attempt_n,row_id}
|
|
19
|
+
// Exit non-zero = refused; stdout carries {error:{code,message}} with
|
|
20
|
+
// code in BOUNDS_REJECTED | DUPLICATE_RUNNING | ATTEMPTS_EXHAUSTED |
|
|
21
|
+
// TRANSPORT_BUDGET_EXHAUSTED.
|
|
22
|
+
//
|
|
23
|
+
// post --session '<path>' [--key '<spawn_key>']
|
|
24
|
+
// Read the session file, classify per the routing order (extractors →
|
|
25
|
+
// reask signal → transport outcomes; kill states only with machine
|
|
26
|
+
// evidence — an unverified kill leaves the row running for the
|
|
27
|
+
// reaper), close the ledger row (crew-api record-spawn-close) with a
|
|
28
|
+
// closed-vocabulary outcome. Prints the typed result on stdout:
|
|
29
|
+
// {closed:true,row_id,status,outcome,...payload}
|
|
30
|
+
// or {closed:false,reason} when the row was left running.
|
|
31
|
+
//
|
|
32
|
+
// Session file schema (written by the tick worker):
|
|
33
|
+
// { key, child: {kind:"subagent",agent_id} | {kind:"os_pid",pid,start_time},
|
|
34
|
+
// outcome: "returned"|"timeout"|"spawn_error"|"synthetic",
|
|
35
|
+
// text?, error?, kill_evidence?: {verified_dead, method, detail} }
|
|
36
|
+
// The tick worker records `child` immediately after spawning (so the
|
|
37
|
+
// Step-0 reaper can find it) and the outcome when the spawn settles.
|
|
38
|
+
// outcome "synthetic" is the P3 no-spawn branch: the workflow authored the
|
|
39
|
+
// prose, no agent was spawned; it flows through the same extractors and
|
|
40
|
+
// closes the row identically — the bridge never requires a spawn to
|
|
41
|
+
// produce a typed result.
|
|
42
|
+
//
|
|
43
|
+
// The closed outcome vocabulary is enforced fail-closed here AND in
|
|
44
|
+
// crew-api record-spawn-close: a row may only leave 'running' with a
|
|
45
|
+
// CLOSED_OUTCOMES outcome. Agent output is data, never executable —
|
|
46
|
+
// extractors parse it, nothing evals it.
|
|
47
|
+
|
|
48
|
+
import { execFileSync } from "node:child_process";
|
|
49
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
50
|
+
import { join, dirname, resolve } from "node:path";
|
|
51
|
+
import { fileURLToPath } from "node:url";
|
|
52
|
+
import { validateSpawnBounds } from "./spawn-boundary.js";
|
|
53
|
+
import {
|
|
54
|
+
SPAWN_KINDS,
|
|
55
|
+
MAX_ATTEMPTS,
|
|
56
|
+
TRANSPORT_BUDGET_CAP,
|
|
57
|
+
MAX_TIMEOUT_MS,
|
|
58
|
+
MIN_TIMEOUT_MS,
|
|
59
|
+
isClosedOutcome,
|
|
60
|
+
} from "./chore/spawn-vocab.js";
|
|
61
|
+
import {
|
|
62
|
+
extractVerdict,
|
|
63
|
+
extractMarkerLines,
|
|
64
|
+
parseToolSignals,
|
|
65
|
+
extractReleaseDecision,
|
|
66
|
+
extractWorktree,
|
|
67
|
+
extractExperiential,
|
|
68
|
+
} from "./chore/extract.js";
|
|
69
|
+
import { buildWorkPrompt } from "./chore/prompts/work-agent.js";
|
|
70
|
+
import { buildVerdictReaskPrompt } from "./chore/prompts/verdict-reask.js";
|
|
71
|
+
import { buildEvidenceGathererPrompt } from "./chore/prompts/evidence-gatherer.js";
|
|
72
|
+
|
|
73
|
+
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
74
|
+
const CREW_API = join(LIB_DIR, "crew-api.js");
|
|
75
|
+
const PROMPTS_DIR = join(LIB_DIR, "chore", "prompts");
|
|
76
|
+
|
|
77
|
+
// Closed kind → (prompt module, schema file, builder). Unknown kinds fail
|
|
78
|
+
// closed in pre. The request's bounds.prompt_file / bounds.schema_file must
|
|
79
|
+
// equal these exact paths — a chore-run.js bug pointing at the wrong
|
|
80
|
+
// builder is rejected, not silently honored.
|
|
81
|
+
const KIND_REGISTRY = {
|
|
82
|
+
"work-agent": {
|
|
83
|
+
promptFile: join(PROMPTS_DIR, "work-agent.js"),
|
|
84
|
+
schemaFile: join(PROMPTS_DIR, "work-agent.schema.json"),
|
|
85
|
+
build: buildWorkPrompt,
|
|
86
|
+
},
|
|
87
|
+
"verdict-reask": {
|
|
88
|
+
promptFile: join(PROMPTS_DIR, "verdict-reask.js"),
|
|
89
|
+
schemaFile: join(PROMPTS_DIR, "verdict-reask.schema.json"),
|
|
90
|
+
build: buildVerdictReaskPrompt,
|
|
91
|
+
},
|
|
92
|
+
"evidence-gatherer": {
|
|
93
|
+
promptFile: join(PROMPTS_DIR, "evidence-gatherer.js"),
|
|
94
|
+
schemaFile: join(PROMPTS_DIR, "evidence-gatherer.schema.json"),
|
|
95
|
+
build: buildEvidenceGathererPrompt,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const USAGE = [
|
|
100
|
+
"usage: spawn-step.js pre --request '<json>'",
|
|
101
|
+
" spawn-step.js post --session '<path>' [--key '<spawn_key>']",
|
|
102
|
+
" spawn-step.js --help",
|
|
103
|
+
"",
|
|
104
|
+
"The production spawn wrapper: pre validates the typed spawn request,",
|
|
105
|
+
"opens the worker_runs ledger row, builds the prompt, and mints the",
|
|
106
|
+
"session path; post classifies the session file and closes the row with",
|
|
107
|
+
"a closed-vocabulary outcome. Refusal codes: BOUNDS_REJECTED |",
|
|
108
|
+
"DUPLICATE_RUNNING | ATTEMPTS_EXHAUSTED | TRANSPORT_BUDGET_EXHAUSTED.",
|
|
109
|
+
].join("\n");
|
|
110
|
+
|
|
111
|
+
function log(msg) {
|
|
112
|
+
process.stderr.write("[spawn-step] " + msg + "\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function printJson(obj) {
|
|
116
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fail(code, message, extra) {
|
|
120
|
+
printJson({ error: Object.assign({ code, message }, extra || {}) });
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function callCrewApi(crewHome, command, argsObj) {
|
|
125
|
+
const argv = ["--crew-home", crewHome, command];
|
|
126
|
+
if (argsObj !== undefined) argv.push("--json", JSON.stringify(argsObj));
|
|
127
|
+
let stdout;
|
|
128
|
+
try {
|
|
129
|
+
stdout = execFileSync("node", [CREW_API, ...argv], {
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
const detail = (e.stderr && e.stderr.toString()) || e.message;
|
|
135
|
+
throw new Error("crew-api " + command + " failed: " + detail.slice(0, 500));
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(stdout);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
throw new Error("crew-api " + command + " returned unparseable JSON");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isPlainObject(v) {
|
|
145
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function requireNonEmptyString(v, name) {
|
|
149
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
150
|
+
throw new Error("request." + name + " must be a non-empty string");
|
|
151
|
+
}
|
|
152
|
+
return v;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── pre ──────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
function validatePreRequest(req) {
|
|
158
|
+
if (!isPlainObject(req)) throw new Error("request must be a JSON object");
|
|
159
|
+
const key = requireNonEmptyString(req.key, "key");
|
|
160
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(key)) {
|
|
161
|
+
throw new Error("request.key must be [A-Za-z0-9_-], up to 128 chars");
|
|
162
|
+
}
|
|
163
|
+
const kind = requireNonEmptyString(req.kind, "kind");
|
|
164
|
+
if (!SPAWN_KINDS.includes(kind)) {
|
|
165
|
+
throw new Error("unknown spawn kind: " + kind + " (frozen vocabulary: " + SPAWN_KINDS.join(", ") + ")");
|
|
166
|
+
}
|
|
167
|
+
const taskId = requireNonEmptyString(req.task_id, "task_id");
|
|
168
|
+
const phase = requireNonEmptyString(req.phase, "phase");
|
|
169
|
+
requireNonEmptyString(req.identity, "identity");
|
|
170
|
+
if (!Number.isInteger(req.owner_tick_seq) || req.owner_tick_seq <= 0) {
|
|
171
|
+
throw new Error("request.owner_tick_seq must be a positive integer");
|
|
172
|
+
}
|
|
173
|
+
if (!Number.isInteger(req.timeout_ms) || req.timeout_ms < MIN_TIMEOUT_MS || req.timeout_ms > MAX_TIMEOUT_MS) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
"request.timeout_ms must be an integer in [" + MIN_TIMEOUT_MS + ", " + MAX_TIMEOUT_MS + "]"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const bounds = req.bounds;
|
|
179
|
+
if (!isPlainObject(bounds)) throw new Error("request.bounds must be an object");
|
|
180
|
+
const promptFile = requireNonEmptyString(bounds.prompt_file, "bounds.prompt_file");
|
|
181
|
+
const schemaFile = requireNonEmptyString(bounds.schema_file, "bounds.schema_file");
|
|
182
|
+
// Closed kind→file mapping: the orchestrator declares its bounds, pre
|
|
183
|
+
// verifies they are exactly the registered files for the kind.
|
|
184
|
+
const reg = KIND_REGISTRY[kind];
|
|
185
|
+
if (resolve(promptFile) !== reg.promptFile) {
|
|
186
|
+
throw new Error("bounds.prompt_file is not the registered builder for kind '" + kind + "'");
|
|
187
|
+
}
|
|
188
|
+
if (resolve(schemaFile) !== reg.schemaFile) {
|
|
189
|
+
throw new Error("bounds.schema_file is not the registered schema for kind '" + kind + "'");
|
|
190
|
+
}
|
|
191
|
+
if (!isPlainObject(req.prompt_params)) throw new Error("request.prompt_params must be an object");
|
|
192
|
+
if (req.env !== undefined || bounds.env !== undefined) {
|
|
193
|
+
const env = bounds.env;
|
|
194
|
+
if (!isPlainObject(env)) throw new Error("bounds.env must be an object of string values");
|
|
195
|
+
for (const k of Object.keys(env)) {
|
|
196
|
+
if (typeof env[k] !== "string") throw new Error("bounds.env['" + k + "'] must be a string");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { key, kind, taskId, phase, bounds, reg };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function checkFileReadable(path, what) {
|
|
203
|
+
try {
|
|
204
|
+
readFileSync(path, "utf8");
|
|
205
|
+
} catch (e) {
|
|
206
|
+
throw new Error(what + " not readable: " + path + " (" + e.message + ")");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Close an opened ledger row as a refused open (transport_failed) so a
|
|
211
|
+
// post-open pre failure never leaves a running row. Best-effort: if the
|
|
212
|
+
// close itself fails, the Step-0 reaper owns the orphaned running row.
|
|
213
|
+
function closeOpenedAsRefused(crewHome, rowId, kind, taskId, phase, attemptN, key, error) {
|
|
214
|
+
try {
|
|
215
|
+
callCrewApi(crewHome, "record-spawn-close", {
|
|
216
|
+
id: rowId,
|
|
217
|
+
status: "failed",
|
|
218
|
+
result: {
|
|
219
|
+
outcome: "transport_failed",
|
|
220
|
+
kind,
|
|
221
|
+
task_id: taskId,
|
|
222
|
+
phase,
|
|
223
|
+
attempt_n: attemptN,
|
|
224
|
+
spawn_key: key,
|
|
225
|
+
error,
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
} catch (closeErr) {
|
|
229
|
+
log("WARNING: failed to close refused row " + rowId + ": " + closeErr.message);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function cmdPre(requestJson) {
|
|
234
|
+
let req;
|
|
235
|
+
try {
|
|
236
|
+
req = JSON.parse(requestJson);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
fail("BOUNDS_REJECTED", "request is not valid JSON: " + e.message);
|
|
239
|
+
}
|
|
240
|
+
let v;
|
|
241
|
+
try {
|
|
242
|
+
v = validatePreRequest(req);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
fail("BOUNDS_REJECTED", e.message);
|
|
245
|
+
}
|
|
246
|
+
const { key, kind, taskId, phase, bounds, reg } = v;
|
|
247
|
+
const crewHome = bounds.crewHome;
|
|
248
|
+
|
|
249
|
+
// Bounds validation (fail closed) + prompt/schema existence + readability.
|
|
250
|
+
// A bad path is an orchestrator bug — rejected before attempts burn.
|
|
251
|
+
try {
|
|
252
|
+
validateSpawnBounds(bounds);
|
|
253
|
+
checkFileReadable(reg.promptFile, "prompt_file");
|
|
254
|
+
checkFileReadable(reg.schemaFile, "schema_file");
|
|
255
|
+
} catch (e) {
|
|
256
|
+
fail("BOUNDS_REJECTED", e.message);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Ledger pre-flight: duplicate guard (M2), attempt derivation (F5),
|
|
260
|
+
// transport budget — all read-only, one crew-api call.
|
|
261
|
+
let assess;
|
|
262
|
+
try {
|
|
263
|
+
assess = callCrewApi(crewHome, "assess-spawn-pre", {
|
|
264
|
+
task_id: taskId,
|
|
265
|
+
phase,
|
|
266
|
+
kind,
|
|
267
|
+
spawn_key: key,
|
|
268
|
+
});
|
|
269
|
+
} catch (e) {
|
|
270
|
+
fail("BOUNDS_REJECTED", "ledger pre-flight failed: " + e.message);
|
|
271
|
+
}
|
|
272
|
+
if (assess.duplicate_running) {
|
|
273
|
+
fail("DUPLICATE_RUNNING", "a running ledger row already exists for spawn_key " + key, {
|
|
274
|
+
row_id: assess.duplicate_row_id,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
if (assess.attempt_n > assess.max_attempts) {
|
|
278
|
+
fail("ATTEMPTS_EXHAUSTED", "attempt " + assess.attempt_n + " exceeds max_attempts=" + assess.max_attempts + " for kind '" + kind + "'", {
|
|
279
|
+
attempt_n: assess.attempt_n,
|
|
280
|
+
max_attempts: assess.max_attempts,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (assess.transport_failures >= assess.budget_cap) {
|
|
284
|
+
fail("TRANSPORT_BUDGET_EXHAUSTED", "task " + taskId + " has " + assess.transport_failures + " consecutive transport failures (cap " + assess.budget_cap + ")", {
|
|
285
|
+
transport_failures: assess.transport_failures,
|
|
286
|
+
budget_cap: assess.budget_cap,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Open the ledger row (re-checks the duplicate guard for race safety).
|
|
291
|
+
let opened;
|
|
292
|
+
try {
|
|
293
|
+
opened = callCrewApi(crewHome, "record-spawn-open", {
|
|
294
|
+
task_id: taskId,
|
|
295
|
+
phase,
|
|
296
|
+
spawn_key: key,
|
|
297
|
+
owner_tick_seq: req.owner_tick_seq,
|
|
298
|
+
kind,
|
|
299
|
+
timeout_ms: req.timeout_ms,
|
|
300
|
+
});
|
|
301
|
+
} catch (e) {
|
|
302
|
+
fail("BOUNDS_REJECTED", "ledger open failed: " + e.message);
|
|
303
|
+
}
|
|
304
|
+
if (opened && opened.ok === false && opened.refused === "duplicate_running") {
|
|
305
|
+
fail("DUPLICATE_RUNNING", "a running ledger row already exists for spawn_key " + key, {
|
|
306
|
+
row_id: opened.row_id,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (!opened || !opened.ok || !opened.id) {
|
|
310
|
+
fail("BOUNDS_REJECTED", "ledger open returned an unexpected result");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Build the prompt from the registered pure builder. A params bug is an
|
|
314
|
+
// orchestrator bug — rejected before the spawn burns.
|
|
315
|
+
let prompt;
|
|
316
|
+
try {
|
|
317
|
+
prompt = reg.build(req.prompt_params);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
// The row was opened but the request is malformed: close it as a
|
|
320
|
+
// refused open so it never lingers as running.
|
|
321
|
+
closeOpenedAsRefused(crewHome, opened.id, kind, taskId, phase, assess.attempt_n, key,
|
|
322
|
+
"prompt build failed: " + e.message);
|
|
323
|
+
fail("BOUNDS_REJECTED", "prompt build failed: " + e.message);
|
|
324
|
+
}
|
|
325
|
+
if (typeof prompt !== "string" || prompt.length === 0) {
|
|
326
|
+
closeOpenedAsRefused(crewHome, opened.id, kind, taskId, phase, assess.attempt_n, key,
|
|
327
|
+
"prompt builder returned an empty prompt");
|
|
328
|
+
fail("BOUNDS_REJECTED", "prompt builder returned an empty prompt");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Mint the session path and write the opened record. The tick worker
|
|
332
|
+
// records `child` here right after spawning, then the outcome.
|
|
333
|
+
const sessionDir = join(crewHome, ".spawn-sessions");
|
|
334
|
+
const sessionPath = join(sessionDir, key + ".json");
|
|
335
|
+
try {
|
|
336
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
337
|
+
writeFileSync(
|
|
338
|
+
sessionPath,
|
|
339
|
+
JSON.stringify({
|
|
340
|
+
key,
|
|
341
|
+
kind,
|
|
342
|
+
task_id: taskId,
|
|
343
|
+
phase,
|
|
344
|
+
attempt_n: assess.attempt_n,
|
|
345
|
+
row_id: opened.id,
|
|
346
|
+
status: "spawn_pending",
|
|
347
|
+
opened_at: new Date().toISOString(),
|
|
348
|
+
}) + "\n",
|
|
349
|
+
"utf8"
|
|
350
|
+
);
|
|
351
|
+
} catch (e) {
|
|
352
|
+
closeOpenedAsRefused(crewHome, opened.id, kind, taskId, phase, assess.attempt_n, key,
|
|
353
|
+
"could not write session file: " + e.message);
|
|
354
|
+
fail("BOUNDS_REJECTED", "could not write session file: " + e.message);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
log("pre ok: kind=" + kind + " task=" + taskId + " phase=" + phase + " attempt_n=" + assess.attempt_n + " row=" + opened.id);
|
|
358
|
+
printJson({
|
|
359
|
+
prompt,
|
|
360
|
+
cwd: bounds.cwd,
|
|
361
|
+
env: bounds.env || {},
|
|
362
|
+
timeout_ms: req.timeout_ms,
|
|
363
|
+
session_path: sessionPath,
|
|
364
|
+
key,
|
|
365
|
+
attempt_n: assess.attempt_n,
|
|
366
|
+
row_id: opened.id,
|
|
367
|
+
});
|
|
368
|
+
process.exit(0);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── post ─────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
function extractTypedResult(kind, text) {
|
|
374
|
+
const verdictRes = extractVerdict(text);
|
|
375
|
+
const base = {
|
|
376
|
+
verdict: verdictRes.ok ? (verdictRes.passed ? "PASS" : "FAIL") : null,
|
|
377
|
+
verdict_ok: verdictRes.ok,
|
|
378
|
+
tool_signals: parseToolSignals(text),
|
|
379
|
+
markers: extractMarkerLines(text),
|
|
380
|
+
};
|
|
381
|
+
if (kind === "verdict-reask" || kind === "evidence-gatherer") {
|
|
382
|
+
return base;
|
|
383
|
+
}
|
|
384
|
+
// work-agent (and the synthetic no-spawn branch): the full extraction.
|
|
385
|
+
base.worktree = extractWorktree(text);
|
|
386
|
+
base.release_decision = extractReleaseDecision(text);
|
|
387
|
+
base.experiential = extractExperiential(text);
|
|
388
|
+
return base;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function closeRow(crewHome, id, status, resultObj, errorNote) {
|
|
392
|
+
const outcome = resultObj.outcome;
|
|
393
|
+
if (!isClosedOutcome(outcome)) {
|
|
394
|
+
throw new Error("refusing to close with outcome outside the closed vocabulary: " + outcome);
|
|
395
|
+
}
|
|
396
|
+
const closed = callCrewApi(crewHome, "record-spawn-close", {
|
|
397
|
+
id,
|
|
398
|
+
status,
|
|
399
|
+
error: errorNote || null,
|
|
400
|
+
result: resultObj,
|
|
401
|
+
});
|
|
402
|
+
return closed;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function cmdPost(sessionPath, keyArg) {
|
|
406
|
+
let session;
|
|
407
|
+
try {
|
|
408
|
+
session = JSON.parse(readFileSync(sessionPath, "utf8"));
|
|
409
|
+
} catch (e) {
|
|
410
|
+
// No session, no outcome — the row stays running; the Step-0 reaper
|
|
411
|
+
// owns it. Post must not invent a result.
|
|
412
|
+
printJson({ closed: false, reason: "session_unreadable", detail: e.message });
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
const key = keyArg || session.key;
|
|
416
|
+
if (!key) {
|
|
417
|
+
printJson({ closed: false, reason: "session_unreadable", detail: "no spawn key" });
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
if (keyArg && session.key && keyArg !== session.key) {
|
|
421
|
+
printJson({ closed: false, reason: "key_mismatch", detail: "argv key != session key" });
|
|
422
|
+
process.exit(1);
|
|
423
|
+
}
|
|
424
|
+
// crewHome: the session file lives under <crewHome>/.spawn-sessions/.
|
|
425
|
+
const crewHome = resolve(sessionPath, "..", "..");
|
|
426
|
+
let rowRes;
|
|
427
|
+
try {
|
|
428
|
+
rowRes = callCrewApi(crewHome, "get-spawn-row", { spawn_key: key });
|
|
429
|
+
} catch (e) {
|
|
430
|
+
printJson({ closed: false, reason: "row_not_found", detail: e.message });
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
const row = rowRes.row;
|
|
434
|
+
if (row.status !== "running") {
|
|
435
|
+
// Idempotent: a duplicate post after the close is a no-op, not an error.
|
|
436
|
+
printJson({ closed: false, reason: "already_closed", row_id: row.id, status: row.status });
|
|
437
|
+
process.exit(0);
|
|
438
|
+
}
|
|
439
|
+
const kind = row.kind;
|
|
440
|
+
const common = {
|
|
441
|
+
kind,
|
|
442
|
+
task_id: row.task_id,
|
|
443
|
+
phase: row.phase,
|
|
444
|
+
attempt_n: null,
|
|
445
|
+
spawn_key: key,
|
|
446
|
+
};
|
|
447
|
+
try {
|
|
448
|
+
common.attempt_n = session.attempt_n || null;
|
|
449
|
+
} catch (e) { /* leave null */ }
|
|
450
|
+
|
|
451
|
+
const outcome = session.outcome;
|
|
452
|
+
let status;
|
|
453
|
+
let resultObj;
|
|
454
|
+
|
|
455
|
+
if (outcome === "synthetic") {
|
|
456
|
+
// P3 no-spawn branch: workflow-authored prose, no agent spawned. Same
|
|
457
|
+
// extractors, same close — the bridge never requires a spawn.
|
|
458
|
+
const text = typeof session.text === "string" ? session.text : "";
|
|
459
|
+
const typed = extractTypedResult(kind, text);
|
|
460
|
+
if (typed.verdict_ok) {
|
|
461
|
+
status = "completed";
|
|
462
|
+
resultObj = Object.assign({}, common, typed, { outcome: "synthetic" });
|
|
463
|
+
} else {
|
|
464
|
+
// Workflow-authored prose that fails extraction is an orchestrator
|
|
465
|
+
// bug — fail closed immediately (no reask for synthetic).
|
|
466
|
+
status = "failed";
|
|
467
|
+
resultObj = Object.assign({}, common, typed, {
|
|
468
|
+
outcome: "transport_parse",
|
|
469
|
+
error: "synthetic prose failed verdict extraction",
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
} else if (outcome === "returned") {
|
|
473
|
+
const text = typeof session.text === "string" ? session.text : "";
|
|
474
|
+
if (text.trim() === "") {
|
|
475
|
+
status = "failed";
|
|
476
|
+
resultObj = Object.assign({}, common, {
|
|
477
|
+
outcome: "transport_empty",
|
|
478
|
+
error: "agent returned blank text",
|
|
479
|
+
});
|
|
480
|
+
} else {
|
|
481
|
+
const typed = extractTypedResult(kind, text);
|
|
482
|
+
status = "completed";
|
|
483
|
+
resultObj = Object.assign({}, common, typed, { outcome: "completed" });
|
|
484
|
+
if (!typed.verdict_ok) {
|
|
485
|
+
// Verdict missing: the row still closes (the spawn is done). The
|
|
486
|
+
// driver sees needs_reask and runs the bounded verdict-reask loop
|
|
487
|
+
// — TRANSPORT_PARSE only after that budget is exhausted.
|
|
488
|
+
resultObj.needs_reask = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
} else if (outcome === "timeout") {
|
|
492
|
+
const killEvidence = session.kill_evidence || {};
|
|
493
|
+
if (killEvidence.verified_dead === true) {
|
|
494
|
+
// Writer-is-verifier: the tick worker's machine liveness check
|
|
495
|
+
// proved the child dead. Close with the kill state.
|
|
496
|
+
status = "failed";
|
|
497
|
+
resultObj = Object.assign({}, common, {
|
|
498
|
+
outcome: "killed_confirmed",
|
|
499
|
+
kill_evidence: killEvidence,
|
|
500
|
+
});
|
|
501
|
+
} else {
|
|
502
|
+
// Unconfirmed kill: the row stays running. The cross-tick reaper is
|
|
503
|
+
// the verifier of record — post must not promote an unverified
|
|
504
|
+
// kill to a terminal state.
|
|
505
|
+
printJson({ closed: false, reason: "kill_unverified", row_id: row.id });
|
|
506
|
+
process.exit(0);
|
|
507
|
+
}
|
|
508
|
+
} else if (outcome === "spawn_error") {
|
|
509
|
+
status = "failed";
|
|
510
|
+
resultObj = Object.assign({}, common, {
|
|
511
|
+
outcome: "transport_error",
|
|
512
|
+
error: typeof session.error === "string" ? session.error : "spawn failed",
|
|
513
|
+
});
|
|
514
|
+
} else {
|
|
515
|
+
// Unknown session outcome — the tick worker wrote garbage. Terminal
|
|
516
|
+
// and safe (counts toward the transport budget), never guessed.
|
|
517
|
+
status = "failed";
|
|
518
|
+
resultObj = Object.assign({}, common, {
|
|
519
|
+
outcome: "transport_failed",
|
|
520
|
+
error: "unknown session outcome: " + String(outcome),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let closed;
|
|
525
|
+
try {
|
|
526
|
+
closed = closeRow(crewHome, row.id, status, resultObj);
|
|
527
|
+
} catch (e) {
|
|
528
|
+
printJson({ closed: false, reason: "close_failed", detail: e.message, row_id: row.id });
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
531
|
+
if (closed && closed.noop) {
|
|
532
|
+
printJson({ closed: false, reason: "already_closed", row_id: row.id });
|
|
533
|
+
process.exit(0);
|
|
534
|
+
}
|
|
535
|
+
log("post ok: row=" + row.id + " status=" + status + " outcome=" + resultObj.outcome);
|
|
536
|
+
printJson(Object.assign({ closed: true, row_id: row.id, status }, resultObj));
|
|
537
|
+
process.exit(0);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── main ─────────────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
function main() {
|
|
543
|
+
const argv = process.argv.slice(2);
|
|
544
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
545
|
+
process.stdout.write(USAGE + "\n");
|
|
546
|
+
process.exit(0);
|
|
547
|
+
}
|
|
548
|
+
const sub = argv[0];
|
|
549
|
+
if (sub === "pre") {
|
|
550
|
+
const ri = argv.indexOf("--request");
|
|
551
|
+
if (ri === -1 || !argv[ri + 1]) {
|
|
552
|
+
printJson({ error: { code: "BOUNDS_REJECTED", message: "pre requires --request '<json>'" } });
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
cmdPre(argv[ri + 1]);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (sub === "post") {
|
|
559
|
+
const si = argv.indexOf("--session");
|
|
560
|
+
if (si === -1 || !argv[si + 1]) {
|
|
561
|
+
printJson({ error: { code: "BOUNDS_REJECTED", message: "post requires --session '<path>'" } });
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
const ki = argv.indexOf("--key");
|
|
565
|
+
cmdPost(argv[si + 1], ki === -1 ? null : argv[ki + 1]);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
printJson({ error: { code: "BOUNDS_REJECTED", message: "unknown subcommand: " + sub } });
|
|
569
|
+
process.exit(1);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
main();
|