quest-loop 0.3.3 → 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,17 @@ 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
+
9
20
  ## [0.3.3] — 2026-07-08
10
21
 
11
22
  ### Changed
package/README.md CHANGED
@@ -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.
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.3",
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" },
@@ -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