quest-loop 0.3.2 → 0.3.4

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 CHANGED
@@ -6,6 +6,25 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## [0.3.4] — 2026-07-08
10
+
11
+ ### Changed
12
+ - `quest codex install-agents` and `quest claude install-agents` now allow
13
+ `--force` to write through symlinked agent template files, while continuing to
14
+ refuse symlinked provider agent directories.
15
+
16
+ ### Added
17
+ - Added a Quest plan-exit reminder hook that nudges accepted `$quest:plan`
18
+ handoffs toward `$quest:orchestrate` rather than direct implementation.
19
+
20
+ ## [0.3.3] — 2026-07-08
21
+
22
+ ### Changed
23
+ - `$quest:plan` now makes the planning parent session adopt
24
+ `$quest:orchestrate` by default after creating and linting quests, stopping
25
+ before dispatch only when the user explicitly asks for create-only or
26
+ no-dispatch behavior.
27
+
9
28
  ## [0.3.2] — 2026-07-08
10
29
 
11
30
  ### Added
package/README.md CHANGED
@@ -22,8 +22,8 @@ actually read.
22
22
  ```
23
23
  you: $quest:plan add dark mode to the settings page
24
24
  agent: creates quest 12 — Objective, Done-when, Validation loop… (quest create)
25
- you: $quest:orchestrate
26
- agent: dispatches a worker on quest 12; it iterates: milestone → validate →
25
+ agent: becomes $quest:orchestrate and dispatches a worker on quest 12:
26
+ milestone → validate →
27
27
  commit → checkpoint. You review evidence, not vibes.
28
28
  ```
29
29
 
@@ -151,6 +151,10 @@ existing project agent template would be replaced, init fails before creating
151
151
  command with `--force` only if you intend to replace them, then rerun
152
152
  `quest init`.
153
153
 
154
+ Agent template files may be symlinks; when you use `--force`, the install writes
155
+ through those file symlinks. Keep `.codex/agents` and `.claude/agents` as real
156
+ directories rather than symlinking the whole directory.
157
+
154
158
  Project-scoped agent templates install at the Git repository root. For a nested
155
159
  quest store, run `quest init --no-agents` in the nested directory and set
156
160
  `QUEST_DIR` for agents launched from elsewhere, or initialize from the repo root.
@@ -187,11 +191,11 @@ required:
187
191
  quest-run 12 --worker codex --codex-goal-mode require
188
192
  ```
189
193
 
190
- When a `$quest:plan` result is accepted from Plan Mode, the parent agent should
191
- stay the orchestrator: create/lint quest records if needed, set the wave goal,
192
- spawn subagents, verify checkpoints, and rule on reviewer findings. Product
193
- implementation belongs to the spawned executor for each quest, not to the parent
194
- session via `$quest:work`.
194
+ When a `$quest:plan` result is accepted from Plan Mode, the parent agent
195
+ automatically stays the orchestrator: create/lint quest records if needed, set
196
+ the wave goal, spawn subagents, verify checkpoints, and rule on reviewer
197
+ findings. Product implementation belongs to the spawned executor for each quest,
198
+ not to the parent session via `$quest:work`.
195
199
 
196
200
  Each iteration ends by recording evidence — a checkpoint a fresh session can
197
201
  resume from:
package/hooks/hooks.json CHANGED
@@ -62,6 +62,33 @@
62
62
  }
63
63
  ]
64
64
  }
65
+ ],
66
+ "PostToolUse": [
67
+ {
68
+ "matcher": "ExitPlanMode",
69
+ "hooks": [
70
+ {
71
+ "type": "command",
72
+ "command": "root=\"${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}\"; [ -n \"$root\" ] && node \"$root/hooks/plan-exit-reminder.mjs\"",
73
+ "commandWindows": "if defined CODEX_PLUGIN_ROOT (node \"%CODEX_PLUGIN_ROOT%\\hooks\\plan-exit-reminder.mjs\") else if defined CLAUDE_PLUGIN_ROOT (node \"%CLAUDE_PLUGIN_ROOT%\\hooks\\plan-exit-reminder.mjs\")",
74
+ "timeout": 10,
75
+ "statusMessage": "Preparing quest orchestration reminder"
76
+ }
77
+ ]
78
+ }
79
+ ],
80
+ "Stop": [
81
+ {
82
+ "hooks": [
83
+ {
84
+ "type": "command",
85
+ "command": "root=\"${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}\"; [ -n \"$root\" ] && node \"$root/hooks/plan-exit-reminder.mjs\"",
86
+ "commandWindows": "if defined CODEX_PLUGIN_ROOT (node \"%CODEX_PLUGIN_ROOT%\\hooks\\plan-exit-reminder.mjs\") else if defined CLAUDE_PLUGIN_ROOT (node \"%CLAUDE_PLUGIN_ROOT%\\hooks\\plan-exit-reminder.mjs\")",
87
+ "timeout": 10,
88
+ "statusMessage": "Checking quest plan handoff"
89
+ }
90
+ ]
91
+ }
65
92
  ]
66
93
  }
67
94
  }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // Plan-exit reminder hook. When a Quest-generated plan handoff is accepted,
3
+ // remind the parent session that the next run is orchestration, not direct
4
+ // implementation. Anything uncertain is a silent no-op.
5
+
6
+ import { writeSync } from "node:fs";
7
+
8
+ const REMINDER = [
9
+ "Quest plan handoff accepted.",
10
+ "This run is about orchestration, not direct implementation:",
11
+ "create or confirm the quest records, run `quest lint`, then switch to `$quest:orchestrate` to dispatch workers, verify checkpoints, and close the wave.",
12
+ ].join(" ");
13
+
14
+ async function readStdin() {
15
+ const chunks = [];
16
+ for await (const chunk of process.stdin) chunks.push(chunk);
17
+ return Buffer.concat(chunks).toString("utf8");
18
+ }
19
+
20
+ function hookEventName(payload) {
21
+ return payload?.hook_event_name ?? payload?.hookEventName ?? "";
22
+ }
23
+
24
+ function toolName(payload) {
25
+ return payload?.tool_name ?? payload?.toolName ?? payload?.tool?.name ?? "";
26
+ }
27
+
28
+ function lastAssistantMessage(payload) {
29
+ const value = payload?.last_assistant_message ?? payload?.lastAssistantMessage;
30
+ if (typeof value === "string") return value;
31
+ if (value && typeof value === "object") return JSON.stringify(value);
32
+ return "";
33
+ }
34
+
35
+ function questPlanHandoff(text) {
36
+ if (!text) return false;
37
+ const hasQuestPlan = /(?:\$?quest:plan\b|\bquest create\b|\bquest lint\b|<proposed_plan>)/i.test(text);
38
+ const hasOrchestrate = /(?:\$?quest:orchestrate\b|\borchestration\b|\borchestrator\b)/i.test(text);
39
+ return hasQuestPlan && hasOrchestrate;
40
+ }
41
+
42
+ function postToolUseText(payload) {
43
+ const parts = [];
44
+ for (const key of ["tool_input", "toolInput", "tool_response", "toolResponse"]) {
45
+ const value = payload?.[key];
46
+ if (typeof value === "string") parts.push(value);
47
+ else if (value && typeof value === "object") parts.push(JSON.stringify(value));
48
+ }
49
+ return parts.join("\n");
50
+ }
51
+
52
+ function shouldRemind(payload) {
53
+ const event = hookEventName(payload);
54
+ if (event === "PostToolUse" && toolName(payload) === "ExitPlanMode") {
55
+ return questPlanHandoff(postToolUseText(payload));
56
+ }
57
+ if (event === "Stop") {
58
+ return questPlanHandoff(lastAssistantMessage(payload));
59
+ }
60
+ return false;
61
+ }
62
+
63
+ try {
64
+ const raw = await readStdin();
65
+ let payload = {};
66
+ try { payload = raw.trim() ? JSON.parse(raw) : {}; } catch { payload = {}; }
67
+ if (shouldRemind(payload)) writeSync(1, JSON.stringify({ systemMessage: REMINDER }) + "\n");
68
+ process.exit(0);
69
+ } catch {
70
+ process.exit(0);
71
+ }
package/lib/cli.mjs CHANGED
@@ -136,9 +136,9 @@ async function runNativeSetupCommand(command, args, { out, cwd, env }, { label,
136
136
  // A real run refuses to overwrite; a --dry-run PREVIEWS the conflict
137
137
  // (its whole purpose) rather than erroring before showing the plan.
138
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 });
139
+ const symlinkDirConflicts = result.conflicts.filter((c) => c.reason === "symlink-directory");
140
+ if (symlinkDirConflicts.length) {
141
+ throw new UsageError(`refusing to write agent templates through symlinked directory: ${symlinkDirConflicts.map((c) => c.path).join(", ")}`, { command });
142
142
  }
143
143
  const conflicts = result.conflicts.map((c) => c.path).join(", ");
144
144
  throw new UsageError(`agent file already exists; pass --force to replace: ${conflicts}`, { command });
@@ -147,7 +147,12 @@ async function runNativeSetupCommand(command, args, { out, cwd, env }, { label,
147
147
  else {
148
148
  out(`${isDry ? "Would install" : "Installed"} ${label} agents to ${result.target}`);
149
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(", ")}`);
150
+ if (!result.ok) {
151
+ const symlinkDirConflicts = result.conflicts.filter((c) => c.reason === "symlink-directory");
152
+ if (symlinkDirConflicts.length) out(` symlinked directories must be real directories: ${symlinkDirConflicts.map((c) => c.path).join(", ")}`);
153
+ const fileConflicts = result.conflicts.filter((c) => c.reason !== "symlink-directory");
154
+ if (fileConflicts.length) out(` conflicts present — pass --force to replace: ${fileConflicts.map((c) => c.path).join(", ")}`);
155
+ }
151
156
  }
152
157
  return 0;
153
158
  }
@@ -82,7 +82,6 @@ function installedAgentDirs(provider, cwd, env) {
82
82
 
83
83
  function sameFile(path, text) {
84
84
  try {
85
- if (lstatSync(path).isSymbolicLink()) return false;
86
85
  return readFileSync(path, "utf8") === text;
87
86
  } catch {
88
87
  return false;
@@ -166,8 +165,8 @@ function installProviderAgents(provider, { scope = "project", dryRun = false, fo
166
165
  const dir = targetDir(provider, scope, cwd, env);
167
166
  const dirConflicts = [dirname(dir), dir]
168
167
  .filter((path, index, all) => all.indexOf(path) === index)
169
- .filter((path) => existsSync(path) && isSymlink(path))
170
- .map((path) => ({ name: null, path, reason: "symlink" }));
168
+ .filter((path) => isSymlink(path))
169
+ .map((path) => ({ name: null, path, reason: "symlink-directory" }));
171
170
 
172
171
  // Plan every agent first; only touch disk once the whole set is known to be
173
172
  // conflict-free. A mixed create+conflict run must not leave a partial install.
@@ -175,29 +174,25 @@ function installProviderAgents(provider, { scope = "project", dryRun = false, fo
175
174
  const src = join(PLUGIN_ROOT, "agents", `${name}.${cfg.extension}`);
176
175
  const dest = join(dir, `${name}.${cfg.extension}`);
177
176
  const text = readFileSync(src, "utf8");
178
- const exists = existsSync(dest);
179
- const symlink = exists && isSymlink(dest);
177
+ const exists = existsSync(dest) || isSymlink(dest);
180
178
  let action;
181
- if (symlink) action = "conflict";
182
- else if (exists && sameFile(dest, text)) action = "unchanged";
179
+ if (exists && sameFile(dest, text)) action = "unchanged";
183
180
  else if (exists && !force) action = "conflict";
184
181
  else action = exists ? "replace" : "create";
185
- return { name, path: dest, action, text, reason: symlink ? "symlink" : undefined };
182
+ return { name, path: dest, action, text };
186
183
  });
187
184
 
188
185
  const conflicts = [
189
186
  ...dirConflicts,
190
- ...plans.filter((p) => p.action === "conflict").map(({ name, path, reason }) => ({ name, path, ...(reason ? { reason } : {}) })),
187
+ ...plans.filter((p) => p.action === "conflict").map(({ name, path }) => ({ name, path })),
191
188
  ];
192
189
  const actions = plans.map(({ name, path, action }) => ({ name, path, action }));
193
190
  const ok = conflicts.length === 0;
194
191
 
195
192
  if (ok && !dryRun) {
196
- if (dirConflicts.length) throw new Error(`refusing to write agent templates through symlinked path: ${dirConflicts.map((c) => c.path).join(", ")}`);
197
193
  for (const p of plans) {
198
194
  if (p.action === "unchanged") continue;
199
195
  mkdirSync(dir, { recursive: true });
200
- if (isSymlink(dirname(p.path)) || isSymlink(p.path)) throw new Error(`refusing to write agent template through symlinked path: ${p.path}`);
201
196
  writeFileSync(p.path, p.text);
202
197
  }
203
198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quest-loop",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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" },
@@ -25,18 +25,19 @@ extra context.
25
25
  ## Plan Mode handoff
26
26
 
27
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:
28
+ plan. The same parent agent/session that processes `$quest:plan` owns the
29
+ handoff: make the quest records real, then adopt `$quest:orchestrate` by
30
+ default.
29
31
 
30
32
  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.
33
+ 2. If the user accepted a plan and asked to implement it, the same parent
34
+ session becomes `$quest:orchestrate`, not `$quest:work`. Do not start editing
35
+ product code in the parent session.
36
+ 3. In `$quest:orchestrate`, set the orchestrator goal for the wave, then spawn
37
+ goal-mode workers.
38
+ 4. Stop after listing the ready quest ids and validation commands only when the
39
+ user explicitly asked for create-only/no-dispatch behavior such as "only
40
+ create quests", "do not dispatch", or "stop after planning".
40
41
 
41
42
  The parent session owns dispatch, checkpoint verification, reviewer rulings, and
42
43
  epic closure. The spawned executor owns implementation for exactly one quest.
@@ -71,7 +71,8 @@ quest claude install-agents --scope user
71
71
 
72
72
  If an existing file conflicts, inspect it first. Use `--force` only when you
73
73
  intend to replace that custom agent with Quest's bundled definition. Symlinked
74
- agent directories or files are refused rather than overwritten.
74
+ agent template files can be written through with `--force`; keep the provider
75
+ agent directories themselves as real directories.
75
76
 
76
77
  ## Update Installed Plugin
77
78