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 +19 -0
- package/README.md +11 -7
- package/hooks/hooks.json +27 -0
- package/hooks/plan-exit-reminder.mjs +71 -0
- package/lib/cli.mjs +9 -4
- package/lib/codex-native.mjs +6 -11
- package/package.json +1 -1
- package/skills/plan/SKILL.md +11 -10
- package/skills/setup/SKILL.md +2 -1
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
|
-
|
|
26
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
spawn subagents, verify checkpoints, and rule on reviewer
|
|
193
|
-
implementation belongs to the spawned executor for each quest,
|
|
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
|
|
140
|
-
if (
|
|
141
|
-
throw new UsageError(`refusing to write agent templates through symlinked
|
|
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)
|
|
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
|
}
|
package/lib/codex-native.mjs
CHANGED
|
@@ -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) =>
|
|
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 (
|
|
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
|
|
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
|
|
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
|
+
"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" },
|
package/skills/plan/SKILL.md
CHANGED
|
@@ -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.
|
|
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,
|
|
32
|
-
`$quest:orchestrate`, not `$quest:work`. Do not start editing
|
|
33
|
-
the parent session.
|
|
34
|
-
3.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|