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.
@@ -3,12 +3,24 @@
3
3
  // recording a checkpoint — the protocol's "no stop without a checkpoint" rule,
4
4
  // enforced deterministically.
5
5
  //
6
- // A subagent counts as a quest-executor iff its transcript contains a
7
- // `quest show <id> --json` invocation (the mandatory orientation marker). We take
8
- // the FIRST id. No marker not our concern, allow silently. We then compare the
9
- // quest's latest checkpoint against the subagent's start time (the transcript's
10
- // first timestamp): a checkpoint newer than start clears the stop; so does a
11
- // terminal store status (complete/blocked/cancelled) reached during the run.
6
+ // A subagent counts as a quest-executor iff one of its transcript entries records
7
+ // an actual *mutating* quest invocation `quest start <id>` or `quest checkpoint
8
+ // <id>` (under any binary prefix: `quest`, `./bin/quest`, `node bin/quest`) as a
9
+ // tool_use block whose shell command carries the marker. Read-only verbs
10
+ // (`quest show <id> --json`, `list`, `protocol`, `runs`) are how reviewers and
11
+ // orchestrators inspect a quest without owning it, so they must NEVER key
12
+ // detection: an agent whose transcript holds only read verbs is allowed silently.
13
+ // The executor id comes from that first mutating invocation (deterministic,
14
+ // transcript order) — including `quest checkpoint <id>`, because an executor
15
+ // resuming an already-in_progress quest skips `quest start` and its first mutating
16
+ // verb is the checkpoint. We parse the JSONL per-entry and inspect only tool_use
17
+ // command inputs; prose, quoted skill text, examples, and echoed file contents live
18
+ // in text blocks and tool_result content (never in a tool_use command), so
19
+ // `quest start 12` / `quest checkpoint 12` examples in the skills can no longer key
20
+ // detection. No mutating invocation → not our concern, allow silently. We then
21
+ // compare the quest's latest checkpoint against the subagent's start time (the
22
+ // transcript's first timestamp): a checkpoint newer than start clears the stop; so
23
+ // does a terminal store status (complete/blocked/cancelled) reached during the run.
12
24
  //
13
25
  // Conservative by construction: any missing/unreadable input or parse failure →
14
26
  // allow (exit 0), with a one-line stderr diagnostic. We never false-positive-block
@@ -22,7 +34,73 @@ import { readFileSync, writeSync } from "node:fs";
22
34
  import { findStoreDir } from "../lib/config.mjs";
23
35
  import { loadQuest } from "../lib/store-local.mjs";
24
36
 
25
- const MARKER = /quest\s+show\s+(\d+)\s+--json/;
37
+ // Mutating quest verbs only. Group 1 is the verb, group 2 the id. The marker is
38
+ // anchored to a COMMAND position — an optional `node ` launcher and/or path prefix
39
+ // then `quest <verb> <id>` at the head of a command — so `quest checkpoint 1`
40
+ // merely quoted inside another program's argument (e.g. `grep 'quest checkpoint 1'`,
41
+ // `git commit -m "quest checkpoint 1"`) does not falsely key executor detection.
42
+ // Read verbs (show/list/protocol/runs) are deliberately absent.
43
+ const MARKER = /^(?:node\s+)?(?:[.\w/@-]*\/)?quest\s+(start|checkpoint)\s+(\d+)/;
44
+
45
+ // Split a shell command into top-level segments on `&&`, `||`, `;`, `|`, and
46
+ // newline, IGNORING any separator that sits inside single or double quotes. This
47
+ // keeps quoted text (grep patterns, commit messages) from forging a command head.
48
+ // (Heredoc bodies on their own line remain a rare residual; a false "checkpoint"
49
+ // nudge is benign, and heredoc parsing is not worth the complexity in a hook.)
50
+ function splitTopLevel(s) {
51
+ const segs = [];
52
+ let buf = "";
53
+ let quote = null;
54
+ let escaped = false;
55
+ for (let i = 0; i < s.length; i++) {
56
+ const c = s[i];
57
+ if (escaped) {
58
+ buf += c;
59
+ escaped = false;
60
+ continue;
61
+ }
62
+ if (quote !== "'" && c === "\\") {
63
+ buf += c;
64
+ escaped = true;
65
+ continue;
66
+ }
67
+ if (quote) {
68
+ if (c === quote) quote = null;
69
+ buf += c;
70
+ continue;
71
+ }
72
+ if (c === "'" || c === '"') { quote = c; buf += c; continue; }
73
+ if ((c === "&" && s[i + 1] === "&") || (c === "|" && s[i + 1] === "|")) { segs.push(buf); buf = ""; i++; continue; }
74
+ if (c === ";" || c === "|" || c === "\n") { segs.push(buf); buf = ""; continue; }
75
+ buf += c;
76
+ }
77
+ segs.push(buf);
78
+ return segs;
79
+ }
80
+
81
+ // Strip a leading shell wrapper (`bash -lc '…'`, `sh -c "…"`) and split the command
82
+ // chain, then look for a quest invocation at the head of any segment. Returns the
83
+ // marker id or null.
84
+ function markerIdInCommand(cmd) {
85
+ if (typeof cmd !== "string") return null;
86
+ let s = cmd.trim();
87
+ const wrap = s.match(/^(?:sudo\s+)?(?:ba)?sh\s+-l?c\s+(['"])([\s\S]*)\1\s*$/);
88
+ if (wrap) s = wrap[2].trim();
89
+ for (const seg of splitTopLevel(s)) {
90
+ const m = MARKER.exec(seg.trim());
91
+ if (m) return Number(m[2]); // m[1] = verb, m[2] = id
92
+ }
93
+ return null;
94
+ }
95
+
96
+ // A Codex `command_execution` item's shell command, across the JSONL envelope
97
+ // shapes we know: top-level `item`, legacy `msg` (the item sits directly at
98
+ // `entry.msg`), and `msg.item`. Returns the command string or null.
99
+ function commandExecutionCommand(node) {
100
+ if (!node || typeof node !== "object") return null;
101
+ if (node.type === "command_execution" && typeof node.command === "string") return node.command;
102
+ return null;
103
+ }
26
104
  const TERMINAL = ["complete", "blocked", "cancelled"];
27
105
 
28
106
  async function readStdin() {
@@ -57,6 +135,59 @@ function firstTimestamp(text) {
57
135
  return null;
58
136
  }
59
137
 
138
+ // A tool_use block's shell command, or null. Bash invocations carry the command
139
+ // under `input.command`; only that field is a real command invocation. We never
140
+ // scan other input fields (e.g. an Edit's new_string or a Read's file_path), which
141
+ // could echo skill text and re-introduce the false positive.
142
+ function commandOf(input) {
143
+ return input && typeof input === "object" && typeof input.command === "string" ? input.command : null;
144
+ }
145
+
146
+ // The mutating-verb marker id from one transcript entry, considering only tool_use
147
+ // command invocations. Assistant messages carry an array of content blocks; string
148
+ // content (plain prose) and tool_result blocks (echoed output/file contents) are
149
+ // ignored. Matches `quest start <id>` or `quest checkpoint <id>` and returns the id.
150
+ function markerIdInEntry(entry) {
151
+ if (!entry || typeof entry !== "object") return null;
152
+
153
+ // Codex JSONL: a `command_execution` item under `item`, the legacy `msg`, or
154
+ // `msg.item`. Handling all three keeps executor detection robust across shapes.
155
+ for (const cand of [entry.item, entry.msg, entry.msg?.item]) {
156
+ const cmd = commandExecutionCommand(cand);
157
+ if (cmd != null) {
158
+ const id = markerIdInCommand(cmd);
159
+ if (id != null) return id;
160
+ }
161
+ }
162
+
163
+ // Claude assistant messages: tool_use blocks carrying a shell command. String
164
+ // content (plain prose) and tool_result blocks are ignored.
165
+ const msg = entry.message;
166
+ const content = msg && typeof msg === "object" ? msg.content : null;
167
+ if (!Array.isArray(content)) return null; // string content is prose, never an invocation
168
+ for (const block of content) {
169
+ if (!block || typeof block !== "object" || block.type !== "tool_use") continue;
170
+ const id = markerIdInCommand(commandOf(block.input));
171
+ if (id != null) return id;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ // The executor's quest id = the FIRST mutating invocation (`quest start <id>` or
177
+ // `quest checkpoint <id>`) that appears as a real command invocation, scanning
178
+ // entries in transcript order. Returns id or null. Per-entry parsing is what keeps
179
+ // skill-text examples from keying detection; read verbs never match at all.
180
+ function executorQuestId(text) {
181
+ for (const line of text.split("\n")) {
182
+ if (!line.trim()) continue;
183
+ let entry;
184
+ try { entry = JSON.parse(line); } catch { continue; }
185
+ const id = markerIdInEntry(entry);
186
+ if (id != null) return id;
187
+ }
188
+ return null;
189
+ }
190
+
60
191
  try {
61
192
  const raw = await readStdin();
62
193
  let payload = {};
@@ -69,9 +200,8 @@ try {
69
200
  try { transcript = readFileSync(transcriptPath, "utf8"); }
70
201
  catch (err) { diag(`cannot read transcript (${err.code || err.message}); allowing`); allow(); }
71
202
 
72
- const m = MARKER.exec(transcript);
73
- if (!m) allow(); // not a quest-executor subagent — leave it entirely alone, silently
74
- const id = Number(m[1]);
203
+ const id = executorQuestId(transcript);
204
+ if (id == null) allow(); // no mutating quest invocation (read-only agent) — leave it alone, silently
75
205
 
76
206
  // Prefer an explicit start field if a future payload carries one; else the
77
207
  // transcript's first timestamp.
package/lib/cli.mjs CHANGED
@@ -11,6 +11,7 @@ import * as local from "./store-local.mjs";
11
11
  import * as github from "./store-github.mjs";
12
12
  import { openStore } from "./store.mjs";
13
13
  import { COMMANDS, renderCommandHelp, renderGeneralHelp, renderInitNextSteps, renderNoStore, renderStatusOverview } from "./help.mjs";
14
+ import { claudeDoctor, doctor as codexDoctor, installAgents as installCodexAgents, installClaudeAgents, versionInfo } from "./codex-native.mjs";
14
15
 
15
16
  const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
16
17
 
@@ -73,10 +74,96 @@ function getStore(cwd, env) {
73
74
  return loadConfig(storeDir, env);
74
75
  }
75
76
 
77
+ function preflightInitAgentInstall(cwd, env) {
78
+ const plans = [
79
+ ["codex", installCodexAgents({ scope: "project", dryRun: true, cwd, env })],
80
+ ["claude", installClaudeAgents({ scope: "project", dryRun: true, cwd, env })],
81
+ ];
82
+ const conflicts = plans.flatMap(([provider, result]) => result.conflicts.map((c) => `${provider}:${c.path}`));
83
+ if (conflicts.length) {
84
+ throw new UsageError(
85
+ "native agent template conflict; inspect existing files or run " +
86
+ "`quest codex install-agents --scope project --force` / " +
87
+ "`quest claude install-agents --scope project --force` after resolving: " +
88
+ conflicts.join(", "),
89
+ { command: "init" },
90
+ );
91
+ }
92
+ return plans;
93
+ }
94
+
95
+ async function runNativeSetupCommand(command, args, { out, cwd, env }, { label, doctor, install }) {
96
+ const [subcommand, ...rest] = args;
97
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
98
+ out(renderCommandHelp(command));
99
+ return 0;
100
+ }
101
+ if (subcommand === "doctor") {
102
+ const p = parse(command, rest, {}, { positionals: 0 });
103
+ if (p.help) {
104
+ out(renderCommandHelp(command));
105
+ return 0;
106
+ }
107
+ const result = doctor({ cwd, env });
108
+ if (p.values.json) out(JSON.stringify(result));
109
+ else {
110
+ for (const c of result.checks) out(`${c.ok ? "OK " : "ERR"} ${c.name}: ${c.detail}`);
111
+ out(result.ok ? `${command} doctor: OK` : `${command} doctor: problems found`);
112
+ }
113
+ // 1 (generic diagnostic failure), not 5 — exit 5 is reserved for
114
+ // ContractError (a quest contract violation), which a doctor finding is not.
115
+ return result.ok ? 0 : 1;
116
+ }
117
+ if (subcommand === "install-agents") {
118
+ const p = parse(command, rest, {
119
+ scope: { type: "string", default: "project" },
120
+ "dry-run": { type: "boolean" },
121
+ force: { type: "boolean" },
122
+ }, { positionals: 0 });
123
+ if (p.help) {
124
+ out(renderCommandHelp(command));
125
+ return 0;
126
+ }
127
+ if (!["project", "user"].includes(p.values.scope)) throw new UsageError(`--scope must be project or user (got "${p.values.scope}")`, { command });
128
+ const isDry = Boolean(p.values["dry-run"]);
129
+ const result = install({
130
+ scope: p.values.scope,
131
+ dryRun: isDry,
132
+ force: Boolean(p.values.force),
133
+ cwd,
134
+ env,
135
+ });
136
+ // A real run refuses to overwrite; a --dry-run PREVIEWS the conflict
137
+ // (its whole purpose) rather than erroring before showing the plan.
138
+ if (!result.ok && !isDry) {
139
+ const symlinkConflicts = result.conflicts.filter((c) => c.reason === "symlink");
140
+ if (symlinkConflicts.length) {
141
+ throw new UsageError(`refusing to write agent templates through symlinked path: ${symlinkConflicts.map((c) => c.path).join(", ")}`, { command });
142
+ }
143
+ const conflicts = result.conflicts.map((c) => c.path).join(", ");
144
+ throw new UsageError(`agent file already exists; pass --force to replace: ${conflicts}`, { command });
145
+ }
146
+ if (p.values.json) out(JSON.stringify(result));
147
+ else {
148
+ out(`${isDry ? "Would install" : "Installed"} ${label} agents to ${result.target}`);
149
+ for (const a of result.actions) out(` ${a.action.padEnd(9)} ${a.path}`);
150
+ if (!result.ok) out(` conflicts present — pass --force to replace: ${result.conflicts.map((c) => c.path).join(", ")}`);
151
+ }
152
+ return 0;
153
+ }
154
+ throw new UsageError(`unknown ${command} subcommand "${subcommand}"`, { command });
155
+ }
156
+
76
157
  async function dispatch(argv, ctx) {
77
158
  const [command, ...rest] = argv;
78
159
  const { out, errOut, cwd, env } = ctx;
79
160
 
161
+ if (command === "--version" || command === "-V" || command === "version") {
162
+ const versions = versionInfo();
163
+ out(versions.package);
164
+ return 0;
165
+ }
166
+
80
167
  if (!command) {
81
168
  const storeDir = findStoreDir(cwd, env);
82
169
  if (!storeDir) {
@@ -114,11 +201,16 @@ const HANDLERS = {
114
201
  backend: { type: "string", default: "local" },
115
202
  repo: { type: "string" },
116
203
  "agents-md": { type: "boolean" },
204
+ "no-agents": { type: "boolean" },
117
205
  });
118
206
  if (p.help) return void out(renderCommandHelp("init")) ?? 0;
119
207
  const backend = p.values.backend;
120
208
  if (!["local", "github"].includes(backend)) throw new UsageError(`--backend must be local or github (got "${backend}")`, { command: "init" });
121
209
  if (backend === "github" && !p.values.repo) throw new UsageError("--repo owner/name is required with --backend github", { command: "init" });
210
+ if (existsSync(join(cwd, ".quests", "config.json"))) {
211
+ throw new ContractError(`a quest store already exists at ${join(cwd, ".quests")}`, { hint: "use the existing store, or delete it first if you really mean to start over" });
212
+ }
213
+ const agentPlans = p.values["no-agents"] ? [] : preflightInitAgentInstall(cwd, env);
122
214
  if (backend === "github") {
123
215
  github.assertAuth(env); // green `gh auth status` required
124
216
  github.ensureLabels(p.values.repo, env); // idempotent label creation
@@ -127,7 +219,7 @@ const HANDLERS = {
127
219
  const config = {
128
220
  backend,
129
221
  ...(p.values.repo ? { github: { repo: p.values.repo } } : {}),
130
- defaults: { worker: "claude", claude: { model: "opus", effort: "xhigh" }, codex: { model: "gpt-5.5", reasoning_effort: "medium" }, max_iterations: 8, priority: "p2" },
222
+ defaults: { worker: "claude", claude: { model: "opus", effort: "xhigh" }, codex: { model: "gpt-5.5", reasoning_effort: "medium", goal_mode: "auto" }, max_iterations: 8, priority: "p2" },
131
223
  notify: { command: "" },
132
224
  };
133
225
  writeFileSync(join(storeDir, "config.json"), JSON.stringify(config, null, 2) + "\n");
@@ -145,8 +237,15 @@ const HANDLERS = {
145
237
  ].join("\n");
146
238
  appendFileSync(join(cwd, "AGENTS.md"), section);
147
239
  }
240
+ for (const [provider] of agentPlans) {
241
+ const label = provider === "codex" ? "Codex" : "Claude";
242
+ const install = provider === "codex" ? installCodexAgents : installClaudeAgents;
243
+ const result = install({ scope: "project", cwd, env });
244
+ out(`Installed ${label} agents to ${result.target}`);
245
+ for (const a of result.actions) out(` ${a.action.padEnd(9)} ${a.path}`);
246
+ }
148
247
  out(`Initialized ${backend} quest store at ${storeDir}`);
149
- out(renderInitNextSteps(backend));
248
+ out(renderInitNextSteps(backend, { agentsInstalled: agentPlans.length > 0 }));
150
249
  return 0;
151
250
  },
152
251
 
@@ -309,6 +408,38 @@ const HANDLERS = {
309
408
  return 0;
310
409
  },
311
410
 
411
+ async reopen(args, { out, errOut, cwd, env }) {
412
+ const p = parse("reopen", args, { reason: { type: "string" } }, { positionals: 1 });
413
+ if (p.help) return void out(renderCommandHelp("reopen")) ?? 0;
414
+ const id = requireId(p, "reopen");
415
+ // --reason is validated in the store (a contract requirement, exit 5), not
416
+ // as a usage error — mirrors `cancel`. reopenQuest throws if it is missing.
417
+ const config = getStore(cwd, env);
418
+ const store = openStore(config, { env });
419
+ const q = store.reopenQuest(id, p.values.reason);
420
+ // Reopening a child of a *complete* parent epic is allowed, never blocked —
421
+ // but the epic's completion verdict may now be falsified, so warn the
422
+ // orchestrator on stderr (the epic-falsification cue). Best-effort: a
423
+ // missing/unloadable parent (or any lookup failure) must never fail an
424
+ // already-successful reopen. Runs for both backends via this one handler.
425
+ if (q.front.parent !== undefined) {
426
+ try {
427
+ const parent = store.loadQuest(q.front.parent);
428
+ if (parent.front.status === "complete") {
429
+ errOut(`quest: warning — quest ${id}'s parent epic ${q.front.parent} is complete; reopening this child may falsify the epic's completion verdict. Rule on it, and \`quest reopen ${q.front.parent} --reason "…"\` too if the integration gate no longer holds.`);
430
+ }
431
+ } catch {
432
+ /* best-effort: never fail the reopen on a parent-lookup problem */
433
+ }
434
+ }
435
+ if (p.values.json) out(JSON.stringify({ ok: true, id, status: q.front.status }));
436
+ else {
437
+ out(`Quest ${id} reopened — now in_progress (reason recorded in an audited checkpoint).`);
438
+ out(`Reopened quests are dispatched directly by id (\`quest-run ${id}\` or $quest:work ${id}); they do not re-enter \`quest list --ready\`.`);
439
+ }
440
+ return 0;
441
+ },
442
+
312
443
  async edit(args, { out, cwd, env }) {
313
444
  const p = parse("edit", args, {
314
445
  "add-done-when": { type: "string", multiple: true },
@@ -398,4 +529,12 @@ const HANDLERS = {
398
529
  else for (const r of runs) out(`${r.run_id} quest ${r.quest} ${r.worker} ${r.ended ? `ended ${r.final_status}` : "ACTIVE"} iters:${r.iterations} $${r.cost_usd.toFixed(2)}`);
399
530
  return 0;
400
531
  },
532
+
533
+ async codex(args, { out, cwd, env }) {
534
+ return runNativeSetupCommand("codex", args, { out, cwd, env }, { label: "Codex", doctor: codexDoctor, install: installCodexAgents });
535
+ },
536
+
537
+ async claude(args, { out, cwd, env }) {
538
+ return runNativeSetupCommand("claude", args, { out, cwd, env }, { label: "Claude", doctor: claudeDoctor, install: installClaudeAgents });
539
+ },
401
540
  };