@jabworks/condux 0.1.1 → 0.3.0
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/README.md +35 -18
- package/agents/explorer.md +1 -0
- package/agents/planner.md +1 -0
- package/agents/researcher.md +1 -0
- package/index.js +34 -3
- package/package.json +6 -4
- package/skills/code-review/SKILL.md +136 -0
- package/skills/code-review/evals/trigger_eval.json +22 -0
- package/skills/discovery/SKILL.md +242 -0
- package/skills/discovery/evals/trigger_eval.json +24 -0
- package/skills/discovery/references/choice-server.js +216 -0
- package/skills/discovery/references/mockup-picker.md +90 -0
- package/skills/discovery/references/package.json +3 -0
- package/skills/draft-plan/SKILL.md +229 -0
- package/skills/draft-plan/evals/trigger_eval.json +22 -0
- package/skills/draft-plan/references/plan-template.md +96 -0
- package/skills/finalize/SKILL.md +123 -0
- package/skills/finalize/evals/trigger_eval.json +22 -0
- package/skills/finalize/references/rationalization-table.md +15 -0
- package/skills/plan-review/SKILL.md +215 -0
- package/skills/plan-review/evals/trigger_eval.json +22 -0
- package/skills/plan-review/references/annotate-server.js +465 -0
- package/skills/plan-review/references/install-codex-hook.sh +112 -0
- package/skills/plan-review/references/package.json +3 -0
- package/skills/plan-review/references/plan-review-template.html +730 -0
- package/skills/preflight/SKILL.md +128 -0
- package/skills/preflight/evals/trigger_eval.json +26 -0
- package/skills/root-cause-analysis/SKILL.md +82 -0
- package/skills/root-cause-analysis/evals/trigger_eval.json +22 -0
- package/skills/subagent-deployment/SKILL.md +105 -0
- package/skills/subagent-deployment/evals/trigger_eval.json +22 -0
- package/skills/subagent-deployment/references/safety-checklist.md +54 -0
- package/skills/subagent-execution/SKILL.md +182 -0
- package/skills/subagent-execution/agents/coder.md +55 -0
- package/skills/subagent-execution/agents/explorer.md +77 -0
- package/skills/subagent-execution/agents/planner.md +101 -0
- package/skills/subagent-execution/agents/researcher.md +78 -0
- package/skills/subagent-execution/evals/trigger_eval.json +22 -0
- package/skills/subagent-execution/references/install-codex-agents.mjs +98 -0
- package/skills/subagent-execution/references/review-package.sh +29 -0
- package/skills/subagent-execution/references/spawn-rules.md +88 -0
- package/skills/subagent-execution/references/task-brief.sh +39 -0
- package/skills/technical-spec/SKILL.md +190 -0
- package/skills/technical-spec/evals/trigger_eval.json +23 -0
- package/skills/technical-spec/references/scaffold.sh +98 -0
- package/skills/technical-spec/references/templates.md +159 -0
- package/skills/test-first-development/SKILL.md +121 -0
- package/skills/test-first-development/evals/trigger_eval.json +22 -0
- package/skills/test-first-development/references/rationalization-table.md +16 -0
- package/skills/workflow/SKILL.md +241 -0
- package/skills/workflow/evals/trigger_eval.json +30 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Local plan-review surface. Renders a plan in the browser, collects inline
|
|
3
|
+
// annotations + an approval decision. No npm deps. Binds 127.0.0.1 only.
|
|
4
|
+
//
|
|
5
|
+
// Four modes:
|
|
6
|
+
//
|
|
7
|
+
// Manual: node annotate-server.js <plan-file.md | spec-dir>
|
|
8
|
+
// Serves the plan, writes the decision to <plan-file>.feedback.md,
|
|
9
|
+
// stays running until Ctrl+C. No agent attached.
|
|
10
|
+
// Passing a DIRECTORY (e.g. a tech-spec folder) reviews every
|
|
11
|
+
// top-level *.md in it: the UI lists the documents, notes are
|
|
12
|
+
// tagged with their source file, and the decision lands in
|
|
13
|
+
// <dir>/review.feedback.md grouped by file. Directory mode works
|
|
14
|
+
// in manual and steer modes.
|
|
15
|
+
//
|
|
16
|
+
// Steer: node annotate-server.js <plan-file.md> --steer [--port 7777]
|
|
17
|
+
// (agent-invoked) Long-lived review server for the iterative loop.
|
|
18
|
+
// The agent launches it once in the background, then BLOCKS on
|
|
19
|
+
// GET /api/decision (a long-poll) which resolves when you submit:
|
|
20
|
+
// {"decision":"Approve|Request Revisions|Reject","feedback":<md>,"feedbackFile":<path>}
|
|
21
|
+
// On "Request Revisions" the agent edits <plan-file.md> in place — the
|
|
22
|
+
// SAME browser tab live-reloads over SSE — and re-polls. On Approve or
|
|
23
|
+
// Reject the review is over and the server exits. Default port 7777 so
|
|
24
|
+
// the agent knows the URL without discovery. Diagnostics go to stderr.
|
|
25
|
+
//
|
|
26
|
+
// Hook: node annotate-server.js --hook (Claude Code)
|
|
27
|
+
// Reads a Claude Code PreToolUse(ExitPlanMode) payload on stdin,
|
|
28
|
+
// renders tool_input.plan, blocks until you decide, then prints a
|
|
29
|
+
// hookSpecificOutput JSON decision to stdout and exits:
|
|
30
|
+
// Approve -> permissionDecision "allow"
|
|
31
|
+
// Request Revisions -> permissionDecision "deny" (reason: revise this plan)
|
|
32
|
+
// Reject -> permissionDecision "deny" (reason: do not implement, reconsider)
|
|
33
|
+
//
|
|
34
|
+
// Codex: node annotate-server.js --codex-stop (Codex Stop hook)
|
|
35
|
+
// Reads a Codex Stop payload on stdin. Only reviews planning turns
|
|
36
|
+
// (permission_mode === 'plan'); otherwise emits "{}" and exits so the
|
|
37
|
+
// turn completes untouched. The plan is taken from last_assistant_message
|
|
38
|
+
// (falling back to transcript_path). On decide it prints a Stop result:
|
|
39
|
+
// Approve -> "{}" (turn completes, plan accepted)
|
|
40
|
+
// Request Revisions -> {"decision":"block", reason: revise this plan}
|
|
41
|
+
// Reject -> {"decision":"block", reason: do not implement, reconsider}
|
|
42
|
+
//
|
|
43
|
+
// In hook/codex modes all diagnostics go to stderr so stdout stays clean JSON.
|
|
44
|
+
'use strict';
|
|
45
|
+
|
|
46
|
+
const http = require('http');
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const os = require('os');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
const { exec } = require('child_process');
|
|
51
|
+
|
|
52
|
+
const HOOK_MODE = process.argv.includes('--hook');
|
|
53
|
+
const CODEX_STOP_MODE = process.argv.includes('--codex-stop');
|
|
54
|
+
const STEER_MODE = process.argv.includes('--steer');
|
|
55
|
+
const NO_OPEN = process.argv.includes('--no-open'); // don't launch the browser (tests, headless)
|
|
56
|
+
const STDOUT_JSON = HOOK_MODE || CODEX_STOP_MODE || STEER_MODE; // these modes emit JSON on stdout
|
|
57
|
+
const log = STDOUT_JSON ? console.error : console.log; // keep stdout clean for hooks
|
|
58
|
+
const sseClients = new Set();
|
|
59
|
+
|
|
60
|
+
// Steer mode: the agent long-polls GET /api/decision; a decision resolves the
|
|
61
|
+
// waiter. Default to a fixed port so the agent knows the URL without discovery.
|
|
62
|
+
const portArg = (function () { const i = process.argv.indexOf('--port'); return i >= 0 ? parseInt(process.argv[i + 1], 10) : NaN; })();
|
|
63
|
+
const PORT = !isNaN(portArg) ? portArg : (STEER_MODE ? 7777 : 0);
|
|
64
|
+
let decisionWaiter = null; // pending GET /api/decision response (steer)
|
|
65
|
+
let pendingDecision = null; // decision that arrived before a waiter (steer)
|
|
66
|
+
|
|
67
|
+
let planFile; // path to the markdown file — or directory — being reviewed
|
|
68
|
+
let feedbackFile; // manual/steer modes
|
|
69
|
+
let DIR_MODE = false; // reviewing a directory of markdown docs (spec folder)
|
|
70
|
+
let tmpPlanFile = null; // hook-mode temp plan we created and must remove on exit
|
|
71
|
+
|
|
72
|
+
// Remove the hook-mode temp plan file. Registered on 'exit' so it runs on every
|
|
73
|
+
// termination path (decision delivered, SIGINT, fail()) without repeating cleanup.
|
|
74
|
+
process.on('exit', function () {
|
|
75
|
+
if (tmpPlanFile) { try { fs.unlinkSync(tmpPlanFile); } catch (e) { /* already gone */ } }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function fail(msg) { console.error(' plan-review: ' + msg); process.exit(1); }
|
|
79
|
+
|
|
80
|
+
function start(planText, sourcePath) {
|
|
81
|
+
if (sourcePath) {
|
|
82
|
+
planFile = path.resolve(sourcePath);
|
|
83
|
+
DIR_MODE = fs.statSync(planFile).isDirectory();
|
|
84
|
+
feedbackFile = DIR_MODE ? path.join(planFile, 'review.feedback.md') : planFile + '.feedback.md';
|
|
85
|
+
} else {
|
|
86
|
+
// hook mode: persist the plan to a temp file so render + live-reload work uniformly
|
|
87
|
+
planFile = path.join(os.tmpdir(), 'plan-review-' + process.pid + '.md');
|
|
88
|
+
fs.writeFileSync(planFile, planText || '# (empty plan)\n');
|
|
89
|
+
tmpPlanFile = planFile; // mark for removal on exit
|
|
90
|
+
}
|
|
91
|
+
watchPlan();
|
|
92
|
+
listen();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Directory mode: the reviewable documents — every *.md in the tree, as POSIX
|
|
96
|
+
// relative paths. Ordering groups docs by folder (root first), index.md first
|
|
97
|
+
// within each folder. Skips dotfiles/dotdirs and the feedback file we write.
|
|
98
|
+
function listDocs() {
|
|
99
|
+
const out = [];
|
|
100
|
+
(function walk(rel) {
|
|
101
|
+
let entries;
|
|
102
|
+
try { entries = fs.readdirSync(path.join(planFile, rel || '.'), { withFileTypes: true }); }
|
|
103
|
+
catch (e) { return; }
|
|
104
|
+
const dirs = [], files = [];
|
|
105
|
+
entries.forEach(function (e) {
|
|
106
|
+
if (e.name[0] === '.') return;
|
|
107
|
+
const r = rel ? rel + '/' + e.name : e.name;
|
|
108
|
+
if (e.isDirectory()) dirs.push(r);
|
|
109
|
+
else if (/\.md$/i.test(e.name) && !/\.feedback\.md$/i.test(e.name)) files.push(r);
|
|
110
|
+
});
|
|
111
|
+
files.sort(function (a, b) {
|
|
112
|
+
const ab = path.basename(a), bb = path.basename(b);
|
|
113
|
+
if (ab === 'index.md') return -1;
|
|
114
|
+
if (bb === 'index.md') return 1;
|
|
115
|
+
return ab.localeCompare(bb);
|
|
116
|
+
});
|
|
117
|
+
out.push.apply(out, files);
|
|
118
|
+
dirs.sort().forEach(walk);
|
|
119
|
+
})('');
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readPlan(doc) {
|
|
124
|
+
try {
|
|
125
|
+
if (DIR_MODE) {
|
|
126
|
+
if (listDocs().indexOf(doc) < 0) return ''; // only enumerated docs are servable
|
|
127
|
+
return fs.readFileSync(path.join.apply(path, [planFile].concat(doc.split('/'))), 'utf8');
|
|
128
|
+
}
|
|
129
|
+
return fs.readFileSync(planFile, 'utf8');
|
|
130
|
+
} catch (e) { return ''; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function watchPlan() {
|
|
134
|
+
try {
|
|
135
|
+
if (DIR_MODE) {
|
|
136
|
+
// Watch every folder that holds a doc (fs.watch recursion is unsupported
|
|
137
|
+
// on Linux). Tag events with the doc's relative path so the client
|
|
138
|
+
// reloads (and diffs) only the changed document.
|
|
139
|
+
const dirs = new Set(['']);
|
|
140
|
+
listDocs().forEach(function (d) { const i = d.lastIndexOf('/'); if (i >= 0) dirs.add(d.slice(0, i)); });
|
|
141
|
+
dirs.forEach(function (rel) {
|
|
142
|
+
try {
|
|
143
|
+
fs.watch(path.join(planFile, rel || '.'), {}, function (_, filename) {
|
|
144
|
+
if (!filename || !/\.md$/i.test(filename)) return;
|
|
145
|
+
if (/\.feedback\.md$/i.test(filename) || filename[0] === '.') return;
|
|
146
|
+
const relpath = rel ? rel + '/' + filename : filename;
|
|
147
|
+
sseClients.forEach(function (res) { try { res.write('data: change:' + relpath + '\n\n'); } catch (e) {} });
|
|
148
|
+
});
|
|
149
|
+
} catch (e) {}
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
fs.watch(path.dirname(planFile), {}, function (_, filename) {
|
|
154
|
+
if (filename && path.basename(planFile) === filename) {
|
|
155
|
+
sseClients.forEach(function (res) { try { res.write('data: change\n\n'); } catch (e) {} });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
} catch (e) { /* live reload simply won't fire */ }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Files-tab support: resolve mentioned paths against the git root of the
|
|
162
|
+
// reviewed file (walking up to the filesystem root), so exists/new badges
|
|
163
|
+
// reflect the repo under review, not the server's CWD.
|
|
164
|
+
function gitRoot() {
|
|
165
|
+
let dir = DIR_MODE ? planFile : path.dirname(planFile);
|
|
166
|
+
while (true) {
|
|
167
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
168
|
+
const up = path.dirname(dir);
|
|
169
|
+
if (up === dir) return DIR_MODE ? planFile : path.dirname(planFile);
|
|
170
|
+
dir = up;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function feedbackMarkdown(decision, thread) {
|
|
175
|
+
const lines = ['# Plan Review Feedback', '',
|
|
176
|
+
'- **Decision:** ' + decision,
|
|
177
|
+
'- **Submitted:** ' + new Date().toISOString(), ''];
|
|
178
|
+
const notes = (thread || []).filter(function (t) { return t.kind === 'note'; });
|
|
179
|
+
const chat = (thread || []).filter(function (t) { return t.kind !== 'note'; });
|
|
180
|
+
if (notes.length) {
|
|
181
|
+
lines.push('## Inline notes', '');
|
|
182
|
+
// Directory mode tags each note with its source doc — group under a
|
|
183
|
+
// per-file heading so the agent knows which spec file each note targets.
|
|
184
|
+
const byDoc = {};
|
|
185
|
+
notes.forEach(function (t) {
|
|
186
|
+
const key = (t.doc || '');
|
|
187
|
+
(byDoc[key] = byDoc[key] || []).push(t);
|
|
188
|
+
});
|
|
189
|
+
Object.keys(byDoc).sort().forEach(function (doc) {
|
|
190
|
+
if (doc) lines.push('### `' + doc + '`', '');
|
|
191
|
+
byDoc[doc].forEach(function (t, i) {
|
|
192
|
+
const quote = (t.quote || '').trim();
|
|
193
|
+
const anchor = quote ? '> ' + quote.replace(/\n+/g, ' ') + '\n\n' : '';
|
|
194
|
+
const cat = t.cat ? '**[' + t.cat + ']** ' : '';
|
|
195
|
+
lines.push((i + 1) + '. ' + cat + anchor + ' ' + (t.text || '').trim());
|
|
196
|
+
});
|
|
197
|
+
lines.push('');
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (chat.length) {
|
|
201
|
+
lines.push('## Messages', '');
|
|
202
|
+
chat.forEach(function (t) { lines.push('- ' + (t.text || '').trim()); });
|
|
203
|
+
lines.push('');
|
|
204
|
+
}
|
|
205
|
+
if (!notes.length && !chat.length) lines.push('_No notes or messages._', '');
|
|
206
|
+
return lines.join('\n');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The agent-facing reason string. "Reject" and "Request Revisions" are distinct
|
|
210
|
+
// intents: Reject = do not implement, reconsider the whole approach; Request
|
|
211
|
+
// Revisions = iterate on this plan and re-present it.
|
|
212
|
+
function feedbackReason(decision, md) {
|
|
213
|
+
if (decision === 'Reject') {
|
|
214
|
+
return 'Plan review: Rejected. Do NOT implement this plan. Step back and ' +
|
|
215
|
+
'reconsider whether this feature or approach should be built at all — then ' +
|
|
216
|
+
'propose a fundamentally different approach or stop. Context:\n\n' + md;
|
|
217
|
+
}
|
|
218
|
+
return 'Plan review: Request Revisions. Revise the plan to address the ' +
|
|
219
|
+
'following, then re-present it:\n\n' + md;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Decide what to do once the user submits in the browser.
|
|
223
|
+
function resolveDecision(decision, thread) {
|
|
224
|
+
const md = feedbackMarkdown(decision, thread);
|
|
225
|
+
|
|
226
|
+
if (HOOK_MODE) {
|
|
227
|
+
const out = { hookSpecificOutput: { hookEventName: 'PreToolUse' } };
|
|
228
|
+
if (decision === 'Approve') {
|
|
229
|
+
out.hookSpecificOutput.permissionDecision = 'allow';
|
|
230
|
+
if (thread && thread.length) {
|
|
231
|
+
out.hookSpecificOutput.additionalContext =
|
|
232
|
+
'Plan approved with notes. Address these while implementing:\n\n' + md;
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
out.hookSpecificOutput.permissionDecision = 'deny';
|
|
236
|
+
out.hookSpecificOutput.permissionDecisionReason = feedbackReason(decision, md);
|
|
237
|
+
}
|
|
238
|
+
process.stdout.write(JSON.stringify(out));
|
|
239
|
+
log('\n Decision: ' + decision + ' — returned to agent. Stopping.\n');
|
|
240
|
+
setTimeout(function () { process.exit(0); }, 150);
|
|
241
|
+
} else if (CODEX_STOP_MODE) {
|
|
242
|
+
// Codex Stop: "block" continues the turn with `reason` as a new user prompt;
|
|
243
|
+
// an empty object lets the turn complete. Codex has no approve-with-notes
|
|
244
|
+
// channel, so we reuse the continuation prompt to carry the notes: a bare
|
|
245
|
+
// Approve completes the turn, but Approve *with* notes continues it so the
|
|
246
|
+
// agent actually receives them (mirrors Claude Code's additionalContext).
|
|
247
|
+
let out;
|
|
248
|
+
if (decision === 'Approve') {
|
|
249
|
+
out = (thread && thread.length)
|
|
250
|
+
? { decision: 'block', reason: 'Plan approved with notes. Address these while implementing:\n\n' + md }
|
|
251
|
+
: {};
|
|
252
|
+
} else {
|
|
253
|
+
out = { decision: 'block', reason: feedbackReason(decision, md) };
|
|
254
|
+
}
|
|
255
|
+
process.stdout.write(JSON.stringify(out));
|
|
256
|
+
log('\n Decision: ' + decision + ' — returned to Codex. Stopping.\n');
|
|
257
|
+
setTimeout(function () { process.exit(0); }, 150);
|
|
258
|
+
} else if (STEER_MODE) {
|
|
259
|
+
// Agent-invoked, long-lived: deliver the decision to the agent's pending
|
|
260
|
+
// GET /api/decision long-poll. The server stays up across rounds so the same
|
|
261
|
+
// browser tab live-reloads when the agent revises the plan on disk. On a
|
|
262
|
+
// terminal decision (Approve/Reject) the review is over, so we exit after the
|
|
263
|
+
// response flushes; on "Request Revisions" we keep serving the next round.
|
|
264
|
+
fs.writeFileSync(feedbackFile, md);
|
|
265
|
+
const payload = { decision: decision, feedback: md, feedbackFile: feedbackFile };
|
|
266
|
+
if (decisionWaiter) {
|
|
267
|
+
try { decisionWaiter.writeHead(200, { 'Content-Type': 'application/json' }); decisionWaiter.end(JSON.stringify(payload)); } catch (e) { /* client gone */ }
|
|
268
|
+
decisionWaiter = null;
|
|
269
|
+
} else {
|
|
270
|
+
pendingDecision = payload; // agent hasn't polled yet — hand it to the next GET
|
|
271
|
+
}
|
|
272
|
+
log('\n Decision: ' + decision + ' — delivered to agent.\n');
|
|
273
|
+
if (decision === 'Approve' || decision === 'Reject') {
|
|
274
|
+
log(' Review concluded (' + decision + '). Stopping.\n');
|
|
275
|
+
setTimeout(function () { process.exit(0); }, 400);
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
fs.writeFileSync(feedbackFile, md);
|
|
279
|
+
log('\n[PLAN-REVIEW FEEDBACK -> ' + feedbackFile + ']\n' + md + '\n');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const TEMPLATE = fs.readFileSync(path.join(__dirname, 'plan-review-template.html'), 'utf8');
|
|
284
|
+
|
|
285
|
+
function sendJson(res, code, obj) {
|
|
286
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
287
|
+
res.end(JSON.stringify(obj));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function escapeHtml(value) {
|
|
291
|
+
return String(value).replace(/[&<>"']/g, function (c) {
|
|
292
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function handler(req, res) {
|
|
297
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
298
|
+
const p = url.pathname;
|
|
299
|
+
|
|
300
|
+
if (req.method === 'GET' && p === '/') {
|
|
301
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
302
|
+
return res.end(TEMPLATE.replace(/\{\{PLAN_NAME\}\}/g, escapeHtml(path.basename(planFile))));
|
|
303
|
+
}
|
|
304
|
+
if (req.method === 'GET' && p === '/api/plan') {
|
|
305
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
306
|
+
return res.end(readPlan(url.searchParams.get('doc') || ''));
|
|
307
|
+
}
|
|
308
|
+
if (req.method === 'GET' && p === '/api/docs') {
|
|
309
|
+
// Document manifest. Single-file reviews report dir:false so the client
|
|
310
|
+
// keeps the classic one-document layout.
|
|
311
|
+
return sendJson(res, 200, DIR_MODE
|
|
312
|
+
? { dir: true, docs: listDocs() }
|
|
313
|
+
: { dir: false, docs: [path.basename(planFile)] });
|
|
314
|
+
}
|
|
315
|
+
if (req.method === 'POST' && p === '/api/verify-paths') {
|
|
316
|
+
// Files tab: check which mentioned paths exist in the repo under review.
|
|
317
|
+
// Read-only existsSync against the git root; refuses escapes and absolutes.
|
|
318
|
+
let body = '';
|
|
319
|
+
req.on('data', function (c) { body += c; if (body.length > 1e6) req.destroy(); });
|
|
320
|
+
req.on('end', function () {
|
|
321
|
+
try {
|
|
322
|
+
const paths = (JSON.parse(body).paths || []).slice(0, 500);
|
|
323
|
+
const root = gitRoot();
|
|
324
|
+
const results = {};
|
|
325
|
+
paths.forEach(function (rel) {
|
|
326
|
+
if (typeof rel !== 'string' || !rel || path.isAbsolute(rel) || rel.split(/[\\/]/).indexOf('..') >= 0) return;
|
|
327
|
+
results[rel] = fs.existsSync(path.join(root, rel));
|
|
328
|
+
});
|
|
329
|
+
sendJson(res, 200, { root: root, results: results });
|
|
330
|
+
} catch (e) {
|
|
331
|
+
sendJson(res, 400, { error: 'invalid JSON' });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (req.method === 'GET' && p === '/api/events') {
|
|
337
|
+
res.writeHead(200, {
|
|
338
|
+
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive',
|
|
339
|
+
});
|
|
340
|
+
res.write(':\n\n');
|
|
341
|
+
sseClients.add(res);
|
|
342
|
+
req.on('close', function () { sseClients.delete(res); });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (req.method === 'GET' && p === '/api/decision') {
|
|
346
|
+
// Steer long-poll: block until a decision is submitted in the browser.
|
|
347
|
+
if (pendingDecision) { const d = pendingDecision; pendingDecision = null; return sendJson(res, 200, d); }
|
|
348
|
+
res.setTimeout(0); // a human may take minutes — don't let Node close the idle socket
|
|
349
|
+
decisionWaiter = res;
|
|
350
|
+
req.on('close', function () { if (decisionWaiter === res) decisionWaiter = null; });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (req.method === 'POST' && p === '/api/feedback') {
|
|
354
|
+
let body = '';
|
|
355
|
+
req.on('data', function (c) { body += c; if (body.length > 1e6) req.destroy(); });
|
|
356
|
+
req.on('end', function () {
|
|
357
|
+
try {
|
|
358
|
+
const data = JSON.parse(body);
|
|
359
|
+
sendJson(res, 200, { status: 'received', mode: CODEX_STOP_MODE ? 'codex' : HOOK_MODE ? 'hook' : STEER_MODE ? 'steer' : 'manual', file: feedbackFile });
|
|
360
|
+
resolveDecision(data.decision || 'Request Revisions', data.thread || []);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
sendJson(res, 400, { error: 'invalid JSON' });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
res.writeHead(404);
|
|
368
|
+
res.end();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let server;
|
|
372
|
+
function listen() {
|
|
373
|
+
server = http.createServer(handler);
|
|
374
|
+
server.on('error', function (e) {
|
|
375
|
+
if (e && e.code === 'EADDRINUSE') fail('port ' + PORT + ' is in use — pass a free --port <n>.');
|
|
376
|
+
fail(String((e && e.message) || e));
|
|
377
|
+
});
|
|
378
|
+
server.listen(PORT, '127.0.0.1', function () {
|
|
379
|
+
const url = 'http://127.0.0.1:' + server.address().port;
|
|
380
|
+
log('\n Plan review → ' + url);
|
|
381
|
+
log(' Plan → ' + planFile);
|
|
382
|
+
if (STEER_MODE) log(' Decisions → GET ' + url + '/api/decision (long-poll)');
|
|
383
|
+
if (!STDOUT_JSON) log(' Feedback → ' + feedbackFile);
|
|
384
|
+
log('\n Annotate the plan, then submit your decision. Ctrl+C to stop.\n');
|
|
385
|
+
if (!NO_OPEN) {
|
|
386
|
+
const opener = process.platform === 'darwin' ? 'open' :
|
|
387
|
+
process.platform === 'win32' ? 'start ""' : 'xdg-open';
|
|
388
|
+
exec(opener + ' ' + url, function () {});
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
process.on('SIGINT', function () {
|
|
394
|
+
log('\n Plan review stopped.\n');
|
|
395
|
+
sseClients.forEach(function (res) { try { res.end(); } catch (e) {} });
|
|
396
|
+
if (server) server.close(function () { process.exit(0); }); else process.exit(0);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Best-effort: pull the latest assistant message text out of a Codex transcript.
|
|
400
|
+
// The transcript format is explicitly not a stable interface, so this is wrapped
|
|
401
|
+
// defensively and only used when last_assistant_message is absent.
|
|
402
|
+
function readCodexTranscriptPlan(transcriptPath) {
|
|
403
|
+
try {
|
|
404
|
+
const raw = fs.readFileSync(transcriptPath, 'utf8');
|
|
405
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
406
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
407
|
+
let entry;
|
|
408
|
+
try { entry = JSON.parse(lines[i]); } catch (e) { continue; }
|
|
409
|
+
const role = entry.role || (entry.message && entry.message.role);
|
|
410
|
+
if (role !== 'assistant') continue;
|
|
411
|
+
const content = (entry.message && entry.message.content) || entry.content;
|
|
412
|
+
if (typeof content === 'string' && content.trim()) return content;
|
|
413
|
+
if (Array.isArray(content)) {
|
|
414
|
+
const text = content
|
|
415
|
+
.map(function (c) { return typeof c === 'string' ? c : (c && c.text) || ''; })
|
|
416
|
+
.join('').trim();
|
|
417
|
+
if (text) return text;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch (e) { /* fall through */ }
|
|
421
|
+
return '';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function readStdinJSON(cb) {
|
|
425
|
+
let input = '';
|
|
426
|
+
process.stdin.setEncoding('utf8');
|
|
427
|
+
process.stdin.on('data', function (c) { input += c; });
|
|
428
|
+
process.stdin.on('end', function () {
|
|
429
|
+
let payload = {};
|
|
430
|
+
try { payload = JSON.parse(input || '{}'); } catch (e) { /* keep {} */ }
|
|
431
|
+
cb(payload);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (HOOK_MODE) {
|
|
436
|
+
readStdinJSON(function (payload) {
|
|
437
|
+
const ti = payload.tool_input || {};
|
|
438
|
+
const plan = ti.plan || ti.message || payload.plan || '';
|
|
439
|
+
start(plan, null);
|
|
440
|
+
});
|
|
441
|
+
} else if (CODEX_STOP_MODE) {
|
|
442
|
+
readStdinJSON(function (payload) {
|
|
443
|
+
// Only intercept planning turns — every other turn-stop must pass through
|
|
444
|
+
// untouched, and Stop requires valid JSON on stdout when exiting 0.
|
|
445
|
+
if (payload.permission_mode !== 'plan') { process.stdout.write('{}'); return process.exit(0); }
|
|
446
|
+
let plan = (payload.last_assistant_message || '').trim();
|
|
447
|
+
if (!plan && payload.transcript_path) plan = readCodexTranscriptPlan(payload.transcript_path).trim();
|
|
448
|
+
if (!plan) { process.stdout.write('{}'); return process.exit(0); } // nothing to review
|
|
449
|
+
start(plan, null);
|
|
450
|
+
});
|
|
451
|
+
} else {
|
|
452
|
+
// Manual or steer: first non-flag arg is the plan file (skip --port's value).
|
|
453
|
+
const args = process.argv.slice(2);
|
|
454
|
+
let file = null;
|
|
455
|
+
for (let i = 0; i < args.length; i++) {
|
|
456
|
+
if (args[i] === '--port') { i++; continue; }
|
|
457
|
+
if (args[i][0] !== '-') { file = args[i]; break; }
|
|
458
|
+
}
|
|
459
|
+
if (!file || !fs.existsSync(file)) fail('plan file not found — pass a markdown file or spec directory, or use --hook / --codex-stop.');
|
|
460
|
+
if (fs.statSync(file).isDirectory()) {
|
|
461
|
+
const docs = fs.readdirSync(file).filter(function (n) { return /\.md$/i.test(n) && !/\.feedback\.md$/i.test(n) && n[0] !== '.'; });
|
|
462
|
+
if (!docs.length) fail('no markdown files in ' + file + ' — a spec directory needs at least one top-level *.md.');
|
|
463
|
+
}
|
|
464
|
+
start(null, file);
|
|
465
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Install the plan-review Stop hook into Codex.
|
|
3
|
+
#
|
|
4
|
+
# Codex has no ExitPlanMode interception point, so plan review runs off Codex's
|
|
5
|
+
# experimental `Stop` hook: when a planning turn ends, annotate-server.js
|
|
6
|
+
# (--codex-stop) opens the review UI and returns a continuation reason on deny.
|
|
7
|
+
#
|
|
8
|
+
# This script:
|
|
9
|
+
# 1. validates and merges a Stop hook into <CODEX_HOME>/hooks.json pointing at
|
|
10
|
+
# this skill's annotate-server.js (absolute paths — Codex Desktop doesn't
|
|
11
|
+
# inherit PATH)
|
|
12
|
+
# 2. enables the experimental hooks feature in <CODEX_HOME>/config.toml
|
|
13
|
+
#
|
|
14
|
+
# Usage: bash install-codex-hook.sh
|
|
15
|
+
# Notes: experimental; disabled on Windows. Restart Codex and trust the hook
|
|
16
|
+
# after running. Honors $CODEX_HOME (falls back to ~/.codex).
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
umask 077
|
|
19
|
+
|
|
20
|
+
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
21
|
+
SERVER="$SCRIPT_DIR/annotate-server.js"
|
|
22
|
+
[[ -f "$SERVER" ]] || { echo "error: annotate-server.js not found next to this script" >&2; exit 1; }
|
|
23
|
+
|
|
24
|
+
NODE_BIN=$(command -v node || true)
|
|
25
|
+
[[ -n "$NODE_BIN" ]] || { echo "error: node not found on PATH" >&2; exit 1; }
|
|
26
|
+
|
|
27
|
+
CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
|
|
28
|
+
mkdir -p "$CODEX_HOME"
|
|
29
|
+
CONFIG="$CODEX_HOME/config.toml"
|
|
30
|
+
HOOKS="$CODEX_HOME/hooks.json"
|
|
31
|
+
COMMAND="$NODE_BIN \"$SERVER\" --codex-stop"
|
|
32
|
+
|
|
33
|
+
# --- 1. validate + merge the Stop hook (idempotent) via node --------------------
|
|
34
|
+
"$NODE_BIN" - "$HOOKS" "$COMMAND" <<'NODE'
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const [, , hooksPath, command] = process.argv;
|
|
37
|
+
let cfg = {};
|
|
38
|
+
try {
|
|
39
|
+
cfg = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e.code !== 'ENOENT') {
|
|
42
|
+
console.error('error: refusing to overwrite invalid hooks JSON at ' + hooksPath + ': ' + e.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
47
|
+
console.error('error: hooks config must be a JSON object: ' + hooksPath);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
if (cfg.hooks === undefined) cfg.hooks = {};
|
|
51
|
+
if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
|
|
52
|
+
console.error('error: "hooks" must be a JSON object: ' + hooksPath);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
if (cfg.hooks.Stop === undefined) cfg.hooks.Stop = [];
|
|
56
|
+
if (!Array.isArray(cfg.hooks.Stop)) {
|
|
57
|
+
console.error('error: "hooks.Stop" must be an array: ' + hooksPath);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const has = JSON.stringify(cfg.hooks.Stop).includes('--codex-stop');
|
|
61
|
+
if (!has) {
|
|
62
|
+
cfg.hooks.Stop.push({ hooks: [{ type: 'command', command, timeout: 345600 }] });
|
|
63
|
+
const tmp = hooksPath + '.tmp-' + process.pid;
|
|
64
|
+
let mode = 0o600;
|
|
65
|
+
try { mode = fs.statSync(hooksPath).mode & 0o777; } catch (e) {
|
|
66
|
+
if (e.code !== 'ENOENT') throw e;
|
|
67
|
+
}
|
|
68
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', { mode });
|
|
69
|
+
fs.chmodSync(tmp, mode);
|
|
70
|
+
fs.renameSync(tmp, hooksPath);
|
|
71
|
+
console.log('set merged Stop hook into ' + hooksPath);
|
|
72
|
+
} else {
|
|
73
|
+
console.log('ok Stop hook already present in ' + hooksPath);
|
|
74
|
+
}
|
|
75
|
+
NODE
|
|
76
|
+
|
|
77
|
+
# --- 2. enable the hooks feature ------------------------------------------------
|
|
78
|
+
if [[ -f "$CONFIG" ]] && grep -Eq '^[[:space:]]*\[features\][[:space:]]*($|#)' "$CONFIG"; then
|
|
79
|
+
cp -p "$CONFIG" "$CONFIG.tmp"
|
|
80
|
+
awk '
|
|
81
|
+
BEGIN { in_features=0; hooks_written=0 }
|
|
82
|
+
/^[[:space:]]*\[features\][[:space:]]*($|#)/ {
|
|
83
|
+
in_features=1
|
|
84
|
+
print
|
|
85
|
+
next
|
|
86
|
+
}
|
|
87
|
+
/^[[:space:]]*\[/ {
|
|
88
|
+
if (in_features && !hooks_written) { print "hooks = true"; hooks_written=1 }
|
|
89
|
+
in_features=0
|
|
90
|
+
print
|
|
91
|
+
next
|
|
92
|
+
}
|
|
93
|
+
{
|
|
94
|
+
if (in_features && /^[[:space:]]*hooks[[:space:]]*=/) {
|
|
95
|
+
if (!hooks_written) { print "hooks = true"; hooks_written=1 }
|
|
96
|
+
next
|
|
97
|
+
}
|
|
98
|
+
print
|
|
99
|
+
}
|
|
100
|
+
END { if (in_features && !hooks_written) print "hooks = true" }
|
|
101
|
+
' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
|
|
102
|
+
echo "set ensured hooks = true under existing [features] in $CONFIG"
|
|
103
|
+
else
|
|
104
|
+
printf '\n[features]\nhooks = true\n' >> "$CONFIG"
|
|
105
|
+
echo "set added [features] hooks = true to $CONFIG"
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
echo ""
|
|
109
|
+
echo "done. Next steps:"
|
|
110
|
+
echo " 1. Restart Codex (and Codex Desktop if used)."
|
|
111
|
+
echo " 2. Codex will ask you to trust the hook definition — approve it."
|
|
112
|
+
echo " 3. Enter plan mode; when the planning turn ends, the review UI opens."
|