quest-loop 0.1.0 → 0.3.2
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/CHANGELOG.md +137 -0
- package/README.md +119 -19
- package/agents/quest-executor.md +12 -2
- package/agents/quest-executor.toml +13 -6
- package/agents/quest-reviewer.md +9 -0
- package/agents/quest-reviewer.toml +11 -7
- package/hooks/hooks.json +40 -5
- package/hooks/subagent-stop.mjs +140 -10
- package/lib/cli.mjs +141 -2
- package/lib/codex-native.mjs +425 -0
- package/lib/config.mjs +1 -1
- package/lib/contract.mjs +22 -2
- package/lib/help.mjs +42 -7
- package/lib/runner.mjs +59 -3
- package/lib/store-github.mjs +34 -2
- package/lib/store-local.mjs +32 -2
- package/lib/store.mjs +2 -0
- package/lib/workers.mjs +26 -12
- package/package.json +3 -3
- package/skills/orchestrate/SKILL.md +100 -23
- package/skills/orchestrate/agents/openai.yaml +1 -1
- package/skills/plan/SKILL.md +42 -5
- package/skills/plan/agents/openai.yaml +1 -1
- package/skills/protocol/SKILL.md +2 -2
- package/skills/protocol/agents/openai.yaml +1 -1
- package/skills/protocol/references/contract-spec.md +14 -5
- package/skills/protocol/references/protocol.md +12 -4
- package/skills/retro/SKILL.md +2 -2
- package/skills/retro/agents/openai.yaml +1 -1
- package/skills/setup/SKILL.md +92 -0
- package/skills/setup/agents/openai.yaml +7 -0
- package/skills/work/SKILL.md +3 -3
- package/skills/work/agents/openai.yaml +1 -1
package/lib/runner.mjs
CHANGED
|
@@ -34,10 +34,11 @@ class RunUsageError extends Error {
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
|
|
36
36
|
const HELP = {
|
|
37
|
-
purpose: "Drive a headless Claude or Codex worker through a quest
|
|
37
|
+
purpose: "Drive a headless Claude or Codex worker through a quest with checkpoint-based stop checks.",
|
|
38
38
|
usage: [
|
|
39
39
|
"quest-run <id> [--worker claude|codex] [--model M] [--effort E]",
|
|
40
40
|
" [--max-iterations N] [--max-cost USD] [--codex-sandbox MODE]",
|
|
41
|
+
" [--codex-goal-mode auto|require|off]",
|
|
41
42
|
" [--continue-session] [--notify '<cmd>'] [--dry-run] [--json]",
|
|
42
43
|
"quest-run --ready [--parallel N] [--isolate worktree] [--dry-run] [--json]",
|
|
43
44
|
],
|
|
@@ -51,6 +52,7 @@ const HELP = {
|
|
|
51
52
|
["--max-tokens <n>", "token cap; governs codex spend since USD is unreported → blocked, exit 11"],
|
|
52
53
|
["--session-timeout <s>", "per-session wall-clock cap (seconds); a hung worker is killed and the session counts as a stall (default 1800; config defaults.session_timeout)"],
|
|
53
54
|
["--codex-sandbox <mode>", "codex exec sandbox: read-only | workspace-write | danger-full-access (default workspace-write; config defaults.codex.sandbox). workspace-write BLOCKS git commits — commit quests need danger-full-access"],
|
|
55
|
+
["--codex-goal-mode <mode>", "codex goal-tool policy: auto | require | off (default auto; config defaults.codex.goal_mode)"],
|
|
54
56
|
["--continue-session", "resume the previous session each iteration instead of a fresh one"],
|
|
55
57
|
["--notify <cmd>", "shell command run on run end (env: QUEST_ID, QUEST_TITLE, FINAL_STATUS, ITERATIONS, COST)"],
|
|
56
58
|
["--dry-run", "print the exact worker invocation and exit without spawning"],
|
|
@@ -68,6 +70,8 @@ const HELP = {
|
|
|
68
70
|
"codex's default --codex-sandbox workspace-write write-protects .git, so a codex worker",
|
|
69
71
|
"cannot `git commit` under it; a commit-requiring quest must opt into danger-full-access —",
|
|
70
72
|
"an explicit tradeoff (full disk + network access), never escalated silently.",
|
|
73
|
+
"codex goal tools are optional by default; --codex-goal-mode require blocks honestly if",
|
|
74
|
+
"the exec surface does not expose or invoke them.",
|
|
71
75
|
"Without --isolate, parallel quests are assumed file-disjoint (no locking beyond the store's).",
|
|
72
76
|
"--isolate worktree leaves the worktree + quest/<id>-<slug> branch in place; the PR is the merge path.",
|
|
73
77
|
],
|
|
@@ -203,7 +207,11 @@ function resolveOptions(front, config, flags) {
|
|
|
203
207
|
if ((flags["codex-sandbox"] != null || worker === "codex") && !CODEX_SANDBOX_MODES.includes(codexSandbox)) {
|
|
204
208
|
throw new RunUsageError(`--codex-sandbox must be one of ${CODEX_SANDBOX_MODES.join(", ")} (got "${codexSandbox}")`);
|
|
205
209
|
}
|
|
206
|
-
|
|
210
|
+
const codexGoalMode = flags["codex-goal-mode"] ?? config.defaults.codex?.goal_mode ?? "auto";
|
|
211
|
+
if ((flags["codex-goal-mode"] != null || worker === "codex") && !["auto", "require", "off"].includes(codexGoalMode)) {
|
|
212
|
+
throw new RunUsageError(`--codex-goal-mode must be one of auto, require, off (got "${codexGoalMode}")`);
|
|
213
|
+
}
|
|
214
|
+
return { worker, model, effort, maxIterations, maxCost, maxTokens, sessionTimeout, codexSandbox, codexGoalMode };
|
|
207
215
|
}
|
|
208
216
|
|
|
209
217
|
function lastSessionIdFor(storeDir, questId, worker) {
|
|
@@ -273,6 +281,7 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
|
|
|
273
281
|
schemaPath: SCHEMA_PATH,
|
|
274
282
|
runStartIso,
|
|
275
283
|
codexSandbox: opts.codexSandbox,
|
|
284
|
+
codexGoalMode: opts.codexGoalMode,
|
|
276
285
|
};
|
|
277
286
|
const inv = adapter.buildInvocation(initial, config, invOpts);
|
|
278
287
|
if (flags.json) {
|
|
@@ -301,6 +310,7 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
|
|
|
301
310
|
if (initial.status === "complete") {
|
|
302
311
|
finalStatus = "complete";
|
|
303
312
|
exitCode = 0;
|
|
313
|
+
io.errOut(`quest-run: quest ${id} is already complete — nothing to run. To legally re-enter the loop, run \`quest reopen ${id} --reason "<why>"\` first (never hand-edit the status line).`);
|
|
304
314
|
} else if (initial.status === "cancelled") {
|
|
305
315
|
finalStatus = "cancelled";
|
|
306
316
|
exitCode = 0;
|
|
@@ -394,6 +404,7 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
|
|
|
394
404
|
schemaPath: SCHEMA_PATH,
|
|
395
405
|
runStartIso,
|
|
396
406
|
codexSandbox: opts.codexSandbox,
|
|
407
|
+
codexGoalMode: opts.codexGoalMode,
|
|
397
408
|
},
|
|
398
409
|
runStartIso,
|
|
399
410
|
iteration: sessionsRun,
|
|
@@ -428,6 +439,8 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
|
|
|
428
439
|
// landed before the kill — a terminal status is still caught at the top
|
|
429
440
|
// of the next iteration, so completions are never lost.
|
|
430
441
|
const progressed = newCheckpoint && !sessionTimedOut;
|
|
442
|
+
// Journal the session BEFORE any terminal break so `quest runs` telemetry
|
|
443
|
+
// (iteration count, cost, tokens) is never dropped for the final session.
|
|
431
444
|
local.appendRunEvent(storeDir, {
|
|
432
445
|
event: "iteration_finished",
|
|
433
446
|
run_id: runId,
|
|
@@ -441,6 +454,25 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
|
|
|
441
454
|
status_after: after ? after.status : null,
|
|
442
455
|
});
|
|
443
456
|
|
|
457
|
+
// Goal-mode `require`: block whenever create_goal was required but never
|
|
458
|
+
// observed and the quest is still OPEN — a milestone checkpoint does not
|
|
459
|
+
// satisfy the requirement. A genuinely complete/blocked/cancelled quest is
|
|
460
|
+
// respected (its evidence trail stands on its own). A LOST record (`!after`)
|
|
461
|
+
// is not "open": let the missing-record path at the loop top surface it.
|
|
462
|
+
const questOpen = after && (after.status === "in_progress" || after.status === "todo");
|
|
463
|
+
if (opts.worker === "codex" && result.goalToolMissingRequired && questOpen) {
|
|
464
|
+
await recordBlocked(
|
|
465
|
+
storeDir,
|
|
466
|
+
env,
|
|
467
|
+
id,
|
|
468
|
+
"runner: codex goal tools were required but no create_goal tool call was observed",
|
|
469
|
+
"runner codex goal-mode enforcement",
|
|
470
|
+
);
|
|
471
|
+
finalStatus = "blocked";
|
|
472
|
+
exitCode = 10;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
|
|
444
476
|
if (progressed) {
|
|
445
477
|
stall = 0;
|
|
446
478
|
if (after.status === "complete") {
|
|
@@ -538,6 +570,7 @@ async function runReady(storeDir, config, flags, io) {
|
|
|
538
570
|
|
|
539
571
|
const summaries = [];
|
|
540
572
|
const done = new Set();
|
|
573
|
+
const skippedEpics = new Set();
|
|
541
574
|
const inFlight = new Map();
|
|
542
575
|
|
|
543
576
|
const readyIds = async () => {
|
|
@@ -550,6 +583,22 @@ async function runReady(storeDir, config, flags, io) {
|
|
|
550
583
|
}
|
|
551
584
|
};
|
|
552
585
|
|
|
586
|
+
// Ids that at least one quest names as its parent — i.e. epics. Epics are
|
|
587
|
+
// closed by the orchestrator inline (verify children, run the epic validation
|
|
588
|
+
// loop, checkpoint), never dispatched to a worker. readyQuests already gates
|
|
589
|
+
// out epics with open children; this catches the fully-verified epic that has
|
|
590
|
+
// become "ready" so --ready still refuses to burn a worker run on it. A
|
|
591
|
+
// direct `quest-run <id>` on an epic stays allowed.
|
|
592
|
+
const epicIds = async () => {
|
|
593
|
+
const { stdout, code } = await questCli(storeDir, io.env, ["list", "--json"]);
|
|
594
|
+
if (code !== 0) return new Set();
|
|
595
|
+
try {
|
|
596
|
+
return new Set(JSON.parse(stdout).filter((q) => q.parent !== undefined).map((q) => q.parent));
|
|
597
|
+
} catch {
|
|
598
|
+
return new Set();
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
553
602
|
const startOne = (id) => {
|
|
554
603
|
const promise = (async () => {
|
|
555
604
|
let cwd = io.cwd;
|
|
@@ -570,8 +619,14 @@ async function runReady(storeDir, config, flags, io) {
|
|
|
570
619
|
};
|
|
571
620
|
|
|
572
621
|
for (;;) {
|
|
573
|
-
const
|
|
622
|
+
const epics = await epicIds();
|
|
623
|
+
const ready = (await readyIds()).filter((id) => !done.has(id) && !inFlight.has(id) && !skippedEpics.has(id));
|
|
574
624
|
for (const id of ready) {
|
|
625
|
+
if (epics.has(id)) {
|
|
626
|
+
skippedEpics.add(id);
|
|
627
|
+
io.errOut(`quest-run: quest ${id} is an epic (other quests name it as parent) — refusing to auto-dispatch it. Close it inline per $quest:orchestrate: verify its children, run the epic validation loop, then \`quest checkpoint ${id} --status complete\`. Never burn a worker run on an epic.`);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
575
630
|
if (inFlight.size >= parallel) break;
|
|
576
631
|
startOne(id);
|
|
577
632
|
}
|
|
@@ -626,6 +681,7 @@ export async function run(argv, io = {}) {
|
|
|
626
681
|
"max-tokens": { type: "string" },
|
|
627
682
|
"session-timeout": { type: "string" },
|
|
628
683
|
"codex-sandbox": { type: "string" },
|
|
684
|
+
"codex-goal-mode": { type: "string" },
|
|
629
685
|
"continue-session": { type: "boolean" },
|
|
630
686
|
notify: { type: "string" },
|
|
631
687
|
"dry-run": { type: "boolean" },
|
package/lib/store-github.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
ContractError,
|
|
21
21
|
appendToSection,
|
|
22
22
|
appendUnderCheckpoints,
|
|
23
|
+
assertReopen,
|
|
23
24
|
assertTransition,
|
|
24
25
|
lintRecord,
|
|
25
26
|
makeCheckpoint,
|
|
@@ -267,12 +268,19 @@ export function listQuests(repo, env) {
|
|
|
267
268
|
});
|
|
268
269
|
}
|
|
269
270
|
|
|
270
|
-
// Same readiness rule as store-local.readyQuests, over the remote list
|
|
271
|
+
// Same readiness rule as store-local.readyQuests, over the remote list —
|
|
272
|
+
// including the epic gate: a quest with any non-terminal child (a quest whose
|
|
273
|
+
// parent points at it, not in complete/cancelled) is never ready. Epics are
|
|
274
|
+
// closed by the orchestrator inline, never auto-dispatched; a cancelled child
|
|
275
|
+
// is terminal so it cannot wedge the epic forever.
|
|
271
276
|
export function readyQuests(repo, env) {
|
|
272
277
|
const all = listQuests(repo, env);
|
|
273
278
|
const done = new Set(all.filter((q) => q.status === "complete").map((q) => q.id));
|
|
279
|
+
const openParents = new Set(
|
|
280
|
+
all.filter((q) => q.parent !== undefined && q.status !== "complete" && q.status !== "cancelled").map((q) => q.parent),
|
|
281
|
+
);
|
|
274
282
|
return all
|
|
275
|
-
.filter((q) => q.status === "todo" && (q.depends_on ?? []).every((d) => done.has(d)))
|
|
283
|
+
.filter((q) => q.status === "todo" && !openParents.has(q.id) && (q.depends_on ?? []).every((d) => done.has(d)))
|
|
276
284
|
.sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
|
|
277
285
|
}
|
|
278
286
|
|
|
@@ -372,10 +380,34 @@ export function cancelQuest(repo, id, reason, env) {
|
|
|
372
380
|
return { front: { ...rec.front, status: "cancelled", updated } };
|
|
373
381
|
}
|
|
374
382
|
|
|
383
|
+
// Comment-first, mirroring cancelQuest's operation order: post the audited
|
|
384
|
+
// reopen checkpoint, refresh the meta timestamp, then swap the label
|
|
385
|
+
// (quest:complete → quest:in-progress) and reopen the issue via applyStatus.
|
|
386
|
+
export function reopenQuest(repo, id, reason, env) {
|
|
387
|
+
if (!reason || !reason.trim()) throw new ContractError("reopen requires --reason", { hint: 'example: quest reopen 4 --reason "review found npm audit criticals"' });
|
|
388
|
+
const rec = reconstruct(viewIssue(repo, id, env));
|
|
389
|
+
assertReopen(rec.front.status);
|
|
390
|
+
const block = makeCheckpoint({
|
|
391
|
+
timestamp: nowIso(),
|
|
392
|
+
quest_status: "in_progress",
|
|
393
|
+
iteration: rec.checkpoints.length + 1,
|
|
394
|
+
changed: "reopened from complete",
|
|
395
|
+
validation_summary: "reopened for further work; no execution this entry",
|
|
396
|
+
reopen_reason: reason.trim(),
|
|
397
|
+
});
|
|
398
|
+
gh(["issue", "comment", String(id), "--repo", repo, "--body-file", "-"], { env, input: block });
|
|
399
|
+
const updated = nowIso();
|
|
400
|
+
writeStoredBody(repo, id, { ...rec.meta, updated }, rec.storedSections, env);
|
|
401
|
+
applyStatus(repo, id, rec.labels, rec.state, "in_progress", env);
|
|
402
|
+
return { front: { ...rec.front, status: "in_progress", updated }, checkpoints: [...rec.checkpoints, ...parseCheckpoints(block)] };
|
|
403
|
+
}
|
|
404
|
+
|
|
375
405
|
export function editQuest(repo, id, { addDoneWhen = [], addMilestone = [], addContext, rationale }, env) {
|
|
376
406
|
if (!rationale || !rationale.trim()) throw new ContractError("edit requires --rationale (scope changes are recorded, per protocol)", { hint: "state why this is a compatible expansion of the Objective" });
|
|
377
407
|
if (!addDoneWhen.length && !addMilestone.length && !addContext) throw new ContractError("nothing to add — pass --add-done-when, --add-milestone, or --add-context", { hint: "the Objective and existing Done-when items are immutable by design" });
|
|
378
408
|
const rec = reconstruct(viewIssue(repo, id, env));
|
|
409
|
+
if (rec.front.status === "complete") throw new ContractError("cannot edit a complete quest", { hint: "reopen it first with `quest reopen <id> --reason`, then edit — or file a new quest" });
|
|
410
|
+
if (rec.front.status === "cancelled") throw new ContractError("cannot edit a cancelled quest", { hint: "cancelled is terminal — file a new quest instead" });
|
|
379
411
|
let sections = rec.storedSections;
|
|
380
412
|
for (const item of addDoneWhen) sections = appendToSection(sections, "## Done when", `- [ ] ${item}`);
|
|
381
413
|
for (const item of addMilestone) sections = appendToSection(sections, "## Milestones", `- [ ] ${item}`, { createAfter: "## Validation loop" });
|
package/lib/store-local.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, writeFileSync, statSync, appendFileSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
|
-
import { ContractError, appendToSection, appendUnderCheckpoints, assertTransition, lintRecord, makeCheckpoint, nowIso, parseRecord, recordFilename, renderBody, serializeRecord, CHECKPOINT_MARKER } from "./contract.mjs";
|
|
7
|
+
import { ContractError, appendToSection, appendUnderCheckpoints, assertReopen, assertTransition, lintRecord, makeCheckpoint, nowIso, parseRecord, recordFilename, renderBody, serializeRecord, CHECKPOINT_MARKER } from "./contract.mjs";
|
|
8
8
|
|
|
9
9
|
export class NotFoundError extends Error {
|
|
10
10
|
constructor(id) {
|
|
@@ -85,8 +85,15 @@ export function listQuests(storeDir) {
|
|
|
85
85
|
export function readyQuests(storeDir) {
|
|
86
86
|
const all = listQuests(storeDir);
|
|
87
87
|
const done = new Set(all.filter((q) => q.status === "complete").map((q) => q.id));
|
|
88
|
+
// Epic gate: a quest with any non-terminal child (a quest whose parent points
|
|
89
|
+
// at it, not in complete/cancelled) is never ready. Epics are closed by the
|
|
90
|
+
// orchestrator inline (see $quest:orchestrate), never auto-dispatched. A
|
|
91
|
+
// cancelled child is terminal so it cannot wedge the epic forever.
|
|
92
|
+
const openParents = new Set(
|
|
93
|
+
all.filter((q) => q.parent !== undefined && q.status !== "complete" && q.status !== "cancelled").map((q) => q.parent),
|
|
94
|
+
);
|
|
88
95
|
return all
|
|
89
|
-
.filter((q) => q.status === "todo" && (q.depends_on ?? []).every((d) => done.has(d)))
|
|
96
|
+
.filter((q) => q.status === "todo" && !openParents.has(q.id) && (q.depends_on ?? []).every((d) => done.has(d)))
|
|
90
97
|
.sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
|
|
91
98
|
}
|
|
92
99
|
|
|
@@ -162,10 +169,33 @@ export function cancelQuest(storeDir, id, reason) {
|
|
|
162
169
|
});
|
|
163
170
|
}
|
|
164
171
|
|
|
172
|
+
// The audited path from complete back into the loop (mirrors cancelQuest, but
|
|
173
|
+
// flips complete → in_progress and appends a real checkpoint carrying the
|
|
174
|
+
// reopen_reason). Uses assertReopen, never assertTransition — a checkpoint can
|
|
175
|
+
// never resurrect a complete quest; only this verb can.
|
|
176
|
+
export function reopenQuest(storeDir, id, reason) {
|
|
177
|
+
if (!reason || !reason.trim()) throw new ContractError("reopen requires --reason", { hint: 'example: quest reopen 4 --reason "review found npm audit criticals"' });
|
|
178
|
+
return updateQuest(storeDir, id, (q) => {
|
|
179
|
+
assertReopen(q.front.status);
|
|
180
|
+
const block = makeCheckpoint({
|
|
181
|
+
timestamp: nowIso(),
|
|
182
|
+
quest_status: "in_progress",
|
|
183
|
+
iteration: q.checkpoints.length + 1,
|
|
184
|
+
changed: "reopened from complete",
|
|
185
|
+
validation_summary: "reopened for further work; no execution this entry",
|
|
186
|
+
reopen_reason: reason.trim(),
|
|
187
|
+
});
|
|
188
|
+
const body = appendUnderCheckpoints(q.body, block);
|
|
189
|
+
return { front: { ...q.front, status: "in_progress" }, body };
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
165
193
|
export function editQuest(storeDir, id, { addDoneWhen = [], addMilestone = [], addContext, rationale }) {
|
|
166
194
|
if (!rationale || !rationale.trim()) throw new ContractError("edit requires --rationale (scope changes are recorded, per protocol)", { hint: "state why this is a compatible expansion of the Objective" });
|
|
167
195
|
if (!addDoneWhen.length && !addMilestone.length && !addContext) throw new ContractError("nothing to add — pass --add-done-when, --add-milestone, or --add-context", { hint: "the Objective and existing Done-when items are immutable by design" });
|
|
168
196
|
return updateQuest(storeDir, id, (q) => {
|
|
197
|
+
if (q.front.status === "complete") throw new ContractError("cannot edit a complete quest", { hint: "reopen it first with `quest reopen <id> --reason`, then edit — or file a new quest" });
|
|
198
|
+
if (q.front.status === "cancelled") throw new ContractError("cannot edit a cancelled quest", { hint: "cancelled is terminal — file a new quest instead" });
|
|
169
199
|
let body = q.body;
|
|
170
200
|
for (const item of addDoneWhen) body = appendToSection(body, "## Done when", `- [ ] ${item}`);
|
|
171
201
|
for (const item of addMilestone) body = appendToSection(body, "## Milestones", `- [ ] ${item}`, { createAfter: "## Validation loop" });
|
package/lib/store.mjs
CHANGED
|
@@ -18,6 +18,7 @@ export function openStore(config, ctx = {}) {
|
|
|
18
18
|
startQuest: (id) => github.startQuest(repo, id, env),
|
|
19
19
|
appendCheckpoint: (id, cp) => github.appendCheckpoint(repo, id, cp, env),
|
|
20
20
|
cancelQuest: (id, reason) => github.cancelQuest(repo, id, reason, env),
|
|
21
|
+
reopenQuest: (id, reason) => github.reopenQuest(repo, id, reason, env),
|
|
21
22
|
editQuest: (id, changes) => github.editQuest(repo, id, changes, env),
|
|
22
23
|
lintAll: () => github.lintAll(repo, env),
|
|
23
24
|
};
|
|
@@ -31,6 +32,7 @@ export function openStore(config, ctx = {}) {
|
|
|
31
32
|
startQuest: (id) => local.startQuest(dir, id),
|
|
32
33
|
appendCheckpoint: (id, cp) => local.appendCheckpoint(dir, id, cp),
|
|
33
34
|
cancelQuest: (id, reason) => local.cancelQuest(dir, id, reason),
|
|
35
|
+
reopenQuest: (id, reason) => local.reopenQuest(dir, id, reason),
|
|
34
36
|
editQuest: (id, changes) => local.editQuest(dir, id, changes),
|
|
35
37
|
lintAll: () => local.lintAll(dir),
|
|
36
38
|
};
|
package/lib/workers.mjs
CHANGED
|
@@ -46,7 +46,7 @@ function workInstructions(id) {
|
|
|
46
46
|
return (
|
|
47
47
|
"Work quest " +
|
|
48
48
|
id +
|
|
49
|
-
" per the
|
|
49
|
+
" per the $quest:work skill. The quest store is in this directory. " +
|
|
50
50
|
"End every iteration by running quest show " +
|
|
51
51
|
id +
|
|
52
52
|
"."
|
|
@@ -242,20 +242,28 @@ const CODEX_CREATE_GOAL_CORRECTION = (id) =>
|
|
|
242
242
|
"You did not invoke the create_goal tool — you narrated it instead. Call the create_goal tool now " +
|
|
243
243
|
"(a real tool call, not prose) with the stopping condition, verify with get_goal, then continue working quest " +
|
|
244
244
|
id +
|
|
245
|
-
" per
|
|
245
|
+
" per $quest:work.";
|
|
246
246
|
|
|
247
247
|
export const codex = {
|
|
248
248
|
name: "codex",
|
|
249
249
|
|
|
250
250
|
buildInvocation(questRecord, config, opts) {
|
|
251
251
|
const { id } = opts;
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
252
|
+
const goalMode = opts.codexGoalMode ?? "auto";
|
|
253
|
+
const condition = stoppingCondition(id, opts.runStartIso);
|
|
254
|
+
// Each mode gets its own complete, self-terminating instruction. Every mode —
|
|
255
|
+
// including `off` — names the `quest checkpoint` command, which is the sole
|
|
256
|
+
// machine-verifiable stop signal a headless worker must emit.
|
|
257
|
+
const goalLine =
|
|
258
|
+
goalMode === "off"
|
|
259
|
+
? `Do not rely on goal tools. Treat this as the run contract — stopping condition: ${condition}. ` +
|
|
260
|
+
'Record progress with `quest checkpoint`; only report the quest complete AFTER `quest checkpoint` succeeded.'
|
|
261
|
+
: goalMode === "require"
|
|
262
|
+
? `First step: Create a goal for this thread using the create_goal tool (not as prose) with this exact stopping condition: ${condition}. ` +
|
|
263
|
+
'Verify with get_goal. Only call update_goal(status="complete") AFTER `quest checkpoint` succeeded.'
|
|
264
|
+
: `If goal tools are available in this exec surface, create a goal for this thread using the create_goal tool with this exact stopping condition: ${condition}. ` +
|
|
265
|
+
'If you created a goal, verify with get_goal. Only call update_goal(status="complete") AFTER `quest checkpoint` succeeded.';
|
|
266
|
+
const prompt = goalLine + "\n" + workInstructions(id);
|
|
259
267
|
const artifactsFile = codexArtifactsFile();
|
|
260
268
|
const args = [
|
|
261
269
|
"exec",
|
|
@@ -287,7 +295,7 @@ export const codex = {
|
|
|
287
295
|
const args = ["exec", "resume"];
|
|
288
296
|
if (sessionArg === "--last") args.push("--last");
|
|
289
297
|
else args.push(sessionArg);
|
|
290
|
-
args.push(text, "--json", "
|
|
298
|
+
args.push(text, "--json", "--skip-git-repo-check", "-o", artifactsFile, "--output-schema", opts.schemaPath);
|
|
291
299
|
if (opts.effort) args.push("-c", `model_reasoning_effort=${opts.effort}`);
|
|
292
300
|
const basePath = opts.env?.PATH ?? "";
|
|
293
301
|
const env = { PATH: `${join(opts.pluginRoot, "bin")}:${basePath}` };
|
|
@@ -339,8 +347,10 @@ export const codex = {
|
|
|
339
347
|
const first = await runSegment(this.buildInvocation(questRecord, config, opts));
|
|
340
348
|
let sawGoal = codexUsedCreateGoal(first.events || []);
|
|
341
349
|
|
|
342
|
-
// Corrective resume
|
|
343
|
-
|
|
350
|
+
// Corrective resume only when the caller explicitly requires goal tools.
|
|
351
|
+
// In auto/off modes the documented Codex exec JSONL + output-schema path is
|
|
352
|
+
// the contract; goal tools are useful but not assumed available.
|
|
353
|
+
if (!sawGoal && opts.codexGoalMode === "require") {
|
|
344
354
|
correctiveResume = true;
|
|
345
355
|
resumes += 1;
|
|
346
356
|
const corr = this.buildResume(questRecord, config, opts, "--last", CODEX_CREATE_GOAL_CORRECTION(opts.id));
|
|
@@ -348,6 +358,10 @@ export const codex = {
|
|
|
348
358
|
if (codexUsedCreateGoal(cres.events || [])) sawGoal = true;
|
|
349
359
|
}
|
|
350
360
|
|
|
361
|
+
if (opts.codexGoalMode === "require" && !sawGoal) {
|
|
362
|
+
return { session_id: sessionId, cost_usd: cost, tokens, invocations, sawGoal, correctiveResume, resumes, goalToolMissingRequired: true };
|
|
363
|
+
}
|
|
364
|
+
|
|
351
365
|
// Same-session continuation while the stopping condition is unmet.
|
|
352
366
|
while (resumes < CODEX_MAX_RESUMES) {
|
|
353
367
|
const state = await ctx.readState();
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quest-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Goal-loop engineering for coding agents: quest contracts, iterative execution with evidence checkpoints, Claude + Codex workers. Ships the `quest` store CLI and the `quest-run` headless runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": { "node": ">=20" },
|
|
7
7
|
"bin": {
|
|
8
|
-
"quest": "
|
|
9
|
-
"quest-run": "
|
|
8
|
+
"quest": "bin/quest",
|
|
9
|
+
"quest-run": "bin/quest-run"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"bin/",
|
|
@@ -21,54 +21,131 @@ trails, and escalations only where a human ruling is genuinely needed.
|
|
|
21
21
|
quest list --ready --json # the dispatch queue (deps met, priority order)
|
|
22
22
|
quest runs --active # headless runners that outlived prior sessions
|
|
23
23
|
```
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
For an implementation accepted from `$quest:plan` in Codex Plan Mode, stay in
|
|
25
|
+
this orchestrator role. Do not implement product code inline; create/lint the
|
|
26
|
+
quest records if needed, then dispatch workers.
|
|
27
|
+
2. **Pin the wave with native goal mode:**
|
|
28
|
+
- In Codex, call `create_goal` with this stopping condition:
|
|
29
|
+
"every quest in the scoped wave shows complete, blocked, or cancelled in
|
|
30
|
+
`quest list --json` output"; verify it with `get_goal`.
|
|
31
|
+
- In Claude Code, start the turn with `/goal` using the same condition.
|
|
32
|
+
If native goal mode is unavailable, say so and keep the Quest checkpoint
|
|
33
|
+
trail as the hard stop signal; do not pretend a goal was set.
|
|
34
|
+
3. **Dispatch** each ready quest per its record:
|
|
35
|
+
- In Codex, the default path is native subagents for both serial and
|
|
36
|
+
parallel waves. If `spawn_agent` is not visible, call `tool_search` once
|
|
37
|
+
for subagent tools before choosing any fallback. When available, spawn:
|
|
38
|
+
`agent_type: "quest-executor"` and prompt =
|
|
39
|
+
`First call create_goal with: quest <id> has a new checkpoint whose
|
|
40
|
+
quest_status is complete or blocked in \`quest show <id> --json\`; verify
|
|
41
|
+
with get_goal; work quest <id> per $quest:work; only call
|
|
42
|
+
update_goal(status="complete") after the checkpoint exists.`
|
|
43
|
+
If the agent template is missing, run `quest codex install-agents --scope
|
|
44
|
+
project` (or `$quest:setup`) before dispatching.
|
|
45
|
+
- In Claude Code, spawn the `quest-executor` subagent with the record's
|
|
46
|
+
`model`/`effort` as the dispatch override and prompt =
|
|
47
|
+
`/goal quest <id> has a new checkpoint whose quest_status is complete or
|
|
48
|
+
blocked in \`quest show <id> --json\`\nWork quest <id> per $quest:work.`
|
|
49
|
+
- Use `quest-run` only when native subagents are still unavailable after
|
|
50
|
+
`tool_search`, or when the user explicitly asks for headless/background
|
|
51
|
+
execution. Codex fallback must require goal mode:
|
|
52
|
+
`quest-run <id> --worker codex --codex-goal-mode require`. Headless
|
|
53
|
+
file-disjoint waves can use `quest-run --ready --parallel 3
|
|
54
|
+
--codex-goal-mode require` (add `--isolate worktree` when they touch the
|
|
55
|
+
same files). Claude headless runs already enter native `/goal` mode.
|
|
56
|
+
4. **Verify before you believe:** when a worker stops, run
|
|
34
57
|
`quest show <id> --json`. A stop WITHOUT a new checkpoint is a protocol
|
|
35
58
|
violation — redispatch with exactly that instruction. Never accept a chat
|
|
36
59
|
summary in place of a recorded checkpoint.
|
|
37
|
-
|
|
38
|
-
`quest-reviewer` on the diff + checkpoint evidence
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
5. **Review before accepting complete:** for non-trivial quests, spawn
|
|
61
|
+
`quest-reviewer` on the diff + checkpoint evidence in the same harness. Give
|
|
62
|
+
it a goal: return an `accept` or `iterate` verdict with evidence. In Codex,
|
|
63
|
+
ask it to call `create_goal`/`get_goal` and only `update_goal` after the
|
|
64
|
+
verdict exists; in Claude Code, prefix the reviewer prompt with `/goal`.
|
|
65
|
+
Every finding gets a disposition: fixed / follow-up quest filed /
|
|
66
|
+
rejected-with-reason.
|
|
67
|
+
6. **Rule** (quote evidence, never adjectives):
|
|
41
68
|
- **accept** — the validation_summary's commands actually discharge the
|
|
42
69
|
Done-when items.
|
|
43
70
|
- **iterate-with-feedback** — send the specific gap back (continue the
|
|
44
71
|
subagent, or `quest-run <id> --continue-session`).
|
|
45
|
-
- **split** — bigger than it looked →
|
|
72
|
+
- **split** — bigger than it looked → `$quest:plan` to decompose; cancel or
|
|
46
73
|
re-parent the original honestly.
|
|
47
74
|
- **escalate-to-human** — surface human-only decisions verbatim. Never
|
|
48
75
|
guess a ruling the human should make.
|
|
49
|
-
|
|
50
|
-
run
|
|
76
|
+
7. **Wave done?** When `quest list --ready` empties and nothing is in flight:
|
|
77
|
+
run `$quest:retro` before starting the next wave.
|
|
78
|
+
|
|
79
|
+
## Closing an epic
|
|
80
|
+
|
|
81
|
+
An epic is an ordinary quest that other quests name as `parent`. It is **never
|
|
82
|
+
dispatched to a worker**: `quest list --ready` gates it out while any child is
|
|
83
|
+
non-terminal, and `quest-run --ready` refuses it even once every child is
|
|
84
|
+
terminal (a direct `quest-run <id>` on an epic still runs, but don't — it burns
|
|
85
|
+
a worker on pure verification). Close it inline yourself, spending zero worker
|
|
86
|
+
tokens:
|
|
87
|
+
|
|
88
|
+
1. **Verify the children are genuinely done:**
|
|
89
|
+
```bash
|
|
90
|
+
quest list --parent <id> --json # is every child complete or cancelled?
|
|
91
|
+
```
|
|
92
|
+
A `cancelled` child is terminal too — account for why it was dropped; it does
|
|
93
|
+
not block the epic and you do not wait on it.
|
|
94
|
+
2. **Run the epic's own validation loop** (the integration-level check in its
|
|
95
|
+
record) and read each child's completion evidence — this is the real work of
|
|
96
|
+
closing an epic.
|
|
97
|
+
3. **Record the verdict on the epic itself:**
|
|
98
|
+
```bash
|
|
99
|
+
quest start <id>
|
|
100
|
+
quest checkpoint <id> --status complete \
|
|
101
|
+
--summary "epic closed inline — children #a #b #c verified, integration loop green" \
|
|
102
|
+
--validation "<epic validation loop> → <observed result>"
|
|
103
|
+
```
|
|
104
|
+
The checkpoint must **enumerate each child and each epic Done-when item** with
|
|
105
|
+
its evidence — the same bar every quest meets, just discharged by you inline
|
|
106
|
+
rather than by a dispatched worker.
|
|
107
|
+
|
|
108
|
+
## Reopening completed work
|
|
109
|
+
|
|
110
|
+
When review (or reality) finds a defect in a quest you already marked
|
|
111
|
+
**complete**, never hand-edit the status line and never redispatch a worker onto
|
|
112
|
+
the terminal record — `quest-run` early-exits on a complete quest and journals a
|
|
113
|
+
0-session no-op. Instead reopen it:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
quest reopen <id> --reason "review found npm audit criticals after completion"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This flips `complete → in_progress` and appends an audited checkpoint carrying
|
|
120
|
+
`reopen_reason`, so the loop keeps custody of the defect trail. Then dispatch the
|
|
121
|
+
quest directly by id (`quest-run <id>` or a `quest-executor` subagent) — reopened
|
|
122
|
+
quests are `in_progress`, so they do **not** re-appear in `quest list --ready`.
|
|
123
|
+
Reopening a child of a **complete** parent epic is allowed (a stderr warning, not
|
|
124
|
+
a block); you then rule whether the epic's completion verdict is falsified and,
|
|
125
|
+
if so, reopen the epic too. `cancelled` is fully terminal — file a new quest.
|
|
51
126
|
|
|
52
127
|
## Autonomous waves
|
|
53
128
|
|
|
54
129
|
For an unattended wave, pin your own session to the outcome with a native goal:
|
|
55
130
|
|
|
56
131
|
```
|
|
57
|
-
|
|
132
|
+
every quest in this wave shows complete, blocked, or cancelled in `quest list --json` output
|
|
58
133
|
```
|
|
59
134
|
|
|
60
|
-
|
|
135
|
+
In Codex, use the native goal tool when available; in Claude Code, use
|
|
136
|
+
`/goal <condition>`. The harness then keeps you cycling until the wave is
|
|
137
|
+
genuinely done.
|
|
61
138
|
|
|
62
139
|
## Worked example
|
|
63
140
|
|
|
64
141
|
```bash
|
|
65
142
|
quest list --ready --json # → [{"id":12,"worker":"claude"…},{"id":13,"worker":"codex"…}]
|
|
66
|
-
# 12 →
|
|
67
|
-
# 13 →
|
|
143
|
+
# 12 → spawn quest-executor with a child /goal or create_goal prompt
|
|
144
|
+
# 13 → spawn quest-executor too; use quest-run only if native subagents are unavailable
|
|
68
145
|
# …executor stops →
|
|
69
146
|
quest show 12 --json # new checkpoint? quest_status? evidence?
|
|
70
147
|
# reviewer on 12's diff → findings dispositioned → accept
|
|
71
148
|
```
|
|
72
149
|
|
|
73
|
-
**Next:** contracts weak?
|
|
74
|
-
|
|
150
|
+
**Next:** contracts weak? `$quest:plan` to fix them first. Wave finished?
|
|
151
|
+
`$quest:retro`. Vocabulary and stop rules: `$quest:protocol`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "Orchestrate Quests"
|
|
3
3
|
short_description: "Dispatch workers on ready quests, verify checkpoints, rule on evidence"
|
|
4
|
-
default_prompt: "Use $orchestrate to drive the ready quests to completion."
|
|
4
|
+
default_prompt: "Use $quest:orchestrate to drive the ready quests to completion."
|
package/skills/plan/SKILL.md
CHANGED
|
@@ -22,10 +22,41 @@ extra context.
|
|
|
22
22
|
| Medium | 1 quest, dispatch an executor | needs iterations, fits one Objective |
|
|
23
23
|
| Large | epic parent + child quests in waves | multiple objectives; order via `depends_on` |
|
|
24
24
|
|
|
25
|
+
## Plan Mode handoff
|
|
26
|
+
|
|
27
|
+
In Codex Plan Mode, do **not** implement product code after the user accepts a
|
|
28
|
+
plan. Your role is to make the quest records real, then ask before orchestration:
|
|
29
|
+
|
|
30
|
+
1. Create or confirm the quest records with `quest create` and `quest lint`.
|
|
31
|
+
2. If the user accepted a plan and asked to implement it, your next role is
|
|
32
|
+
`$quest:orchestrate`, not `$quest:work`. Do not start editing product code in
|
|
33
|
+
the parent session.
|
|
34
|
+
3. If the user only asked to create quests, ask whether to enter
|
|
35
|
+
`$quest:orchestrate` and dispatch the ready quests now. Do not silently
|
|
36
|
+
switch modes.
|
|
37
|
+
4. In `$quest:orchestrate`, set the orchestrator goal for the wave, then spawn
|
|
38
|
+
goal-mode workers. If the user declines orchestration, stop after listing the
|
|
39
|
+
ready quest ids and validation commands.
|
|
40
|
+
|
|
41
|
+
The parent session owns dispatch, checkpoint verification, reviewer rulings, and
|
|
42
|
+
epic closure. The spawned executor owns implementation for exactly one quest.
|
|
43
|
+
The parent session never uses `$quest:work <id>` after a Plan Mode handoff
|
|
44
|
+
unless the user explicitly asks to bypass orchestration for a genuinely small
|
|
45
|
+
single quest.
|
|
46
|
+
|
|
25
47
|
For epics: create the parent first, then children with `--parent <id>` and
|
|
26
48
|
`--depends-on` expressing the real order. `quest list --ready` becomes the
|
|
27
49
|
dispatch queue — that is the whole wave mechanic.
|
|
28
50
|
|
|
51
|
+
Keep the **epic itself thin**. Its Done-when is **integration-level only**: it
|
|
52
|
+
checks that the children compose into a working whole (the end-to-end behavior,
|
|
53
|
+
the parity gate, the shipped docs), never a restatement of each child's
|
|
54
|
+
Done-when. Its milestones must **not mirror the children 1:1** — the children
|
|
55
|
+
*are* the decomposition, so re-listing them in the epic body earns no worker and
|
|
56
|
+
wastes review. Give the epic an objective, integration Done-when, and the
|
|
57
|
+
validation loop the orchestrator runs to close it inline (it is never
|
|
58
|
+
dispatched — see `$quest:orchestrate` "Closing an epic").
|
|
59
|
+
|
|
29
60
|
## Author the contract
|
|
30
61
|
|
|
31
62
|
Every field earns its place:
|
|
@@ -42,8 +73,12 @@ Every field earns its place:
|
|
|
42
73
|
- **Context**: files + symbols (`resolveConfig` in `lib/config.mjs`), related
|
|
43
74
|
quests. NEVER bare line numbers — they rot.
|
|
44
75
|
- **Out of scope**: the adjacent work you are explicitly not doing.
|
|
45
|
-
- `--worker
|
|
46
|
-
|
|
76
|
+
- `--worker`, `--model`, and `--effort`: always specify all three explicitly in
|
|
77
|
+
every `quest create` command you author. Pick them deliberately from the task's
|
|
78
|
+
risk, ambiguity, context size, and validation cost so the worker starts with
|
|
79
|
+
an intentional execution profile instead of inheriting blindly from defaults.
|
|
80
|
+
- `--max-iterations`: match the loop budget to the difficulty; defaults in
|
|
81
|
+
`.quests/config.json` are fallback behavior, not a planning substitute.
|
|
47
82
|
|
|
48
83
|
**Anti-patterns** (lint catches some, you catch the rest): adjective done-whens
|
|
49
84
|
("fast", "clean"); validation loops that are prose, not commands; objectives
|
|
@@ -52,7 +87,8 @@ hiding three objectives; context by line number; budgets so big they never bind.
|
|
|
52
87
|
## Worked example
|
|
53
88
|
|
|
54
89
|
```bash
|
|
55
|
-
quest create --
|
|
90
|
+
quest create --worker codex --model gpt-5.5 --effort medium --max-iterations 4 \
|
|
91
|
+
--title "Add dark mode to settings" \
|
|
56
92
|
--objective "The settings page offers a dark theme that persists across reloads." \
|
|
57
93
|
--done-when "toggling theme switches the UI and survives reload" \
|
|
58
94
|
--done-when "\`npm test\` passes including new theme tests" \
|
|
@@ -65,5 +101,6 @@ quest create --title "Add dark mode to settings" \
|
|
|
65
101
|
quest lint 12 # always, before dispatch
|
|
66
102
|
```
|
|
67
103
|
|
|
68
|
-
**Next:** dispatch with
|
|
69
|
-
|
|
104
|
+
**Next:** dispatch with `$quest:orchestrate`. Only work it yourself via
|
|
105
|
+
`$quest:work <id>` for genuinely small inline work outside Plan Mode and outside
|
|
106
|
+
an accepted Plan Mode handoff. Rules and vocabulary: `$quest:protocol`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "Plan Quests"
|
|
3
3
|
short_description: "Turn an ask into evidence-checkable quest contracts"
|
|
4
|
-
default_prompt: "Use $plan to decompose this ask into quest contracts via `quest create`."
|
|
4
|
+
default_prompt: "Use $quest:plan to decompose this ask into quest contracts via `quest create`."
|