ctxr-cli 0.18.0 → 0.20.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 +13 -4
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +2 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.d.ts +41 -2
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +125 -3
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/version.d.ts +6 -0
- package/dist/commands/version.d.ts.map +1 -1
- package/dist/commands/version.js +4 -1
- package/dist/commands/version.js.map +1 -1
- package/dist/config/defaults.d.ts +10 -7
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +10 -7
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/load.d.ts +22 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +54 -17
- package/dist/config/load.js.map +1 -1
- package/dist/config/migrations.d.ts +61 -0
- package/dist/config/migrations.d.ts.map +1 -0
- package/dist/config/migrations.js +55 -0
- package/dist/config/migrations.js.map +1 -0
- package/dist/config/schema.d.ts +11 -8
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +14 -11
- package/dist/config/schema.js.map +1 -1
- package/dist/core/checks/write-lifecycle-checks.d.ts +8 -0
- package/dist/core/checks/write-lifecycle-checks.d.ts.map +1 -1
- package/dist/core/checks/write-lifecycle-checks.js +22 -3
- package/dist/core/checks/write-lifecycle-checks.js.map +1 -1
- package/dist/core/conventions.d.ts +3 -4
- package/dist/core/conventions.d.ts.map +1 -1
- package/dist/core/conventions.js +3 -4
- package/dist/core/conventions.js.map +1 -1
- package/dist/core/env.d.ts +7 -0
- package/dist/core/env.d.ts.map +1 -1
- package/dist/core/env.js.map +1 -1
- package/dist/core/errors.d.ts +16 -1
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +30 -4
- package/dist/core/errors.js.map +1 -1
- package/dist/core/install-kind.d.ts +13 -0
- package/dist/core/install-kind.d.ts.map +1 -1
- package/dist/core/install-kind.js +29 -0
- package/dist/core/install-kind.js.map +1 -1
- package/dist/core/reconcile.js +3 -3
- package/dist/core/reconcile.js.map +1 -1
- package/dist/core/skills.d.ts +30 -4
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +141 -22
- package/dist/core/skills.js.map +1 -1
- package/dist/core/store.d.ts +15 -0
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/store.js +18 -2
- package/dist/core/store.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +8 -4
- package/dist/run.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/templates/agents/canonical.md +2 -2
- package/templates/skills/ctxr-second-opinion/critic-contract.md +40 -0
- package/templates/skills/ctxr-second-opinion/lenses.md +15 -0
- package/templates/skills/ctxr-second-opinion/personas/architect.md +17 -0
- package/templates/skills/ctxr-second-opinion/personas/pragmatist.md +17 -0
- package/templates/skills/ctxr-second-opinion/personas/skeptic.md +17 -0
- package/templates/skills/ctxr-second-opinion/run.mjs +504 -0
- package/templates/skills/ctxr-second-opinion.md +174 -0
- package/templates/skills/ctxr-session-capture.md +3 -2
- package/templates/skills/ctxr-upgrade.md +8 -2
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ctxr-second-opinion runner — fans a plan (critique) or a question (poll) out to
|
|
3
|
+
// three different model families' command-line tools, in parallel, and writes what
|
|
4
|
+
// comes back. It owns the mechanics only: launching, containment, timeouts,
|
|
5
|
+
// validation, anonymizing. Whether to run it, and how to weigh the answers, is the
|
|
6
|
+
// skill's judgment (SKILL.md), never this file's.
|
|
7
|
+
//
|
|
8
|
+
// Zero dependencies: Node's standard library only. Invoked as `node run.mjs`, so the
|
|
9
|
+
// file needs no exec bit.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// node run.mjs --preflight [--only claude,codex]
|
|
13
|
+
// node run.mjs --mode critique --plan-file plan.md [--out DIR] [--tier strong|fast]
|
|
14
|
+
// node run.mjs --mode poll --prompt-file q.txt [--lens minimalist,marketer]
|
|
15
|
+
//
|
|
16
|
+
// Exit codes: 0 at least two valid results
|
|
17
|
+
// 2 a partial result: fewer than two valid (preflight: a CLI is down)
|
|
18
|
+
// 1 usage or setup error, nothing was run
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { homedir, tmpdir } from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const QUORUM = 2;
|
|
28
|
+
const ALL_CLIS = ['claude', 'codex', 'agy'];
|
|
29
|
+
const ROLE_CLI = { architect: 'claude', skeptic: 'codex', pragmatist: 'agy' };
|
|
30
|
+
const ROLES = Object.keys(ROLE_CLI);
|
|
31
|
+
// Linux caps one argv element at 128 KiB; agy is the only CLI given its prompt as argv.
|
|
32
|
+
const MAX_ARGV_BYTES = 120_000;
|
|
33
|
+
|
|
34
|
+
const env = (name, fallback) => process.env[`CTXR_SECOND_OPINION_${name}`] || fallback;
|
|
35
|
+
|
|
36
|
+
/** Model choice lives here, in one table. Override by environment, never by editing a store. */
|
|
37
|
+
function tiers() {
|
|
38
|
+
return {
|
|
39
|
+
strong: {
|
|
40
|
+
// `high`, not `max`: the same two-and-a-half-thousand-character plan took Claude 35 s at `high` and 514 s at
|
|
41
|
+
// `max` (and once timed out at 280 s), with a valid critique both times. `max` stays one variable away.
|
|
42
|
+
claude: { model: env('CLAUDE_MODEL', 'opus'), effort: env('CLAUDE_EFFORT', 'high') },
|
|
43
|
+
codex: { effort: env('CODEX_EFFORT', 'xhigh') },
|
|
44
|
+
agy: { model: env('AGY_MODEL', 'Gemini 3.1 Pro (High)') },
|
|
45
|
+
},
|
|
46
|
+
fast: {
|
|
47
|
+
claude: { model: env('CLAUDE_MODEL', 'haiku'), effort: null },
|
|
48
|
+
codex: { effort: env('CODEX_EFFORT', 'medium') },
|
|
49
|
+
agy: { model: env('AGY_MODEL', 'Gemini 3.8 Flash (High)') },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The flags this runner passes, so preflight can confirm each CLI's --help still lists them. Only flags
|
|
56
|
+
* the help text documents belong here: `claude` still accepts a hidden --max-turns, but with `--tools ""`
|
|
57
|
+
* there is no tool turn to bound, so it is not passed at all.
|
|
58
|
+
*/
|
|
59
|
+
const FLAGS_USED = {
|
|
60
|
+
claude: { helpArgs: ['--help'], flags: ['--tools', '--model', '--effort', '--no-session-persistence', '--disable-slash-commands', '--output-format'] },
|
|
61
|
+
codex: { helpArgs: ['exec', '--help'], flags: ['--skip-git-repo-check', '--sandbox', '--ephemeral', '--color', '--config'] },
|
|
62
|
+
agy: { helpArgs: ['--help'], flags: ['--print', '--sandbox', '--disable-slash-commands', '--model'] },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
class UsageError extends Error {}
|
|
66
|
+
|
|
67
|
+
function parseArgs(argv) {
|
|
68
|
+
const opts = { mode: 'critique', tier: 'strong', timeout: 600, only: null, lens: null, out: null, planFile: null, promptFile: null, preflight: false, noWrap: false, seed: null };
|
|
69
|
+
const takesValue = new Set(['--mode', '--tier', '--timeout', '--only', '--lens', '--out', '--plan-file', '--prompt-file', '--seed']);
|
|
70
|
+
for (let i = 0; i < argv.length; i++) {
|
|
71
|
+
const a = argv[i];
|
|
72
|
+
if (a === '--preflight') opts.preflight = true;
|
|
73
|
+
else if (a === '--no-wrap') opts.noWrap = true;
|
|
74
|
+
else if (takesValue.has(a)) {
|
|
75
|
+
const v = argv[++i];
|
|
76
|
+
if (v === undefined) throw new UsageError(`${a} needs a value`);
|
|
77
|
+
const key = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
78
|
+
opts[key] = a === '--timeout' || a === '--seed' ? Number(v) : v;
|
|
79
|
+
} else throw new UsageError(`unknown argument: ${a}`);
|
|
80
|
+
}
|
|
81
|
+
if (!['critique', 'poll'].includes(opts.mode)) throw new UsageError('--mode is critique or poll');
|
|
82
|
+
if (!['strong', 'fast'].includes(opts.tier)) throw new UsageError('--tier is strong or fast');
|
|
83
|
+
if (!Number.isFinite(opts.timeout) || opts.timeout <= 0) throw new UsageError('--timeout must be a positive number of seconds');
|
|
84
|
+
return opts;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function requestedClis(opts) {
|
|
88
|
+
if (!opts.only) return [...ALL_CLIS];
|
|
89
|
+
const clis = opts.only.split(',').map((c) => c.trim()).filter(Boolean);
|
|
90
|
+
const bad = clis.filter((c) => !ALL_CLIS.includes(c));
|
|
91
|
+
if (bad.length) throw new UsageError(`unknown CLI in --only: ${bad.join(', ')} (valid: ${ALL_CLIS.join(', ')})`);
|
|
92
|
+
return [...new Set(clis)];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Launching. No shell anywhere: an argv array plus stdin, in an empty scratch directory.
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function childEnv() {
|
|
100
|
+
// Appended, so a CLI already on PATH wins; this only rescues the usual user-install spots.
|
|
101
|
+
const extra = [path.join(homedir(), '.local', 'bin'), path.join(homedir(), '.local', 'share', 'node', 'bin')];
|
|
102
|
+
return { ...process.env, PATH: [process.env.PATH ?? '', ...extra].filter(Boolean).join(path.delimiter) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Runs one CLI. Resolves {ok, stdout, stderr, cause}; never rejects. */
|
|
106
|
+
function runCli(cmd, args, stdin, timeoutSec) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const scratch = mkdtempSync(path.join(tmpdir(), 'ctxr-second-opinion-'));
|
|
109
|
+
let settled = false;
|
|
110
|
+
let stdout = '';
|
|
111
|
+
let stderr = '';
|
|
112
|
+
let timedOut = false;
|
|
113
|
+
const done = (result) => {
|
|
114
|
+
if (settled) return;
|
|
115
|
+
settled = true;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
try { rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
118
|
+
resolve({ stdout, stderr, ...result });
|
|
119
|
+
};
|
|
120
|
+
let child;
|
|
121
|
+
try {
|
|
122
|
+
child = spawn(cmd, args, { cwd: scratch, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return done({ ok: false, cause: `could not start ${cmd}: ${err.message}` });
|
|
125
|
+
}
|
|
126
|
+
const killGroup = (signal) => { try { process.kill(-child.pid, signal); } catch { /* already gone */ } };
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
timedOut = true;
|
|
129
|
+
killGroup('SIGTERM');
|
|
130
|
+
setTimeout(() => killGroup('SIGKILL'), 2000).unref();
|
|
131
|
+
}, timeoutSec * 1000);
|
|
132
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
133
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
134
|
+
child.on('error', (err) => done({ ok: false, cause: err.code === 'ENOENT' ? `'${cmd}' not found on PATH` : `could not start ${cmd}: ${err.message}` }));
|
|
135
|
+
child.on('close', (code) => {
|
|
136
|
+
if (timedOut) return done({ ok: false, cause: `timed out after ${timeoutSec}s` });
|
|
137
|
+
if (code !== 0) return done({ ok: false, cause: `exit ${code}: ${firstLines(stderr || stdout, 3)}` });
|
|
138
|
+
return done({ ok: true });
|
|
139
|
+
});
|
|
140
|
+
child.stdin.on('error', () => { /* the CLI closed stdin early; its exit code says why */ });
|
|
141
|
+
child.stdin.end(stdin ?? '');
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const firstLines = (text, n) => text.trim().split('\n').slice(0, n).join(' | ').slice(0, 400);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How each family is contained. Only claude and codex offer a mode that removes the ability to
|
|
149
|
+
* act; agy does not (its headless plan mode still wrote a file), so it is held to an empty working
|
|
150
|
+
* directory and an untrusted plan should run without it.
|
|
151
|
+
*/
|
|
152
|
+
function buildInvocation(cli, prompt, tier) {
|
|
153
|
+
const t = tiers()[tier][cli];
|
|
154
|
+
if (cli === 'claude') {
|
|
155
|
+
const args = ['-p', '--tools', '', '--model', t.model, '--no-session-persistence', '--disable-slash-commands', '--output-format', 'json'];
|
|
156
|
+
if (t.effort) args.push('--effort', t.effort);
|
|
157
|
+
return { cmd: 'claude', args, stdin: prompt };
|
|
158
|
+
}
|
|
159
|
+
if (cli === 'codex') {
|
|
160
|
+
const args = ['exec', '--skip-git-repo-check', '--sandbox', 'read-only', '--ephemeral', '--color', 'never'];
|
|
161
|
+
if (t.effort) args.push('-c', `model_reasoning_effort=${t.effort}`);
|
|
162
|
+
args.push('-');
|
|
163
|
+
return { cmd: 'codex', args, stdin: prompt };
|
|
164
|
+
}
|
|
165
|
+
return { cmd: 'agy', args: ['-p', prompt, '--sandbox', '--disable-slash-commands', '--model', t.model], stdin: '' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Normalizes a CLI's raw output to answer text plus the model it says it ran. */
|
|
169
|
+
function normalize(cli, raw) {
|
|
170
|
+
if (cli === 'claude') {
|
|
171
|
+
try {
|
|
172
|
+
const j = JSON.parse(raw.stdout);
|
|
173
|
+
const models = Object.keys(j.modelUsage ?? {});
|
|
174
|
+
return { text: String(j.result ?? '').trim(), model: models.length ? models.join(', ') : null };
|
|
175
|
+
} catch {
|
|
176
|
+
return { text: raw.stdout.trim(), model: null };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (cli === 'codex') {
|
|
180
|
+
const model = /^model:\s*(.+)$/m.exec(raw.stdout + '\n' + raw.stderr)?.[1]?.trim() ?? null;
|
|
181
|
+
return { text: stripCodexNoise(raw.stdout), model };
|
|
182
|
+
}
|
|
183
|
+
return { text: raw.stdout.trim(), model: null };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Codex wraps its answer in a session banner and repeats it after a `tokens used` trailer.
|
|
188
|
+
* The answer starts at the first line of the required shape; failing that, the trailer is cut and
|
|
189
|
+
* everything else is kept, so real content is never eaten.
|
|
190
|
+
*/
|
|
191
|
+
function stripCodexNoise(text) {
|
|
192
|
+
const lines = text.split('\n');
|
|
193
|
+
const start = lines.findIndex((l) => /^## (Role|Verdict):/.test(l));
|
|
194
|
+
let body = start >= 0 ? lines.slice(start) : lines;
|
|
195
|
+
const trailer = body.findIndex((l) => /^tokens used\b/i.test(l.trim()));
|
|
196
|
+
if (trailer >= 0) body = body.slice(0, trailer);
|
|
197
|
+
return body.join('\n').trim();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Critique validation.
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
const normalizeWs = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
205
|
+
|
|
206
|
+
/** Step ids a plan numbers as S1, S2, ... at the start of a line, list item, or heading. */
|
|
207
|
+
export function planStepIds(plan) {
|
|
208
|
+
const ids = new Set();
|
|
209
|
+
for (const m of plan.matchAll(/^[ \t]*(?:[-*][ \t]+|#{1,6}[ \t]+)?\**S(\d+)\b/gm)) ids.add(`S${m[1]}`);
|
|
210
|
+
return ids;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function parseCritique(text) {
|
|
214
|
+
const verdict = /^## Verdict:\s*(APPROVE_WITH_CHANGES|APPROVE|REJECT)\b/m.exec(text)?.[1] ?? null;
|
|
215
|
+
const findings = [];
|
|
216
|
+
const blocks = text.split(/^### F\d+\b/m).slice(1);
|
|
217
|
+
for (const block of blocks) {
|
|
218
|
+
// Brackets around the severity are what the contract asks for, and models drop them: accept both.
|
|
219
|
+
const head = /^\s*\[?(critical|major|minor)\]?\s*(?:confidence\s*[=:]\s*([01](?:\.\d+)?))?/i.exec(block);
|
|
220
|
+
const evidence = /^Evidence:\s*(.+)$/im.exec(block)?.[1] ?? '';
|
|
221
|
+
findings.push({
|
|
222
|
+
severity: head?.[1]?.toLowerCase() ?? null,
|
|
223
|
+
confidence: head?.[2] !== undefined ? Number(head[2]) : null,
|
|
224
|
+
evidence,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return { verdict, findings };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** True if a finding's evidence points at something the plan actually contains. */
|
|
231
|
+
export function evidenceResolves(evidence, plan, stepIds) {
|
|
232
|
+
for (const m of evidence.matchAll(/\bS(\d+)\b/g)) if (stepIds.has(`S${m[1]}`)) return true;
|
|
233
|
+
const haystack = normalizeWs(plan);
|
|
234
|
+
for (const m of evidence.matchAll(/["“`]([^"”`]{12,})["”`]/g)) if (haystack.includes(normalizeWs(m[1]))) return true;
|
|
235
|
+
for (const m of evidence.matchAll(/([\w./-]+\.[a-z]{1,6}):\d+/gi)) if (plan.includes(m[1])) return true;
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** valid | blind | failed, with the cause when failed. */
|
|
240
|
+
export function judgeCritique(text, plan) {
|
|
241
|
+
if (!text) return { status: 'failed', cause: 'empty output', verdict: null, findings: [], veto: false };
|
|
242
|
+
const { verdict, findings } = parseCritique(text);
|
|
243
|
+
if (!verdict) return { status: 'failed', cause: 'output has no "## Verdict:" line', verdict, findings, veto: false };
|
|
244
|
+
if (verdict !== 'APPROVE' && findings.length === 0) return { status: 'failed', cause: `${verdict} with no parseable findings`, verdict, findings, veto: false };
|
|
245
|
+
const stepIds = planStepIds(plan);
|
|
246
|
+
const resolved = findings.filter((f) => evidenceResolves(f.evidence, plan, stepIds));
|
|
247
|
+
// An APPROVE with no findings has nothing to cite and is valid. Findings that exist but resolve to
|
|
248
|
+
// nothing in the plan mean the critic answered without reading it.
|
|
249
|
+
const blind = findings.length > 0 && resolved.length === 0;
|
|
250
|
+
const veto = verdict === 'REJECT' && findings.some((f) => f.severity === 'critical' && resolved.includes(f));
|
|
251
|
+
return { status: blind ? 'blind' : 'valid', cause: blind ? 'no finding cites anything in the plan' : null, verdict, findings, veto };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Prompts.
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
const read = (...p) => readFileSync(path.join(HERE, ...p), 'utf8');
|
|
259
|
+
|
|
260
|
+
function critiquePrompt(role, plan) {
|
|
261
|
+
return `${read('critic-contract.md').trimEnd()}\n\n---\n${read('personas', `${role}.md`).trimEnd()}\n\nPLAN:\n${plan}\n`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function loadLensRoster() {
|
|
265
|
+
const roster = new Map();
|
|
266
|
+
for (const m of read('lenses.md').matchAll(/^- \*\*([a-z-]+)\*\* — (.+)$/gm)) roster.set(m[1], m[2].trim());
|
|
267
|
+
return roster;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function lensBlock(spec) {
|
|
271
|
+
const roster = loadLensRoster();
|
|
272
|
+
const specs = spec.split(',').map((s) => s.trim()).filter(Boolean);
|
|
273
|
+
const described = [];
|
|
274
|
+
for (const s of specs) {
|
|
275
|
+
if (roster.has(s.toLowerCase())) described.push(`- ${s.toLowerCase()} — ${roster.get(s.toLowerCase())}`);
|
|
276
|
+
else if (s.includes(':')) {
|
|
277
|
+
const [name, ...rest] = s.split(':');
|
|
278
|
+
described.push(`- ${name.trim()} — ${rest.join(':').trim()}`);
|
|
279
|
+
} else throw new UsageError(`unknown lens "${s}" (known: ${[...roster.keys()].join(', ')}; or pass "Name:description")`);
|
|
280
|
+
}
|
|
281
|
+
if (described.length < 2 || described.length > 3) throw new UsageError('--lens takes two or three lenses; keep them orthogonal');
|
|
282
|
+
return described.join('\n');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const POLL_TAIL = `
|
|
286
|
+
|
|
287
|
+
Please answer:
|
|
288
|
+
1. Your pick or ranking
|
|
289
|
+
2. Two or three reasons
|
|
290
|
+
3. Anything surprising about this choice
|
|
291
|
+
4. What you would recommend instead, if anything
|
|
292
|
+
|
|
293
|
+
Be direct and brief: about 200 to 300 words.`;
|
|
294
|
+
|
|
295
|
+
function pollPrompt(question, opts) {
|
|
296
|
+
if (opts.lens) {
|
|
297
|
+
return `You are running a taste panel. Answer the question below once as EACH lens. Reason only from that lens's values; do not blend them. A lens judges which option is better, never whether it will work.
|
|
298
|
+
|
|
299
|
+
Lenses:
|
|
300
|
+
${lensBlock(opts.lens)}
|
|
301
|
+
|
|
302
|
+
QUESTION:
|
|
303
|
+
${question}
|
|
304
|
+
|
|
305
|
+
For EACH lens output exactly:
|
|
306
|
+
PICK: <option>
|
|
307
|
+
WHY: <two sentences, that lens's values only>
|
|
308
|
+
REJECT: <the option this lens most dislikes, and a four-word reason>
|
|
309
|
+
|
|
310
|
+
Only the lens blocks. No preamble, no summary.
|
|
311
|
+
`;
|
|
312
|
+
}
|
|
313
|
+
return opts.noWrap ? question : question + POLL_TAIL;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Anonymizing.
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
function rng(seed) {
|
|
321
|
+
if (seed === null || Number.isNaN(seed)) return Math.random;
|
|
322
|
+
let a = seed >>> 0; // mulberry32: deterministic, for tests
|
|
323
|
+
return () => {
|
|
324
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
325
|
+
let t = a;
|
|
326
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
327
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
328
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function shuffled(items, random) {
|
|
333
|
+
const a = [...items];
|
|
334
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
335
|
+
const j = Math.floor(random() * (i + 1));
|
|
336
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
337
|
+
}
|
|
338
|
+
return a;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Modes.
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
async function preflight(clis, opts) {
|
|
346
|
+
let allUp = true;
|
|
347
|
+
const results = await Promise.all(
|
|
348
|
+
clis.map(async (cli) => {
|
|
349
|
+
const spec = FLAGS_USED[cli];
|
|
350
|
+
const help = await runCli(cli, spec.helpArgs, '', 30);
|
|
351
|
+
if (!help.ok) return { cli, ok: false, detail: help.cause };
|
|
352
|
+
const helpText = help.stdout + help.stderr;
|
|
353
|
+
const missing = spec.flags.filter((f) => !helpText.includes(f));
|
|
354
|
+
const inv = buildInvocation(cli, 'say PONG only', 'fast');
|
|
355
|
+
const ping = await runCli(inv.cmd, inv.args, inv.stdin, Math.min(opts.timeout, 60));
|
|
356
|
+
if (!ping.ok) return { cli, ok: false, detail: ping.cause };
|
|
357
|
+
const { text } = normalize(cli, ping);
|
|
358
|
+
if (!/pong/i.test(text)) return { cli, ok: false, detail: `unexpected reply: ${firstLines(text, 2)}` };
|
|
359
|
+
if (missing.length) return { cli, ok: false, detail: `answers, but its --help no longer lists: ${missing.join(' ')}` };
|
|
360
|
+
return { cli, ok: true, detail: 'PONG' };
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
for (const r of results) {
|
|
364
|
+
if (!r.ok) allUp = false;
|
|
365
|
+
console.log(`[${r.ok ? 'OK ' : 'DOWN'}] ${r.cli.padEnd(7)} ${r.detail}`);
|
|
366
|
+
}
|
|
367
|
+
if (!allUp) console.log('\nAt least one CLI is down. Re-authenticate it, or run with --only on the ones that are up.');
|
|
368
|
+
return allUp ? 0 : 2;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function readInput(opts, flag) {
|
|
372
|
+
const file = flag === 'plan' ? opts.planFile : opts.promptFile;
|
|
373
|
+
if (file) {
|
|
374
|
+
try { return readFileSync(file, 'utf8'); } catch (err) { throw new UsageError(`cannot read ${file}: ${err.message}`); }
|
|
375
|
+
}
|
|
376
|
+
if (!process.stdin.isTTY) {
|
|
377
|
+
const text = readFileSync(0, 'utf8');
|
|
378
|
+
if (text.trim()) return text;
|
|
379
|
+
}
|
|
380
|
+
throw new UsageError(`no input: pass --${flag === 'plan' ? 'plan' : 'prompt'}-file PATH or pipe it on stdin`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function outDir(opts) {
|
|
384
|
+
const dir = opts.out ?? mkdtempSync(path.join(tmpdir(), 'ctxr-second-opinion-out-'));
|
|
385
|
+
mkdirSync(path.join(dir, 'raw'), { recursive: true });
|
|
386
|
+
return dir;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function runCritique(opts, clis) {
|
|
390
|
+
const plan = readInput(opts, 'plan');
|
|
391
|
+
const roles = ROLES.filter((r) => clis.includes(ROLE_CLI[r]));
|
|
392
|
+
const dir = outDir(opts);
|
|
393
|
+
console.error(`plan: ${plan.length} characters (about ${Math.ceil(plan.length / 4)} tokens) · tier: ${opts.tier} · critics: ${roles.length} · each may take 1-3 minutes`);
|
|
394
|
+
|
|
395
|
+
const runs = await Promise.all(
|
|
396
|
+
roles.map(async (role) => {
|
|
397
|
+
const cli = ROLE_CLI[role];
|
|
398
|
+
const prompt = critiquePrompt(role, plan);
|
|
399
|
+
const started = Date.now();
|
|
400
|
+
let raw;
|
|
401
|
+
if (cli === 'agy' && Buffer.byteLength(prompt) > MAX_ARGV_BYTES) {
|
|
402
|
+
raw = { ok: false, stdout: '', stderr: '', cause: `prompt is ${Buffer.byteLength(prompt)} bytes; agy takes it as one argument and the limit is ${MAX_ARGV_BYTES}` };
|
|
403
|
+
} else {
|
|
404
|
+
const inv = buildInvocation(cli, prompt, opts.tier);
|
|
405
|
+
raw = await runCli(inv.cmd, inv.args, inv.stdin, opts.timeout);
|
|
406
|
+
}
|
|
407
|
+
writeFileSync(path.join(dir, 'raw', `${role}-${cli}.txt`), `${raw.stdout}\n--- stderr ---\n${raw.stderr}\n`);
|
|
408
|
+
const base = { role, cli, duration_ms: Date.now() - started };
|
|
409
|
+
if (!raw.ok) return { ...base, status: 'failed', cause: raw.cause, model: null, text: '', verdict: null, findings: 0, veto: false };
|
|
410
|
+
const { text, model } = normalize(cli, raw);
|
|
411
|
+
const judged = judgeCritique(text, plan);
|
|
412
|
+
return { ...base, status: judged.status, cause: judged.cause, model, text, verdict: judged.verdict, findings: judged.findings.length, veto: judged.veto };
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// Letters go to every critique that produced text (valid or blind), in an order that does not follow the roster.
|
|
417
|
+
const answered = runs.filter((r) => r.status !== 'failed');
|
|
418
|
+
const letters = shuffled(answered, rng(opts.seed)).map((r, i) => ({ ...r, letter: String.fromCharCode(65 + i) }));
|
|
419
|
+
for (const r of letters) writeFileSync(path.join(dir, `critique-${r.letter}.md`), `${r.text}\n`);
|
|
420
|
+
|
|
421
|
+
const manifest = {
|
|
422
|
+
mode: 'critique',
|
|
423
|
+
tier: opts.tier,
|
|
424
|
+
quorum: QUORUM,
|
|
425
|
+
critiques: runs.map((r) => ({
|
|
426
|
+
letter: letters.find((l) => l.role === r.role)?.letter ?? null,
|
|
427
|
+
role: r.role,
|
|
428
|
+
cli: r.cli,
|
|
429
|
+
model_reported: r.model,
|
|
430
|
+
status: r.status,
|
|
431
|
+
cause: r.cause,
|
|
432
|
+
verdict: r.verdict,
|
|
433
|
+
findings: r.findings,
|
|
434
|
+
veto: r.veto,
|
|
435
|
+
duration_ms: r.duration_ms,
|
|
436
|
+
})),
|
|
437
|
+
};
|
|
438
|
+
const valid = runs.filter((r) => r.status === 'valid').length;
|
|
439
|
+
manifest.valid = valid;
|
|
440
|
+
manifest.partial = valid < QUORUM;
|
|
441
|
+
writeFileSync(path.join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
442
|
+
|
|
443
|
+
// stdout names letters, never roles or models; a failed critic has no letter and is named by CLI, since that is what to fix.
|
|
444
|
+
console.log(`out: ${dir}`);
|
|
445
|
+
console.log(`valid: ${valid} blind: ${runs.filter((r) => r.status === 'blind').length} failed: ${runs.filter((r) => r.status === 'failed').length}`);
|
|
446
|
+
for (const r of [...letters].sort((a, b) => a.letter.localeCompare(b.letter))) {
|
|
447
|
+
console.log(` ${r.letter} ${r.status} ${r.verdict ?? '-'} findings=${r.findings}${r.veto ? ' veto' : ''}`);
|
|
448
|
+
}
|
|
449
|
+
for (const r of runs.filter((x) => x.status === 'failed')) console.log(` failed ${r.cli} ${r.cause}`);
|
|
450
|
+
if (manifest.partial) console.log(`PARTIAL: fewer than ${QUORUM} valid critiques. Report this as a partial result, not a consensus.`);
|
|
451
|
+
console.log(`Read the critique files first; open ${path.join(dir, 'manifest.json')} last.`);
|
|
452
|
+
return manifest.partial ? 2 : 0;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function runPoll(opts, clis) {
|
|
456
|
+
const question = readInput(opts, 'prompt');
|
|
457
|
+
const prompt = pollPrompt(question, opts);
|
|
458
|
+
const dir = outDir(opts);
|
|
459
|
+
console.error(`question: ${question.length} characters · tier: ${opts.tier} · models: ${clis.length}${opts.lens ? ` · lenses: ${opts.lens}` : ''}`);
|
|
460
|
+
const runs = await Promise.all(
|
|
461
|
+
clis.map(async (cli) => {
|
|
462
|
+
let raw;
|
|
463
|
+
if (cli === 'agy' && Buffer.byteLength(prompt) > MAX_ARGV_BYTES) raw = { ok: false, stdout: '', stderr: '', cause: `prompt exceeds the ${MAX_ARGV_BYTES}-byte argument limit` };
|
|
464
|
+
else {
|
|
465
|
+
const inv = buildInvocation(cli, prompt, opts.tier);
|
|
466
|
+
raw = await runCli(inv.cmd, inv.args, inv.stdin, opts.timeout);
|
|
467
|
+
}
|
|
468
|
+
writeFileSync(path.join(dir, 'raw', `poll-${cli}.txt`), `${raw.stdout}\n--- stderr ---\n${raw.stderr}\n`);
|
|
469
|
+
if (!raw.ok) return { cli, status: 'failed', cause: raw.cause, model: null, text: '' };
|
|
470
|
+
const { text, model } = normalize(cli, raw);
|
|
471
|
+
if (!text) return { cli, status: 'failed', cause: 'empty output', model, text };
|
|
472
|
+
return { cli, status: 'valid', cause: null, model, text };
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
// A poll is read model against model (the lens grid's columns are models), so files are named by model here.
|
|
476
|
+
for (const r of runs.filter((x) => x.status === 'valid')) writeFileSync(path.join(dir, `poll-${r.cli}.md`), `${r.text}\n`);
|
|
477
|
+
const valid = runs.filter((r) => r.status === 'valid').length;
|
|
478
|
+
const manifest = { mode: 'poll', tier: opts.tier, lens: opts.lens, quorum: QUORUM, valid, partial: valid < QUORUM, answers: runs.map(({ text: _t, ...rest }) => rest) };
|
|
479
|
+
writeFileSync(path.join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
480
|
+
console.log(`out: ${dir}`);
|
|
481
|
+
console.log(`valid: ${valid} failed: ${runs.length - valid}`);
|
|
482
|
+
for (const r of runs) console.log(` ${r.cli} ${r.status}${r.cause ? ` ${r.cause}` : ''}${r.model ? ` model=${r.model}` : ''}`);
|
|
483
|
+
if (manifest.partial) console.log(`PARTIAL: fewer than ${QUORUM} answers. Report it as a partial result.`);
|
|
484
|
+
return manifest.partial ? 2 : 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function main() {
|
|
488
|
+
let opts;
|
|
489
|
+
try {
|
|
490
|
+
opts = parseArgs(process.argv.slice(2));
|
|
491
|
+
const clis = requestedClis(opts);
|
|
492
|
+
if (opts.preflight) return await preflight(clis, opts);
|
|
493
|
+
if (clis.length < QUORUM) throw new UsageError(`--only names ${clis.length} CLI; a second opinion needs at least ${QUORUM}`);
|
|
494
|
+
return opts.mode === 'critique' ? await runCritique(opts, clis) : await runPoll(opts, clis);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
if (!(err instanceof UsageError)) throw err;
|
|
497
|
+
console.error(`run.mjs: ${err.message}`);
|
|
498
|
+
return 1;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
503
|
+
process.exitCode = await main();
|
|
504
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Get an independent read from models built by different labs before you commit to something you cannot
|
|
2
|
+
cheaply take back, or when your own taste is one vote among several. A second opinion from the same model
|
|
3
|
+
family you are running as mostly repeats you, so this skill sends the work to three command-line tools backed
|
|
4
|
+
by three different families and hands you their answers. It does not decide anything and it never carries out
|
|
5
|
+
what it reviews. Weighing the answers is your job, and steps 4 and 5 are how.
|
|
6
|
+
|
|
7
|
+
## 1. Critique, poll, or just answer?
|
|
8
|
+
|
|
9
|
+
- **Critique** when the question is *will this work?* A plan with several steps, an architectural or
|
|
10
|
+
data-model choice, anything that deletes, force-pushes, sends, spends money, or touches secrets. Also a
|
|
11
|
+
recovery sequence run against a broken system: those plans look fine right up to the step that makes it
|
|
12
|
+
worse.
|
|
13
|
+
- **Poll** when the question is *which of these is better?* and being wrong costs mild regret: a name, a
|
|
14
|
+
framing, a piece of copy, which of several tools fits. If all three agreeing would settle it, it is a poll.
|
|
15
|
+
If you would still want each reviewer's top objections and a suggested edit, it is a critique.
|
|
16
|
+
- **Answer directly** when there is one right answer, when the operator has already decided and wants
|
|
17
|
+
confirmation, or when the work is read-only investigation.
|
|
18
|
+
|
|
19
|
+
Push back when asked for a critique of a taste question. "Which domain suffix should I pick?" is a poll. "Should
|
|
20
|
+
I move my mail to a custom domain?" is a critique: it has steps, an order, and parts that cannot be undone. Say
|
|
21
|
+
which you are running and why in one line before you start.
|
|
22
|
+
|
|
23
|
+
A poll that asks a lens to judge whether something will *work* has become a critique. Stop and use that mode.
|
|
24
|
+
|
|
25
|
+
## 2. Check the tools before spending anything
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
node __SKILLS_PATH__/ctxr-second-opinion/run.mjs --preflight
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Each of the three should report `OK` within about twenty seconds. The preflight also confirms that each
|
|
32
|
+
tool still accepts the options the runner passes, because a renamed option makes a reviewer silently run at
|
|
33
|
+
default settings and nothing else notices.
|
|
34
|
+
|
|
35
|
+
If one is down, stop and give the operator the two real choices: sign that tool in and re-run, or continue
|
|
36
|
+
with the others by adding `--only` and naming them. Do not quietly run with one model. A single model is not a
|
|
37
|
+
second opinion, and the runner refuses to start with fewer than two.
|
|
38
|
+
|
|
39
|
+
Failure signatures: `Not logged in` means Claude needs `/login`; a `401` or an invalid refresh token from
|
|
40
|
+
Codex means `codex login`; an agy call that hangs usually means its sign-in expired and it needs one
|
|
41
|
+
interactive run.
|
|
42
|
+
|
|
43
|
+
If the plan came from someone other than the operator, leave agy out with `--only claude,codex`. Claude
|
|
44
|
+
runs with no tools at all and Codex in a read-only sandbox. Agy has no mode that removes its ability to act:
|
|
45
|
+
it runs in an empty scratch directory with terminal restrictions, which contains an accident and not an attack.
|
|
46
|
+
|
|
47
|
+
## 3. Run a critique
|
|
48
|
+
|
|
49
|
+
Write the plan to a file. The reviewers can only cite what is written down, and they cite by step number:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
# Plan: <one-line goal>
|
|
53
|
+
|
|
54
|
+
## Context
|
|
55
|
+
<two to five lines: what is being attempted and what constrains it>
|
|
56
|
+
|
|
57
|
+
## Steps
|
|
58
|
+
S1. <step>
|
|
59
|
+
S2. <step>
|
|
60
|
+
|
|
61
|
+
## Assumptions
|
|
62
|
+
- <what the plan takes for granted>
|
|
63
|
+
|
|
64
|
+
## Reversibility
|
|
65
|
+
- Reversible: <steps>
|
|
66
|
+
- Irreversible: <steps>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then run it:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
node __SKILLS_PATH__/ctxr-second-opinion/run.mjs --mode critique --plan-file <plan> --out <dir>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Three reviewers each read the plan alone: structure and ordering, failure modes and trust boundaries,
|
|
76
|
+
simplicity and cost. None sees another's answer. That is deliberate: reviewers who read each other converge
|
|
77
|
+
on whoever spoke first, and a correct one gets talked out of it. Do not add a second round.
|
|
78
|
+
|
|
79
|
+
The runner prints the letters it wrote, whether each is valid or blind, and any reviewer that failed. It
|
|
80
|
+
never prints which model wrote which letter. A **blind** critique returned a well-formed verdict but cited
|
|
81
|
+
nothing in the plan, so it was written without reading it; it does not count. **Partial** means fewer than
|
|
82
|
+
two valid critiques: say so at the top of your answer and do not call anything a consensus.
|
|
83
|
+
|
|
84
|
+
Add `--tier fast` to drop each family to a cheaper model, for a plan that is small and easy to redo. The
|
|
85
|
+
default tier is right for anything the operator will act on. A default critique usually takes under a minute
|
|
86
|
+
and costs a few tens of cents. Claude's highest effort setting (`CTXR_SECOND_OPINION_CLAUDE_EFFORT=max`) is
|
|
87
|
+
a deliberate deep pass, not a default: on the same short plan it took over eight minutes where the default
|
|
88
|
+
took half a minute, and it is the one setting that can reach the runner's ten-minute limit. Start such a run
|
|
89
|
+
in the background and tell the operator it is under way.
|
|
90
|
+
|
|
91
|
+
## 4. Synthesize on the letters first
|
|
92
|
+
|
|
93
|
+
Read the `critique-A`, `critique-B` and `critique-C` files and write your synthesis before you open
|
|
94
|
+
`manifest.json`. You share a model family with one of the reviewers, and models rate their own family's
|
|
95
|
+
writing higher. Reading letters first is what keeps that from steering you. Then open the manifest and
|
|
96
|
+
attach roles and models to the letters in the final report.
|
|
97
|
+
|
|
98
|
+
Write it in this order:
|
|
99
|
+
|
|
100
|
+
- **Verdicts.** One line per letter, and any blind or failed reviewer stated plainly, not buried.
|
|
101
|
+
- **Consensus (all agree).** Highest signal. When all three independently flag the same simplification,
|
|
102
|
+
take it whole; do not keep a reduced version of complexity all three called wrong.
|
|
103
|
+
- **Majority (two of three).** Say whether the two agree on the fix or only on the diagnosis. Two
|
|
104
|
+
reviewers who name the same problem often propose different repairs: choose on merit, meaning which
|
|
105
|
+
repair keeps more of what you want (granular rollback, fewer side effects, smaller blast radius), and do
|
|
106
|
+
not default to the majority's.
|
|
107
|
+
- **Evidence-backed dissent.** A lone finding that cites the plan gets its own section and stays there. The
|
|
108
|
+
minority is right often enough that overruling it on your own authority is a net loss. Overrule only by
|
|
109
|
+
showing what the dissent missed.
|
|
110
|
+
- **Vetoes.** A REJECT that carries a critical finding is stated as a veto, first, with its evidence.
|
|
111
|
+
- **Revised plan**, and **what changed and why**: each change tied to the letter and finding that drove it.
|
|
112
|
+
- **Recommendation**: proceed with the revision, revise further, or stop.
|
|
113
|
+
|
|
114
|
+
Where the reviewers pull apart on ceremony, one wanting more safeguards and another fewer, decide by blast
|
|
115
|
+
radius. If being wrong is expensive to undo, side with more safeguards. If it is cheap to undo, side with
|
|
116
|
+
fewer. Where a safeguard needs machinery an earlier round already turned down (a background job, a state file
|
|
117
|
+
kept across sessions, a hook), side with fewer: adding it now defeats the earlier decision.
|
|
118
|
+
|
|
119
|
+
When you have critiqued this plan before, reread that synthesis first. The reviewers only see the current text
|
|
120
|
+
and drift toward their own local optimum. Name any proposal that reverses an earlier decision, and hold the
|
|
121
|
+
earlier line unless the new critique brings evidence the old one lacked.
|
|
122
|
+
|
|
123
|
+
## 5. Say what you are biased about, then stop
|
|
124
|
+
|
|
125
|
+
State in the report that one reviewer shares your model family, and say when that reviewer's finding is what
|
|
126
|
+
drove the recommendation. Name any reviewer that ran a different model from the one you expected: the
|
|
127
|
+
manifest records the model each tool reported.
|
|
128
|
+
|
|
129
|
+
Then hand the synthesis to the operator and stop. This skill never executes a step of the plan it critiqued.
|
|
130
|
+
If the operator says to go, proceed with the revised plan. If they push back, treat that as a fourth critique
|
|
131
|
+
and run the skill again on the changed plan. It is a new run and not a continuation.
|
|
132
|
+
|
|
133
|
+
To record the outcome in the plan itself, add a section near the top headed `## Revisions after critique`
|
|
134
|
+
that says it overrides anything inconsistent below, one numbered item per change, each naming the finding
|
|
135
|
+
behind it. Leave the original text in place as the record of what was reviewed. Run one more critique after a
|
|
136
|
+
plan has been substantially rewritten and before you carry it out: new wording creates new gaps. Do not rerun
|
|
137
|
+
for a typo.
|
|
138
|
+
|
|
139
|
+
## 6. Run a poll
|
|
140
|
+
|
|
141
|
+
Write the question and the options to a file, then:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
node __SKILLS_PATH__/ctxr-second-opinion/run.mjs --mode poll --prompt-file <question>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The same question goes to all three models, each answering with a pick, reasons, and anything surprising.
|
|
148
|
+
This mode names its files by model, because what you are reading is whether the models agree.
|
|
149
|
+
|
|
150
|
+
To get breadth as well as model diversity, add `--lens` with two or three taste lenses. Each model then
|
|
151
|
+
answers as every lens in one response, giving a lens-by-model grid. The built-in lenses are minimalist,
|
|
152
|
+
marketer, contrarian, end-user and brand-strategist. A store's own lens goes in as `Name:description`, for
|
|
153
|
+
example `--lens "minimalist,Plainspoken:blunt and substance first"`. Choose lenses that pull in different
|
|
154
|
+
directions; if two would obviously agree, drop one.
|
|
155
|
+
|
|
156
|
+
Read a lens grid on both axes. Down a column (one lens across the three models) agreement means that
|
|
157
|
+
taste value is robust to the model. Across a row (one model across the lenses) disagreement is where the
|
|
158
|
+
real decision is. Give your own recommendation and say which axis it rests on.
|
|
159
|
+
|
|
160
|
+
Report the answers, where they agree, where they split, and your read. The answers are evidence and not a
|
|
161
|
+
vote: one dissent with a substantive reason can outweigh two agreements. If two of the three agreeing models
|
|
162
|
+
include your own family, say so.
|
|
163
|
+
|
|
164
|
+
## 7. When something goes wrong
|
|
165
|
+
|
|
166
|
+
- A reviewer that timed out, exited non-zero, or returned no verdict is `failed` and carries its cause in
|
|
167
|
+
the manifest. Report it. Do not rerun that one reviewer on a different model to fill the gap.
|
|
168
|
+
- Do not poll from inside a critique to settle a quick doubt. If you want a gut check mid-critique, answer it
|
|
169
|
+
yourself.
|
|
170
|
+
- Model names change faster than releases. When a default is stale, set
|
|
171
|
+
`CTXR_SECOND_OPINION_CLAUDE_MODEL`, `CTXR_SECOND_OPINION_CLAUDE_EFFORT`, `CTXR_SECOND_OPINION_CODEX_EFFORT`
|
|
172
|
+
or `CTXR_SECOND_OPINION_AGY_MODEL` in the environment for the run. Nothing about the lineup lives in the store's configuration.
|
|
173
|
+
- The scratch files under the output directory hold each reviewer's raw output and are safe to delete when the
|
|
174
|
+
operator has the synthesis. They can contain the plan, so do not commit them.
|