ctxr-cli 0.33.0 → 0.34.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 +15 -1
- package/dist/commands/archive.d.ts +5 -4
- package/dist/commands/archive.d.ts.map +1 -1
- package/dist/commands/archive.js +7 -7
- package/dist/commands/archive.js.map +1 -1
- package/dist/commands/catalog-show.d.ts +8 -0
- package/dist/commands/catalog-show.d.ts.map +1 -1
- package/dist/commands/catalog-show.js +29 -2
- package/dist/commands/catalog-show.js.map +1 -1
- package/dist/commands/context-gather.d.ts.map +1 -1
- package/dist/commands/context-gather.js +3 -2
- package/dist/commands/context-gather.js.map +1 -1
- package/dist/commands/doctor.d.ts +5 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +4 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/graph-build.d.ts +4 -1
- package/dist/commands/graph-build.d.ts.map +1 -1
- package/dist/commands/graph-build.js +14 -5
- package/dist/commands/graph-build.js.map +1 -1
- package/dist/commands/land.d.ts +27 -7
- package/dist/commands/land.d.ts.map +1 -1
- package/dist/commands/land.js +307 -32
- package/dist/commands/land.js.map +1 -1
- package/dist/commands/publish-gather.d.ts.map +1 -1
- package/dist/commands/publish-gather.js +2 -3
- package/dist/commands/publish-gather.js.map +1 -1
- package/dist/commands/review-list.d.ts +19 -5
- package/dist/commands/review-list.d.ts.map +1 -1
- package/dist/commands/review-list.js +55 -6
- package/dist/commands/review-list.js.map +1 -1
- package/dist/commands/rollup-gather.d.ts.map +1 -1
- package/dist/commands/rollup-gather.js +2 -3
- package/dist/commands/rollup-gather.js.map +1 -1
- package/dist/commands/second-opinion.d.ts +33 -0
- package/dist/commands/second-opinion.d.ts.map +1 -0
- package/dist/commands/second-opinion.js +56 -0
- package/dist/commands/second-opinion.js.map +1 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +4 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/session-discard.d.ts +18 -0
- package/dist/commands/session-discard.d.ts.map +1 -0
- package/dist/commands/session-discard.js +31 -0
- package/dist/commands/session-discard.js.map +1 -0
- package/dist/commands/session-list.d.ts +17 -6
- package/dist/commands/session-list.d.ts.map +1 -1
- package/dist/commands/session-list.js +31 -11
- package/dist/commands/session-list.js.map +1 -1
- package/dist/commands/session-rebase.d.ts +18 -0
- package/dist/commands/session-rebase.d.ts.map +1 -0
- package/dist/commands/session-rebase.js +43 -0
- package/dist/commands/session-rebase.js.map +1 -0
- package/dist/commands/session-start.d.ts +8 -0
- package/dist/commands/session-start.d.ts.map +1 -1
- package/dist/commands/session-start.js +45 -5
- package/dist/commands/session-start.js.map +1 -1
- package/dist/commands/session-status.d.ts +17 -0
- package/dist/commands/session-status.d.ts.map +1 -0
- package/dist/commands/session-status.js +38 -0
- package/dist/commands/session-status.js.map +1 -0
- package/dist/commands/session-submit.d.ts +23 -0
- package/dist/commands/session-submit.d.ts.map +1 -0
- package/dist/commands/session-submit.js +42 -0
- package/dist/commands/session-submit.js.map +1 -0
- package/dist/commands/shared-stores-status.d.ts +24 -0
- package/dist/commands/shared-stores-status.d.ts.map +1 -0
- package/dist/commands/shared-stores-status.js +51 -0
- package/dist/commands/shared-stores-status.js.map +1 -0
- package/dist/commands/shared-stores-sync.d.ts +27 -0
- package/dist/commands/shared-stores-sync.d.ts.map +1 -0
- package/dist/commands/shared-stores-sync.js +115 -0
- package/dist/commands/shared-stores-sync.js.map +1 -0
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +2 -4
- package/dist/commands/verify.js.map +1 -1
- package/dist/config/schema.d.ts +16 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +32 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/core/agents-doc.d.ts +10 -0
- package/dist/core/agents-doc.d.ts.map +1 -1
- package/dist/core/agents-doc.js +26 -1
- package/dist/core/agents-doc.js.map +1 -1
- package/dist/core/approval/decision.d.ts +39 -1
- package/dist/core/approval/decision.d.ts.map +1 -1
- package/dist/core/approval/decision.js +53 -6
- package/dist/core/approval/decision.js.map +1 -1
- package/dist/core/approval/session-group.d.ts +156 -0
- package/dist/core/approval/session-group.d.ts.map +1 -0
- package/dist/core/approval/session-group.js +359 -0
- package/dist/core/approval/session-group.js.map +1 -0
- package/dist/core/approval/session-marker.d.ts +79 -0
- package/dist/core/approval/session-marker.d.ts.map +1 -0
- package/dist/core/approval/session-marker.js +146 -0
- package/dist/core/approval/session-marker.js.map +1 -0
- package/dist/core/approval/store-forges.d.ts +15 -0
- package/dist/core/approval/store-forges.d.ts.map +1 -0
- package/dist/core/approval/store-forges.js +33 -0
- package/dist/core/approval/store-forges.js.map +1 -0
- package/dist/core/browse/approve.d.ts +8 -0
- package/dist/core/browse/approve.d.ts.map +1 -1
- package/dist/core/browse/approve.js +366 -48
- package/dist/core/browse/approve.js.map +1 -1
- package/dist/core/browse/link-resolver.d.ts +9 -4
- package/dist/core/browse/link-resolver.d.ts.map +1 -1
- package/dist/core/browse/link-resolver.js +12 -7
- package/dist/core/browse/link-resolver.js.map +1 -1
- package/dist/core/browse/review.d.ts +5 -0
- package/dist/core/browse/review.d.ts.map +1 -1
- package/dist/core/browse/review.js +8 -0
- package/dist/core/browse/review.js.map +1 -1
- package/dist/core/checks/integrity-checks.d.ts.map +1 -1
- package/dist/core/checks/integrity-checks.js +7 -1
- package/dist/core/checks/integrity-checks.js.map +1 -1
- package/dist/core/checks/manifest.d.ts.map +1 -1
- package/dist/core/checks/manifest.js +2 -0
- package/dist/core/checks/manifest.js.map +1 -1
- package/dist/core/checks/shared-stores-checks.d.ts +17 -0
- package/dist/core/checks/shared-stores-checks.d.ts.map +1 -0
- package/dist/core/checks/shared-stores-checks.js +84 -0
- package/dist/core/checks/shared-stores-checks.js.map +1 -0
- package/dist/core/errors.d.ts +17 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +21 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/forge/github.d.ts +14 -0
- package/dist/core/forge/github.d.ts.map +1 -1
- package/dist/core/forge/github.js +17 -0
- package/dist/core/forge/github.js.map +1 -1
- package/dist/core/git/fast-forward.d.ts +23 -0
- package/dist/core/git/fast-forward.d.ts.map +1 -0
- package/dist/core/git/fast-forward.js +38 -0
- package/dist/core/git/fast-forward.js.map +1 -0
- package/dist/core/git/repo.d.ts +11 -0
- package/dist/core/git/repo.d.ts.map +1 -1
- package/dist/core/git/repo.js +25 -0
- package/dist/core/git/repo.js.map +1 -1
- package/dist/core/graph/model.d.ts +27 -0
- package/dist/core/graph/model.d.ts.map +1 -1
- package/dist/core/graph/model.js +68 -2
- package/dist/core/graph/model.js.map +1 -1
- package/dist/core/graph/persist.d.ts.map +1 -1
- package/dist/core/graph/persist.js +3 -2
- package/dist/core/graph/persist.js.map +1 -1
- package/dist/core/mcp/sessions.d.ts +11 -14
- package/dist/core/mcp/sessions.d.ts.map +1 -1
- package/dist/core/mcp/sessions.js +23 -43
- package/dist/core/mcp/sessions.js.map +1 -1
- package/dist/core/notes/list.d.ts.map +1 -1
- package/dist/core/notes/list.js +5 -0
- package/dist/core/notes/list.js.map +1 -1
- package/dist/core/reconcile.d.ts.map +1 -1
- package/dist/core/reconcile.js +2 -1
- package/dist/core/reconcile.js.map +1 -1
- package/dist/core/retrieval/pass.d.ts.map +1 -1
- package/dist/core/retrieval/pass.js +5 -4
- package/dist/core/retrieval/pass.js.map +1 -1
- package/dist/core/rollup.d.ts.map +1 -1
- package/dist/core/rollup.js +2 -3
- package/dist/core/rollup.js.map +1 -1
- package/dist/core/root.d.ts.map +1 -1
- package/dist/core/root.js +33 -2
- package/dist/core/root.js.map +1 -1
- package/dist/core/second-opinion/environment.d.ts +21 -0
- package/dist/core/second-opinion/environment.d.ts.map +1 -0
- package/dist/core/second-opinion/environment.js +70 -0
- package/dist/core/second-opinion/environment.js.map +1 -0
- package/dist/core/second-opinion/judge.d.ts +29 -0
- package/dist/core/second-opinion/judge.d.ts.map +1 -0
- package/dist/core/second-opinion/judge.js +55 -0
- package/dist/core/second-opinion/judge.js.map +1 -0
- package/dist/core/second-opinion/launch.d.ts +16 -0
- package/dist/core/second-opinion/launch.d.ts.map +1 -0
- package/dist/core/second-opinion/launch.js +75 -0
- package/dist/core/second-opinion/launch.js.map +1 -0
- package/dist/core/second-opinion/lineup.d.ts +82 -0
- package/dist/core/second-opinion/lineup.d.ts.map +1 -0
- package/dist/core/second-opinion/lineup.js +113 -0
- package/dist/core/second-opinion/lineup.js.map +1 -0
- package/dist/core/second-opinion/modes.d.ts +98 -0
- package/dist/core/second-opinion/modes.d.ts.map +1 -0
- package/dist/core/second-opinion/modes.js +224 -0
- package/dist/core/second-opinion/modes.js.map +1 -0
- package/dist/core/second-opinion/prompts.d.ts +18 -0
- package/dist/core/second-opinion/prompts.d.ts.map +1 -0
- package/dist/core/second-opinion/prompts.js +65 -0
- package/dist/core/second-opinion/prompts.js.map +1 -0
- package/dist/core/session/commit.d.ts +29 -0
- package/dist/core/session/commit.d.ts.map +1 -0
- package/dist/core/session/commit.js +22 -0
- package/dist/core/session/commit.js.map +1 -0
- package/dist/core/session/discard.d.ts +24 -0
- package/dist/core/session/discard.d.ts.map +1 -0
- package/dist/core/session/discard.js +68 -0
- package/dist/core/session/discard.js.map +1 -0
- package/dist/core/session/list.d.ts +48 -0
- package/dist/core/session/list.d.ts.map +1 -0
- package/dist/core/session/list.js +55 -0
- package/dist/core/session/list.js.map +1 -0
- package/dist/core/session/rebase.d.ts +34 -0
- package/dist/core/session/rebase.d.ts.map +1 -0
- package/dist/core/session/rebase.js +87 -0
- package/dist/core/session/rebase.js.map +1 -0
- package/dist/core/session/remote.d.ts +80 -0
- package/dist/core/session/remote.d.ts.map +1 -0
- package/dist/core/session/remote.js +102 -0
- package/dist/core/session/remote.js.map +1 -0
- package/dist/core/session/resolve.d.ts +41 -0
- package/dist/core/session/resolve.d.ts.map +1 -0
- package/dist/core/session/resolve.js +109 -0
- package/dist/core/session/resolve.js.map +1 -0
- package/dist/core/session/status.d.ts +47 -0
- package/dist/core/session/status.d.ts.map +1 -0
- package/dist/core/session/status.js +55 -0
- package/dist/core/session/status.js.map +1 -0
- package/dist/core/session/submit.d.ts +67 -0
- package/dist/core/session/submit.d.ts.map +1 -0
- package/dist/core/session/submit.js +254 -0
- package/dist/core/session/submit.js.map +1 -0
- package/dist/core/shared-stores-location.d.ts +17 -0
- package/dist/core/shared-stores-location.d.ts.map +1 -0
- package/dist/core/shared-stores-location.js +22 -0
- package/dist/core/shared-stores-location.js.map +1 -0
- package/dist/core/shared-stores.d.ts +69 -0
- package/dist/core/shared-stores.d.ts.map +1 -0
- package/dist/core/shared-stores.js +100 -0
- package/dist/core/shared-stores.js.map +1 -0
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +14 -10
- package/dist/core/skills.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +122 -6
- 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/shared-stores.md +11 -0
- package/templates/approve/copies-missing.md +1 -0
- package/templates/approve/elsewhere-heading.md +1 -0
- package/templates/approve/elsewhere-intro.md +1 -0
- package/templates/approve/homed-elsewhere.md +1 -0
- package/templates/approve/not-decider.md +1 -0
- package/templates/approve/part-heading.md +1 -0
- package/templates/approve/parts-changed.md +1 -0
- package/templates/approve/session-decision-note.md +1 -0
- package/templates/approve/session-intro.md +1 -0
- package/templates/approve/session-no-decision.md +1 -0
- package/templates/approve/store-unreachable.md +1 -0
- package/templates/land/resolver.md +2 -0
- package/templates/second-opinion/poll-answer.md +7 -0
- package/templates/second-opinion/taste-panel.md +14 -0
- package/templates/session/pages/bare-route.md +2 -0
- package/templates/session/pages/block.md +8 -0
- package/templates/session/pages/condition-preview.md +1 -0
- package/templates/session/pages/condition.md +1 -0
- package/templates/session/pages/moved.md +1 -0
- package/templates/session/pages/stops-working.md +1 -0
- package/templates/session/pages/table-head-preview.md +2 -0
- package/templates/session/pages/table-head.md +2 -0
- package/templates/skills/ctxr-connection-finding.md +4 -1
- package/templates/skills/ctxr-land.md +26 -7
- package/templates/skills/ctxr-placement.md +10 -0
- package/templates/skills/ctxr-rollup.md +3 -0
- package/templates/skills/ctxr-second-opinion.md +52 -36
- package/templates/skills/ctxr-session-lifecycle.md +60 -33
- package/templates/skills/ctxr-submit.md +65 -56
- package/templates/skills/ctxr-second-opinion/run.mjs +0 -542
- /package/templates/{skills/ctxr-second-opinion → second-opinion}/critic-contract.md +0 -0
- /package/templates/{skills/ctxr-second-opinion → second-opinion}/lenses.md +0 -0
- /package/templates/{skills/ctxr-second-opinion → second-opinion}/personas/architect.md +0 -0
- /package/templates/{skills/ctxr-second-opinion → second-opinion}/personas/pragmatist.md +0 -0
- /package/templates/{skills/ctxr-second-opinion → second-opinion}/personas/skeptic.md +0 -0
|
@@ -1,542 +0,0 @@
|
|
|
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] [--tier strong|fast]
|
|
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
|
-
/**
|
|
37
|
-
* Model choice lives here, in one table. Override by environment, never by editing a store.
|
|
38
|
-
*
|
|
39
|
-
* The tiers name a class of model, not a price. `strong` is each family's frontier model and `fast` its
|
|
40
|
-
* workhorse: claude `fable` and `opus`; codex `gpt-6-astra` ("frontier intelligence") and `gpt-6.1-sol`
|
|
41
|
-
* ("latest workhorse"), as codex's model catalog described them on 2026-10-15; agy `Gemini 4 Argon` and
|
|
42
|
-
* `Gemini 3.8 Flash`, once agy offered Argon (2026-10-19). The tiers differ in model only: every model runs at
|
|
43
|
-
* its provider's default effort.
|
|
44
|
-
*
|
|
45
|
-
* Effort is each provider's own recommended default, not one this runner picked:
|
|
46
|
-
* - claude: no --effort at all, so Claude Code applies its own default for the model.
|
|
47
|
-
* - codex: the default its model catalog declares (`default_reasoning_level`), which was `low` for both
|
|
48
|
-
* gpt-6-astra and gpt-6.1-sol on 2026-10-15. It is passed explicitly because the flag cannot simply be
|
|
49
|
-
* left off: with no pin in config.toml, codex resolves to `reasoning effort: none` on a ChatGPT login
|
|
50
|
-
* (seen 2026-10-05), which is no review at all. A machine that pins its own effort in config.toml is
|
|
51
|
-
* overridden by this; set CTXR_SECOND_OPINION_CODEX_EFFORT to use another.
|
|
52
|
-
* - agy: the effort is part of the model name. The default thinking level is `(Medium)` for Gemini 4 Argon
|
|
53
|
-
* and `(High)` for Gemini 3.8 Flash. agy rejects an unknown name, and preflight tests it.
|
|
54
|
-
* For scale: on one short plan Claude (Opus) took 35 s at `high` and 514 s at `max`, and Fable took 122 s at
|
|
55
|
-
* its default (2026-10-15), so a deeper pass is a choice
|
|
56
|
-
* (CTXR_SECOND_OPINION_CLAUDE_EFFORT=max), not something to leave on by default.
|
|
57
|
-
*/
|
|
58
|
-
function tiers() {
|
|
59
|
-
return {
|
|
60
|
-
strong: {
|
|
61
|
-
claude: { model: env('CLAUDE_MODEL', 'fable'), effort: env('CLAUDE_EFFORT', null) },
|
|
62
|
-
codex: { model: env('CODEX_MODEL', 'gpt-6-astra'), effort: env('CODEX_EFFORT', 'low') },
|
|
63
|
-
agy: { model: env('AGY_MODEL', 'Gemini 4 Argon (Medium)') },
|
|
64
|
-
},
|
|
65
|
-
fast: {
|
|
66
|
-
claude: { model: env('CLAUDE_MODEL', 'opus'), effort: env('CLAUDE_EFFORT', null) },
|
|
67
|
-
codex: { model: env('CODEX_MODEL', 'gpt-6.1-sol'), effort: env('CODEX_EFFORT', 'low') },
|
|
68
|
-
agy: { model: env('AGY_MODEL', 'Gemini 3.8 Flash (High)') },
|
|
69
|
-
},
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* The flags this runner passes, so preflight can confirm each CLI's --help still lists them. Only flags
|
|
75
|
-
* the help text documents belong here: `claude` still accepts a hidden --max-turns, but with `--tools ""`
|
|
76
|
-
* there is no tool turn to bound, so it is not passed at all. --effort is not listed because it is only
|
|
77
|
-
* passed when the environment asks for one.
|
|
78
|
-
*/
|
|
79
|
-
const FLAGS_USED = {
|
|
80
|
-
claude: { helpArgs: ['--help'], flags: ['--tools', '--model', '--no-session-persistence', '--disable-slash-commands', '--output-format'] },
|
|
81
|
-
codex: { helpArgs: ['exec', '--help'], flags: ['--skip-git-repo-check', '--sandbox', '--ephemeral', '--color', '--config', '--model'] },
|
|
82
|
-
agy: { helpArgs: ['--help'], flags: ['--print', '--sandbox', '--disable-slash-commands', '--model'] },
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
class UsageError extends Error {}
|
|
86
|
-
|
|
87
|
-
function parseArgs(argv) {
|
|
88
|
-
const opts = { mode: 'critique', tier: 'strong', timeout: 600, only: null, lens: null, out: null, planFile: null, promptFile: null, preflight: false, noWrap: false, seed: null };
|
|
89
|
-
const takesValue = new Set(['--mode', '--tier', '--timeout', '--only', '--lens', '--out', '--plan-file', '--prompt-file', '--seed']);
|
|
90
|
-
for (let i = 0; i < argv.length; i++) {
|
|
91
|
-
const a = argv[i];
|
|
92
|
-
if (a === '--preflight') opts.preflight = true;
|
|
93
|
-
else if (a === '--no-wrap') opts.noWrap = true;
|
|
94
|
-
else if (takesValue.has(a)) {
|
|
95
|
-
const v = argv[++i];
|
|
96
|
-
if (v === undefined) throw new UsageError(`${a} needs a value`);
|
|
97
|
-
const key = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
98
|
-
opts[key] = a === '--timeout' || a === '--seed' ? Number(v) : v;
|
|
99
|
-
} else throw new UsageError(`unknown argument: ${a}`);
|
|
100
|
-
}
|
|
101
|
-
if (!['critique', 'poll'].includes(opts.mode)) throw new UsageError('--mode is critique or poll');
|
|
102
|
-
if (!['strong', 'fast'].includes(opts.tier)) throw new UsageError('--tier is strong or fast');
|
|
103
|
-
if (!Number.isFinite(opts.timeout) || opts.timeout <= 0) throw new UsageError('--timeout must be a positive number of seconds');
|
|
104
|
-
return opts;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function requestedClis(opts) {
|
|
108
|
-
if (!opts.only) return [...ALL_CLIS];
|
|
109
|
-
const clis = opts.only.split(',').map((c) => c.trim()).filter(Boolean);
|
|
110
|
-
const bad = clis.filter((c) => !ALL_CLIS.includes(c));
|
|
111
|
-
if (bad.length) throw new UsageError(`unknown CLI in --only: ${bad.join(', ')} (valid: ${ALL_CLIS.join(', ')})`);
|
|
112
|
-
return [...new Set(clis)];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
// Launching. No shell anywhere: an argv array plus stdin, in an empty scratch directory.
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
|
|
119
|
-
function childEnv() {
|
|
120
|
-
// Appended, so a CLI already on PATH wins; this only rescues the usual user-install spots.
|
|
121
|
-
const extra = [path.join(homedir(), '.local', 'bin'), path.join(homedir(), '.local', 'share', 'node', 'bin')];
|
|
122
|
-
return { ...process.env, PATH: [process.env.PATH ?? '', ...extra].filter(Boolean).join(path.delimiter) };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Runs one CLI. Resolves {ok, stdout, stderr, cause}; never rejects. */
|
|
126
|
-
function runCli(cmd, args, stdin, timeoutSec) {
|
|
127
|
-
return new Promise((resolve) => {
|
|
128
|
-
const scratch = mkdtempSync(path.join(tmpdir(), 'ctxr-second-opinion-'));
|
|
129
|
-
let settled = false;
|
|
130
|
-
let stdout = '';
|
|
131
|
-
let stderr = '';
|
|
132
|
-
let timedOut = false;
|
|
133
|
-
const done = (result) => {
|
|
134
|
-
if (settled) return;
|
|
135
|
-
settled = true;
|
|
136
|
-
clearTimeout(timer);
|
|
137
|
-
try { rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
138
|
-
resolve({ stdout, stderr, ...result });
|
|
139
|
-
};
|
|
140
|
-
let child;
|
|
141
|
-
try {
|
|
142
|
-
child = spawn(cmd, args, { cwd: scratch, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
143
|
-
} catch (err) {
|
|
144
|
-
return done({ ok: false, cause: `could not start ${cmd}: ${err.message}` });
|
|
145
|
-
}
|
|
146
|
-
const killGroup = (signal) => { try { process.kill(-child.pid, signal); } catch { /* already gone */ } };
|
|
147
|
-
const timer = setTimeout(() => {
|
|
148
|
-
timedOut = true;
|
|
149
|
-
killGroup('SIGTERM');
|
|
150
|
-
setTimeout(() => killGroup('SIGKILL'), 2000).unref();
|
|
151
|
-
}, timeoutSec * 1000);
|
|
152
|
-
child.stdout.on('data', (d) => { stdout += d; });
|
|
153
|
-
child.stderr.on('data', (d) => { stderr += d; });
|
|
154
|
-
child.on('error', (err) => done({ ok: false, cause: err.code === 'ENOENT' ? `'${cmd}' not found on PATH` : `could not start ${cmd}: ${err.message}` }));
|
|
155
|
-
child.on('close', (code) => {
|
|
156
|
-
if (timedOut) return done({ ok: false, cause: `timed out after ${timeoutSec}s` });
|
|
157
|
-
if (code !== 0) return done({ ok: false, cause: `exit ${code}: ${firstLines(stderr || stdout, 3)}` });
|
|
158
|
-
return done({ ok: true });
|
|
159
|
-
});
|
|
160
|
-
child.stdin.on('error', () => { /* the CLI closed stdin early; its exit code says why */ });
|
|
161
|
-
child.stdin.end(stdin ?? '');
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const firstLines = (text, n) => text.trim().split('\n').slice(0, n).join(' | ').slice(0, 400);
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* How each family is contained. Only claude and codex offer a mode that removes the ability to
|
|
169
|
-
* act; agy does not (its headless plan mode still wrote a file), so it is held to an empty working
|
|
170
|
-
* directory and an untrusted plan should run without it.
|
|
171
|
-
*/
|
|
172
|
-
function buildInvocation(cli, prompt, tier) {
|
|
173
|
-
const t = tiers()[tier][cli];
|
|
174
|
-
if (cli === 'claude') {
|
|
175
|
-
const args = ['-p', '--tools', '', '--model', t.model, '--no-session-persistence', '--disable-slash-commands', '--output-format', 'json'];
|
|
176
|
-
if (t.effort) args.push('--effort', t.effort);
|
|
177
|
-
return { cmd: 'claude', args, stdin: prompt };
|
|
178
|
-
}
|
|
179
|
-
if (cli === 'codex') {
|
|
180
|
-
const args = ['exec', '--skip-git-repo-check', '--sandbox', 'read-only', '--ephemeral', '--color', 'never', '--model', t.model];
|
|
181
|
-
if (t.effort) args.push('-c', `model_reasoning_effort=${t.effort}`);
|
|
182
|
-
args.push('-');
|
|
183
|
-
return { cmd: 'codex', args, stdin: prompt };
|
|
184
|
-
}
|
|
185
|
-
return { cmd: 'agy', args: ['-p', prompt, '--sandbox', '--disable-slash-commands', '--model', t.model], stdin: '' };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Normalizes a CLI's raw output to answer text plus the model it says it ran. */
|
|
189
|
-
function normalize(cli, raw) {
|
|
190
|
-
if (cli === 'claude') {
|
|
191
|
-
try {
|
|
192
|
-
const j = JSON.parse(raw.stdout);
|
|
193
|
-
const models = Object.keys(j.modelUsage ?? {});
|
|
194
|
-
return { text: String(j.result ?? '').trim(), model: models.length ? models.join(', ') : null };
|
|
195
|
-
} catch {
|
|
196
|
-
return { text: raw.stdout.trim(), model: null };
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (cli === 'codex') {
|
|
200
|
-
const model = /^model:\s*(.+)$/m.exec(raw.stdout + '\n' + raw.stderr)?.[1]?.trim() ?? null;
|
|
201
|
-
return { text: stripCodexNoise(raw.stdout), model };
|
|
202
|
-
}
|
|
203
|
-
return { text: raw.stdout.trim(), model: null };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Codex wraps its answer in a session banner and repeats it after a `tokens used` trailer.
|
|
208
|
-
* The answer starts at the first line of the required shape; failing that, the trailer is cut and
|
|
209
|
-
* everything else is kept, so real content is never eaten.
|
|
210
|
-
*/
|
|
211
|
-
function stripCodexNoise(text) {
|
|
212
|
-
const lines = text.split('\n');
|
|
213
|
-
const start = lines.findIndex((l) => /^## (Role|Verdict):/.test(l));
|
|
214
|
-
let body = start >= 0 ? lines.slice(start) : lines;
|
|
215
|
-
const trailer = body.findIndex((l) => /^tokens used\b/i.test(l.trim()));
|
|
216
|
-
if (trailer >= 0) body = body.slice(0, trailer);
|
|
217
|
-
return body.join('\n').trim();
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ---------------------------------------------------------------------------
|
|
221
|
-
// Critique validation.
|
|
222
|
-
// ---------------------------------------------------------------------------
|
|
223
|
-
|
|
224
|
-
const normalizeWs = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
225
|
-
|
|
226
|
-
/** Step ids a plan numbers as S1, S2, ... at the start of a line, list item, or heading. */
|
|
227
|
-
export function planStepIds(plan) {
|
|
228
|
-
const ids = new Set();
|
|
229
|
-
for (const m of plan.matchAll(/^[ \t]*(?:[-*][ \t]+|#{1,6}[ \t]+)?\**S(\d+)\b/gm)) ids.add(`S${m[1]}`);
|
|
230
|
-
return ids;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function parseCritique(text) {
|
|
234
|
-
const verdict = /^## Verdict:\s*(APPROVE_WITH_CHANGES|APPROVE|REJECT)\b/m.exec(text)?.[1] ?? null;
|
|
235
|
-
const findings = [];
|
|
236
|
-
const blocks = text.split(/^### F\d+\b/m).slice(1);
|
|
237
|
-
for (const block of blocks) {
|
|
238
|
-
// Brackets around the severity are what the contract asks for, and models drop them: accept both.
|
|
239
|
-
const head = /^\s*\[?(critical|major|minor)\]?\s*(?:confidence\s*[=:]\s*([01](?:\.\d+)?))?/i.exec(block);
|
|
240
|
-
const evidence = /^Evidence:\s*(.+)$/im.exec(block)?.[1] ?? '';
|
|
241
|
-
findings.push({
|
|
242
|
-
severity: head?.[1]?.toLowerCase() ?? null,
|
|
243
|
-
confidence: head?.[2] !== undefined ? Number(head[2]) : null,
|
|
244
|
-
evidence,
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
return { verdict, findings };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** True if a finding's evidence points at something the plan actually contains. */
|
|
251
|
-
export function evidenceResolves(evidence, plan, stepIds) {
|
|
252
|
-
for (const m of evidence.matchAll(/\bS(\d+)\b/g)) if (stepIds.has(`S${m[1]}`)) return true;
|
|
253
|
-
const haystack = normalizeWs(plan);
|
|
254
|
-
for (const m of evidence.matchAll(/["“`]([^"”`]{12,})["”`]/g)) if (haystack.includes(normalizeWs(m[1]))) return true;
|
|
255
|
-
for (const m of evidence.matchAll(/([\w./-]+\.[a-z]{1,6}):\d+/gi)) if (plan.includes(m[1])) return true;
|
|
256
|
-
return false;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** valid | blind | failed, with the cause when failed. */
|
|
260
|
-
export function judgeCritique(text, plan) {
|
|
261
|
-
if (!text) return { status: 'failed', cause: 'empty output', verdict: null, findings: [], veto: false };
|
|
262
|
-
const { verdict, findings } = parseCritique(text);
|
|
263
|
-
if (!verdict) return { status: 'failed', cause: 'output has no "## Verdict:" line', verdict, findings, veto: false };
|
|
264
|
-
if (verdict !== 'APPROVE' && findings.length === 0) return { status: 'failed', cause: `${verdict} with no parseable findings`, verdict, findings, veto: false };
|
|
265
|
-
const stepIds = planStepIds(plan);
|
|
266
|
-
const resolved = findings.filter((f) => evidenceResolves(f.evidence, plan, stepIds));
|
|
267
|
-
// An APPROVE with no findings has nothing to cite and is valid. Findings that exist but resolve to
|
|
268
|
-
// nothing in the plan mean the critic answered without reading it.
|
|
269
|
-
const blind = findings.length > 0 && resolved.length === 0;
|
|
270
|
-
const veto = verdict === 'REJECT' && findings.some((f) => f.severity === 'critical' && resolved.includes(f));
|
|
271
|
-
return { status: blind ? 'blind' : 'valid', cause: blind ? 'no finding cites anything in the plan' : null, verdict, findings, veto };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ---------------------------------------------------------------------------
|
|
275
|
-
// Prompts.
|
|
276
|
-
// ---------------------------------------------------------------------------
|
|
277
|
-
|
|
278
|
-
const read = (...p) => readFileSync(path.join(HERE, ...p), 'utf8');
|
|
279
|
-
|
|
280
|
-
function critiquePrompt(role, plan) {
|
|
281
|
-
return `${read('critic-contract.md').trimEnd()}\n\n---\n${read('personas', `${role}.md`).trimEnd()}\n\nPLAN:\n${plan}\n`;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function loadLensRoster() {
|
|
285
|
-
const roster = new Map();
|
|
286
|
-
for (const m of read('lenses.md').matchAll(/^- \*\*([a-z-]+)\*\* — (.+)$/gm)) roster.set(m[1], m[2].trim());
|
|
287
|
-
return roster;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Splits `--lens` into one string per lens. Lenses are comma-separated, but an inline description may itself
|
|
292
|
-
* contain commas: a fragment starts a NEW lens only when it is exactly a roster name or begins `Name:`;
|
|
293
|
-
* any other fragment continues the lens before it. So `minimalist,House taste:blunt, anti-slogan` is two
|
|
294
|
-
* lenses, and a description must not contain a colon (that would read as the start of another lens).
|
|
295
|
-
*/
|
|
296
|
-
export function splitLenses(spec, roster) {
|
|
297
|
-
const lenses = [];
|
|
298
|
-
for (const fragment of spec.split(',').map((f) => f.trim()).filter(Boolean)) {
|
|
299
|
-
const startsLens = roster.has(fragment.toLowerCase()) || /^[^:,]{1,40}:/.test(fragment);
|
|
300
|
-
if (startsLens || lenses.length === 0) lenses.push(fragment);
|
|
301
|
-
else lenses[lenses.length - 1] += `, ${fragment}`;
|
|
302
|
-
}
|
|
303
|
-
return lenses;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function lensBlock(spec) {
|
|
307
|
-
const roster = loadLensRoster();
|
|
308
|
-
const described = [];
|
|
309
|
-
for (const s of splitLenses(spec, roster)) {
|
|
310
|
-
if (roster.has(s.toLowerCase())) described.push(`- ${s.toLowerCase()} — ${roster.get(s.toLowerCase())}`);
|
|
311
|
-
else if (/^[^:,]{1,40}:/.test(s)) {
|
|
312
|
-
const [name, ...rest] = s.split(':');
|
|
313
|
-
described.push(`- ${name.trim()} — ${rest.join(':').trim()}`);
|
|
314
|
-
} else throw new UsageError(`unknown lens "${s}" (known: ${[...roster.keys()].join(', ')}; or pass "Name:description")`);
|
|
315
|
-
}
|
|
316
|
-
if (described.length < 2 || described.length > 3) throw new UsageError('--lens takes two or three lenses; keep them orthogonal');
|
|
317
|
-
return described.join('\n');
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const POLL_TAIL = `
|
|
321
|
-
|
|
322
|
-
Please answer:
|
|
323
|
-
1. Your pick or ranking
|
|
324
|
-
2. Two or three reasons
|
|
325
|
-
3. Anything surprising about this choice
|
|
326
|
-
4. What you would recommend instead, if anything
|
|
327
|
-
|
|
328
|
-
Be direct and brief: about 200 to 300 words.`;
|
|
329
|
-
|
|
330
|
-
function pollPrompt(question, opts) {
|
|
331
|
-
if (opts.lens) {
|
|
332
|
-
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.
|
|
333
|
-
|
|
334
|
-
Lenses:
|
|
335
|
-
${lensBlock(opts.lens)}
|
|
336
|
-
|
|
337
|
-
QUESTION:
|
|
338
|
-
${question}
|
|
339
|
-
|
|
340
|
-
For EACH lens output exactly:
|
|
341
|
-
PICK: <option>
|
|
342
|
-
WHY: <two sentences, that lens's values only>
|
|
343
|
-
REJECT: <the option this lens most dislikes, and a four-word reason>
|
|
344
|
-
|
|
345
|
-
Only the lens blocks. No preamble, no summary.
|
|
346
|
-
`;
|
|
347
|
-
}
|
|
348
|
-
return opts.noWrap ? question : question + POLL_TAIL;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// ---------------------------------------------------------------------------
|
|
352
|
-
// Anonymizing.
|
|
353
|
-
// ---------------------------------------------------------------------------
|
|
354
|
-
|
|
355
|
-
function rng(seed) {
|
|
356
|
-
if (seed === null || Number.isNaN(seed)) return Math.random;
|
|
357
|
-
let a = seed >>> 0; // mulberry32: deterministic, for tests
|
|
358
|
-
return () => {
|
|
359
|
-
a = (a + 0x6d2b79f5) >>> 0;
|
|
360
|
-
let t = a;
|
|
361
|
-
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
362
|
-
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
363
|
-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
function shuffled(items, random) {
|
|
368
|
-
const a = [...items];
|
|
369
|
-
for (let i = a.length - 1; i > 0; i--) {
|
|
370
|
-
const j = Math.floor(random() * (i + 1));
|
|
371
|
-
[a[i], a[j]] = [a[j], a[i]];
|
|
372
|
-
}
|
|
373
|
-
return a;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// ---------------------------------------------------------------------------
|
|
377
|
-
// Modes.
|
|
378
|
-
// ---------------------------------------------------------------------------
|
|
379
|
-
|
|
380
|
-
async function preflight(clis, opts) {
|
|
381
|
-
let allUp = true;
|
|
382
|
-
const results = await Promise.all(
|
|
383
|
-
clis.map(async (cli) => {
|
|
384
|
-
const spec = FLAGS_USED[cli];
|
|
385
|
-
const help = await runCli(cli, spec.helpArgs, '', 30);
|
|
386
|
-
if (!help.ok) return { cli, ok: false, detail: help.cause };
|
|
387
|
-
const helpText = help.stdout + help.stderr;
|
|
388
|
-
const missing = spec.flags.filter((f) => !helpText.includes(f));
|
|
389
|
-
// The ping runs at the tier the run will use. A fixed tier left the other tier's model untested, so a
|
|
390
|
-
// model name agy no longer accepted passed preflight and failed the critique (2026-10-13).
|
|
391
|
-
const inv = buildInvocation(cli, 'say PONG only', opts.tier);
|
|
392
|
-
const ping = await runCli(inv.cmd, inv.args, inv.stdin, Math.min(opts.timeout, 60));
|
|
393
|
-
if (!ping.ok) return { cli, ok: false, detail: ping.cause };
|
|
394
|
-
const { text } = normalize(cli, ping);
|
|
395
|
-
if (!/pong/i.test(text)) return { cli, ok: false, detail: `unexpected reply: ${firstLines(text, 2)}` };
|
|
396
|
-
if (missing.length) return { cli, ok: false, detail: `answers, but its --help no longer lists: ${missing.join(' ')}` };
|
|
397
|
-
const model = tiers()[opts.tier][cli].model;
|
|
398
|
-
return { cli, ok: true, detail: model ? `PONG (${model})` : 'PONG' };
|
|
399
|
-
}),
|
|
400
|
-
);
|
|
401
|
-
for (const r of results) {
|
|
402
|
-
if (!r.ok) allUp = false;
|
|
403
|
-
console.log(`[${r.ok ? 'OK ' : 'DOWN'}] ${r.cli.padEnd(7)} ${r.detail}`);
|
|
404
|
-
}
|
|
405
|
-
if (!allUp) console.log('\nAt least one CLI is down. Re-authenticate it, or run with --only on the ones that are up.');
|
|
406
|
-
return allUp ? 0 : 2;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function readInput(opts, flag) {
|
|
410
|
-
const file = flag === 'plan' ? opts.planFile : opts.promptFile;
|
|
411
|
-
if (file) {
|
|
412
|
-
try { return readFileSync(file, 'utf8'); } catch (err) { throw new UsageError(`cannot read ${file}: ${err.message}`); }
|
|
413
|
-
}
|
|
414
|
-
if (!process.stdin.isTTY) {
|
|
415
|
-
const text = readFileSync(0, 'utf8');
|
|
416
|
-
if (text.trim()) return text;
|
|
417
|
-
}
|
|
418
|
-
throw new UsageError(`no input: pass --${flag === 'plan' ? 'plan' : 'prompt'}-file PATH or pipe it on stdin`);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function outDir(opts) {
|
|
422
|
-
const dir = opts.out ?? mkdtempSync(path.join(tmpdir(), 'ctxr-second-opinion-out-'));
|
|
423
|
-
mkdirSync(path.join(dir, 'raw'), { recursive: true });
|
|
424
|
-
return dir;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
async function runCritique(opts, clis) {
|
|
428
|
-
const plan = readInput(opts, 'plan');
|
|
429
|
-
const roles = ROLES.filter((r) => clis.includes(ROLE_CLI[r]));
|
|
430
|
-
const dir = outDir(opts);
|
|
431
|
-
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`);
|
|
432
|
-
|
|
433
|
-
const runs = await Promise.all(
|
|
434
|
-
roles.map(async (role) => {
|
|
435
|
-
const cli = ROLE_CLI[role];
|
|
436
|
-
const prompt = critiquePrompt(role, plan);
|
|
437
|
-
const started = Date.now();
|
|
438
|
-
let raw;
|
|
439
|
-
if (cli === 'agy' && Buffer.byteLength(prompt) > MAX_ARGV_BYTES) {
|
|
440
|
-
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}` };
|
|
441
|
-
} else {
|
|
442
|
-
const inv = buildInvocation(cli, prompt, opts.tier);
|
|
443
|
-
raw = await runCli(inv.cmd, inv.args, inv.stdin, opts.timeout);
|
|
444
|
-
}
|
|
445
|
-
writeFileSync(path.join(dir, 'raw', `${role}-${cli}.txt`), `${raw.stdout}\n--- stderr ---\n${raw.stderr}\n`);
|
|
446
|
-
const base = { role, cli, duration_ms: Date.now() - started };
|
|
447
|
-
if (!raw.ok) return { ...base, status: 'failed', cause: raw.cause, model: null, text: '', verdict: null, findings: 0, veto: false };
|
|
448
|
-
const { text, model } = normalize(cli, raw);
|
|
449
|
-
const judged = judgeCritique(text, plan);
|
|
450
|
-
return { ...base, status: judged.status, cause: judged.cause, model, text, verdict: judged.verdict, findings: judged.findings.length, veto: judged.veto };
|
|
451
|
-
}),
|
|
452
|
-
);
|
|
453
|
-
|
|
454
|
-
// Letters go to every critique that produced text (valid or blind), in an order that does not follow the roster.
|
|
455
|
-
const answered = runs.filter((r) => r.status !== 'failed');
|
|
456
|
-
const letters = shuffled(answered, rng(opts.seed)).map((r, i) => ({ ...r, letter: String.fromCharCode(65 + i) }));
|
|
457
|
-
for (const r of letters) writeFileSync(path.join(dir, `critique-${r.letter}.md`), `${r.text}\n`);
|
|
458
|
-
|
|
459
|
-
const manifest = {
|
|
460
|
-
mode: 'critique',
|
|
461
|
-
tier: opts.tier,
|
|
462
|
-
quorum: QUORUM,
|
|
463
|
-
critiques: runs.map((r) => ({
|
|
464
|
-
letter: letters.find((l) => l.role === r.role)?.letter ?? null,
|
|
465
|
-
role: r.role,
|
|
466
|
-
cli: r.cli,
|
|
467
|
-
model_reported: r.model,
|
|
468
|
-
status: r.status,
|
|
469
|
-
cause: r.cause,
|
|
470
|
-
verdict: r.verdict,
|
|
471
|
-
findings: r.findings,
|
|
472
|
-
veto: r.veto,
|
|
473
|
-
duration_ms: r.duration_ms,
|
|
474
|
-
})),
|
|
475
|
-
};
|
|
476
|
-
const valid = runs.filter((r) => r.status === 'valid').length;
|
|
477
|
-
manifest.valid = valid;
|
|
478
|
-
manifest.partial = valid < QUORUM;
|
|
479
|
-
writeFileSync(path.join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
480
|
-
|
|
481
|
-
// stdout names letters, never roles or models; a failed critic has no letter and is named by CLI, since that is what to fix.
|
|
482
|
-
console.log(`out: ${dir}`);
|
|
483
|
-
console.log(`valid: ${valid} blind: ${runs.filter((r) => r.status === 'blind').length} failed: ${runs.filter((r) => r.status === 'failed').length}`);
|
|
484
|
-
for (const r of [...letters].sort((a, b) => a.letter.localeCompare(b.letter))) {
|
|
485
|
-
console.log(` ${r.letter} ${r.status} ${r.verdict ?? '-'} findings=${r.findings}${r.veto ? ' veto' : ''}`);
|
|
486
|
-
}
|
|
487
|
-
for (const r of runs.filter((x) => x.status === 'failed')) console.log(` failed ${r.cli} ${r.cause}`);
|
|
488
|
-
if (manifest.partial) console.log(`PARTIAL: fewer than ${QUORUM} valid critiques. Report this as a partial result, not a consensus.`);
|
|
489
|
-
console.log(`Read the critique files first; open ${path.join(dir, 'manifest.json')} last.`);
|
|
490
|
-
return manifest.partial ? 2 : 0;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
async function runPoll(opts, clis) {
|
|
494
|
-
const question = readInput(opts, 'prompt');
|
|
495
|
-
const prompt = pollPrompt(question, opts);
|
|
496
|
-
const dir = outDir(opts);
|
|
497
|
-
console.error(`question: ${question.length} characters · tier: ${opts.tier} · models: ${clis.length}${opts.lens ? ` · lenses: ${opts.lens}` : ''}`);
|
|
498
|
-
const runs = await Promise.all(
|
|
499
|
-
clis.map(async (cli) => {
|
|
500
|
-
let raw;
|
|
501
|
-
if (cli === 'agy' && Buffer.byteLength(prompt) > MAX_ARGV_BYTES) raw = { ok: false, stdout: '', stderr: '', cause: `prompt exceeds the ${MAX_ARGV_BYTES}-byte argument limit` };
|
|
502
|
-
else {
|
|
503
|
-
const inv = buildInvocation(cli, prompt, opts.tier);
|
|
504
|
-
raw = await runCli(inv.cmd, inv.args, inv.stdin, opts.timeout);
|
|
505
|
-
}
|
|
506
|
-
writeFileSync(path.join(dir, 'raw', `poll-${cli}.txt`), `${raw.stdout}\n--- stderr ---\n${raw.stderr}\n`);
|
|
507
|
-
if (!raw.ok) return { cli, status: 'failed', cause: raw.cause, model: null, text: '' };
|
|
508
|
-
const { text, model } = normalize(cli, raw);
|
|
509
|
-
if (!text) return { cli, status: 'failed', cause: 'empty output', model, text };
|
|
510
|
-
return { cli, status: 'valid', cause: null, model, text };
|
|
511
|
-
}),
|
|
512
|
-
);
|
|
513
|
-
// A poll is read model against model (the lens grid's columns are models), so files are named by model here.
|
|
514
|
-
for (const r of runs.filter((x) => x.status === 'valid')) writeFileSync(path.join(dir, `poll-${r.cli}.md`), `${r.text}\n`);
|
|
515
|
-
const valid = runs.filter((r) => r.status === 'valid').length;
|
|
516
|
-
const manifest = { mode: 'poll', tier: opts.tier, lens: opts.lens, quorum: QUORUM, valid, partial: valid < QUORUM, answers: runs.map(({ text: _t, ...rest }) => rest) };
|
|
517
|
-
writeFileSync(path.join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
518
|
-
console.log(`out: ${dir}`);
|
|
519
|
-
console.log(`valid: ${valid} failed: ${runs.length - valid}`);
|
|
520
|
-
for (const r of runs) console.log(` ${r.cli} ${r.status}${r.cause ? ` ${r.cause}` : ''}${r.model ? ` model=${r.model}` : ''}`);
|
|
521
|
-
if (manifest.partial) console.log(`PARTIAL: fewer than ${QUORUM} answers. Report it as a partial result.`);
|
|
522
|
-
return manifest.partial ? 2 : 0;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
async function main() {
|
|
526
|
-
let opts;
|
|
527
|
-
try {
|
|
528
|
-
opts = parseArgs(process.argv.slice(2));
|
|
529
|
-
const clis = requestedClis(opts);
|
|
530
|
-
if (opts.preflight) return await preflight(clis, opts);
|
|
531
|
-
if (clis.length < QUORUM) throw new UsageError(`--only names ${clis.length} CLI; a second opinion needs at least ${QUORUM}`);
|
|
532
|
-
return opts.mode === 'critique' ? await runCritique(opts, clis) : await runPoll(opts, clis);
|
|
533
|
-
} catch (err) {
|
|
534
|
-
if (!(err instanceof UsageError)) throw err;
|
|
535
|
-
console.error(`run.mjs: ${err.message}`);
|
|
536
|
-
return 1;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
541
|
-
process.exitCode = await main();
|
|
542
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|