@jabworks/condux 0.2.0 → 0.3.1

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -29
  3. package/index.js +21 -0
  4. package/package.json +6 -4
  5. package/skills/code-review/SKILL.md +136 -0
  6. package/skills/code-review/evals/trigger_eval.json +22 -0
  7. package/skills/discovery/SKILL.md +242 -0
  8. package/skills/discovery/evals/trigger_eval.json +24 -0
  9. package/skills/discovery/references/choice-server.js +216 -0
  10. package/skills/discovery/references/mockup-picker.md +90 -0
  11. package/skills/discovery/references/package.json +3 -0
  12. package/skills/draft-plan/SKILL.md +229 -0
  13. package/skills/draft-plan/evals/trigger_eval.json +22 -0
  14. package/skills/draft-plan/references/plan-template.md +96 -0
  15. package/skills/finalize/SKILL.md +123 -0
  16. package/skills/finalize/evals/trigger_eval.json +22 -0
  17. package/skills/finalize/references/rationalization-table.md +15 -0
  18. package/skills/plan-review/SKILL.md +215 -0
  19. package/skills/plan-review/evals/trigger_eval.json +22 -0
  20. package/skills/plan-review/references/annotate-server.js +465 -0
  21. package/skills/plan-review/references/install-codex-hook.sh +112 -0
  22. package/skills/plan-review/references/package.json +3 -0
  23. package/skills/plan-review/references/plan-review-template.html +730 -0
  24. package/skills/preflight/SKILL.md +128 -0
  25. package/skills/preflight/evals/trigger_eval.json +26 -0
  26. package/skills/root-cause-analysis/SKILL.md +82 -0
  27. package/skills/root-cause-analysis/evals/trigger_eval.json +22 -0
  28. package/skills/subagent-deployment/SKILL.md +105 -0
  29. package/skills/subagent-deployment/evals/trigger_eval.json +22 -0
  30. package/skills/subagent-deployment/references/safety-checklist.md +54 -0
  31. package/skills/subagent-execution/SKILL.md +182 -0
  32. package/skills/subagent-execution/agents/coder.md +55 -0
  33. package/skills/subagent-execution/agents/explorer.md +77 -0
  34. package/skills/subagent-execution/agents/planner.md +101 -0
  35. package/skills/subagent-execution/agents/researcher.md +78 -0
  36. package/skills/subagent-execution/evals/trigger_eval.json +22 -0
  37. package/skills/subagent-execution/references/install-codex-agents.mjs +98 -0
  38. package/skills/subagent-execution/references/review-package.sh +29 -0
  39. package/skills/subagent-execution/references/spawn-rules.md +88 -0
  40. package/skills/subagent-execution/references/task-brief.sh +39 -0
  41. package/skills/technical-spec/SKILL.md +190 -0
  42. package/skills/technical-spec/evals/trigger_eval.json +23 -0
  43. package/skills/technical-spec/references/scaffold.sh +98 -0
  44. package/skills/technical-spec/references/templates.md +159 -0
  45. package/skills/test-first-development/SKILL.md +121 -0
  46. package/skills/test-first-development/evals/trigger_eval.json +22 -0
  47. package/skills/test-first-development/references/rationalization-table.md +16 -0
  48. package/skills/workflow/SKILL.md +241 -0
  49. 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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."
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }