sprag-cli 3.69.0 → 3.71.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/NOTICE +5 -2
- package/README.ko.md +16 -0
- package/README.md +16 -0
- package/bin/cli.js +53 -4
- package/docs/CODEX.ko.md +3 -2
- package/docs/CODEX.md +5 -4
- package/docs/TEAM.ko.md +174 -0
- package/docs/TEAM.md +175 -0
- package/package.json +5 -1
- package/presets/agents.json +118 -0
- package/presets/doc2md/convert.py +1 -1
- package/presets/ratchet-rules.json +12 -2
- package/src/agents.js +3 -1
- package/src/cli-entry.js +40 -0
- package/src/codex/delegation.js +8 -5
- package/src/codex/hooks.js +25 -12
- package/src/codex/panel-setup.js +2 -1
- package/src/codex/panel.js +2 -1
- package/src/codex/route-scan.js +6 -4
- package/src/commands/brief.js +2 -0
- package/src/commands/codex-delegate.js +3 -3
- package/src/commands/codex.js +6 -5
- package/src/commands/harness.js +3 -4
- package/src/commands/install.js +19 -18
- package/src/commands/presets.js +158 -0
- package/src/commands/profile-map.js +2 -2
- package/src/commands/route-scan.js +25 -10
- package/src/commands/team.js +334 -0
- package/src/commands/update-check.js +2 -2
- package/src/cost.js +6 -2
- package/src/cowork-sync.js +5 -4
- package/src/debug.js +3 -2
- package/src/delegation-agents.js +49 -49
- package/src/delegation-guard.js +3 -2
- package/src/doc2md.cjs +4 -3
- package/src/env.cjs +7 -0
- package/src/env.js +19 -0
- package/src/first-run-note.js +3 -2
- package/src/harness-templates.js +10 -0
- package/src/harness.js +4 -14
- package/src/hook.cjs +4 -3
- package/src/model-rules.js +58 -29
- package/src/preset-sign.js +106 -0
- package/src/preset-sync.js +133 -0
- package/src/presets-source.js +209 -0
- package/src/prompt.js +2 -1
- package/src/route-caps.js +3 -3
- package/src/route-inject.js +24 -15
- package/src/route-scan.js +39 -15
- package/src/seed-rules.js +14 -16
- package/src/shared-model-rules.js +1 -1
- package/src/star-note.js +3 -2
- package/src/statusline-guard.js +144 -0
- package/src/stdin-payload.js +33 -4
- package/src/team-prompts.js +353 -0
- package/src/team-ratchet.js +276 -0
- package/src/team-sync.js +625 -0
- package/src/update-check.js +3 -2
package/src/codex/panel-setup.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* not zsh, or the user already has their own `codex` alias/function.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { envVar } from '../env.js';
|
|
11
12
|
import { execFileSync } from 'node:child_process';
|
|
12
13
|
import { existsSync } from 'node:fs';
|
|
13
14
|
import { basename, dirname, join } from 'node:path';
|
|
@@ -21,7 +22,7 @@ export function tmuxAvailable({ exec = execFileSync } = {}) {
|
|
|
21
22
|
|
|
22
23
|
export function ensureTmux({ platform = process.platform, env = process.env, exec = execFileSync, exists = existsSync } = {}) {
|
|
23
24
|
// Checked first, before probing anything, so tests and CI never spawn brew.
|
|
24
|
-
if (env
|
|
25
|
+
if (envVar('NO_TMUX', env) === '1' || (env.CI && env.CI !== 'false')) return { status: 'skipped' };
|
|
25
26
|
if (tmuxAvailable({ exec })) return { status: 'present' };
|
|
26
27
|
if (platform !== 'darwin') {
|
|
27
28
|
return { status: 'unavailable', detail: 'install tmux with your package manager (e.g. sudo apt install tmux)' };
|
package/src/codex/panel.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { envVar } from '../env.js';
|
|
1
2
|
import { resolve, relative, isAbsolute, basename } from 'node:path';
|
|
2
3
|
import { realpathSync, statSync } from 'node:fs';
|
|
3
4
|
import { stat } from 'node:fs/promises';
|
|
@@ -151,7 +152,7 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
151
152
|
cohesion: cfg?.cohesion?.enabled === true,
|
|
152
153
|
brief: cfg?.codex?.brief !== false,
|
|
153
154
|
delegate: codexDelegateEnabled(cfg),
|
|
154
|
-
doc2md: cfg?.codex?.doc2md !== false &&
|
|
155
|
+
doc2md: cfg?.codex?.doc2md !== false && envVar('NO_DOC2MD') !== '1',
|
|
155
156
|
update: version ? readUpdateChip(version, { refresh: refreshUpdate }) : null,
|
|
156
157
|
updatedAt: new Date() };
|
|
157
158
|
};
|
package/src/codex/route-scan.js
CHANGED
|
@@ -92,7 +92,9 @@ function pickTarget(group, { prices, known, home }) {
|
|
|
92
92
|
.sort((a, b) => a.usd - b.usd);
|
|
93
93
|
const eligible = cheaper.filter((x) => known.has(`${group.provider}|${x.model}`));
|
|
94
94
|
// Simple work goes to the cheapest model; moderate work steps down one notch.
|
|
95
|
-
|
|
95
|
+
// The heaviest band goes to the most expensive cheaper model (the T1H target).
|
|
96
|
+
const { T2, T1, T1H } = group.tiers;
|
|
97
|
+
const pick = T2 >= T1 && T2 >= T1H ? eligible[0] : eligible.at(-1);
|
|
96
98
|
return {
|
|
97
99
|
suggestedModel: pick?.model ?? null,
|
|
98
100
|
alternatives: pick ? [] : cheaper.slice(0, 3).map((x) => x.model),
|
|
@@ -135,10 +137,10 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
135
137
|
const runtime = codexPromptCategory(ep.text);
|
|
136
138
|
if (!runtime || !categorize(ep.text, ep.tools)) continue;
|
|
137
139
|
const tier = tierOf(ep, runtime, thresholds);
|
|
138
|
-
if (tier !== 'T1' && tier !== 'T2') continue;
|
|
140
|
+
if (tier !== 'T1' && tier !== 'T2' && tier !== 'T1H') continue;
|
|
139
141
|
const key = `${runtime.id}|${ep.provider}|${ep.model}|${ep.projectRoot}`;
|
|
140
142
|
const g = groups.get(key) || { signature: key, category: runtime.id, label: runtime.label, labelEn: runtime.labelEn,
|
|
141
|
-
from: ep.model, provider: ep.provider, projectRoot: ep.projectRoot, count: 0, tiers: { T1: 0, T2: 0 }, example: ep.text,
|
|
143
|
+
from: ep.model, provider: ep.provider, projectRoot: ep.projectRoot, count: 0, tiers: { T1: 0, T2: 0, T1H: 0 }, example: ep.text,
|
|
142
144
|
tokens: { input: 0, cached: 0, cacheWrite: 0, output: 0 }, lastSeen: 0 };
|
|
143
145
|
g.count++;
|
|
144
146
|
g.tiers[tier]++;
|
|
@@ -154,7 +156,7 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
154
156
|
(!r.provider || r.provider === g.provider) &&
|
|
155
157
|
(r.scope === 'global' || r.targetRoot === g.projectRoot))) return true;
|
|
156
158
|
const shared = sharedCodexModelRules({ cfg, dir, root: g.projectRoot, model: g.from, provider: g.provider });
|
|
157
|
-
return ['T1', 'T2'].every((tier) => !g.tiers[tier] || shared.some((r) => r.category === g.category && r.tier === tier));
|
|
159
|
+
return ['T1', 'T2', 'T1H'].every((tier) => !g.tiers[tier] || shared.some((r) => r.category === g.category && r.tier === tier));
|
|
158
160
|
};
|
|
159
161
|
const known = new Set([...observed, ...rules.filter((r) => r.provider).map((r) => `${r.provider}|${r.model}`)]);
|
|
160
162
|
const target = cfg?.codex?.delegateTarget?.model;
|
package/src/commands/brief.js
CHANGED
|
@@ -23,6 +23,8 @@ export async function run({ hasFlag }) {
|
|
|
23
23
|
}
|
|
24
24
|
const ctx = readStdinJson() || {};
|
|
25
25
|
const parts = [];
|
|
26
|
+
// Team prompt upload (Enterprise, opt-in): a stat-light gate, the work runs in a detached child.
|
|
27
|
+
try { (await import('../team-ratchet.js')).spawnTeamSyncOnPrompt(); } catch (e) { debug('brief:team-prompts', e); }
|
|
26
28
|
try {
|
|
27
29
|
const { runBrief } = await import('../brief.js');
|
|
28
30
|
// `root` is the session's cwd for the same reason routeHint takes it below:
|
|
@@ -43,7 +43,7 @@ function printShared(cfg, { details = false } = {}) {
|
|
|
43
43
|
if (missing.length) {
|
|
44
44
|
console.log(`Unmapped tiers for ${parent || 'any parent model'} on ${provider || 'unknown provider'}: ${missing.join(', ')}`
|
|
45
45
|
+ `${autoOn && auto.reason ? ` (auto: ${auto.reason})` : ''}. `
|
|
46
|
-
+ `Set: sprag delegate shared map <T1|T2> --from ${parent || '<parent-model>'} --model <target-model> --agent codex`);
|
|
46
|
+
+ `Set: sprag delegate shared map <T1|T1H|T2> --from ${parent || '<parent-model>'} --model <target-model> --agent codex`);
|
|
47
47
|
}
|
|
48
48
|
console.log(`Automatic tier models: ${autoOn ? 'on' : 'off'} (turn ${autoOn ? 'off' : 'on'}: sprag delegate shared auto ${autoOn ? 'off' : 'on'} --agent codex)`);
|
|
49
49
|
if (!parent && targets.length) console.log('Codex config names no default model; each mapping routes only sessions on its --from model.');
|
|
@@ -74,7 +74,7 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
74
74
|
if (sub === 'shared') {
|
|
75
75
|
const action = args[2] || 'status';
|
|
76
76
|
if (!['status', 'on', 'off', 'map', 'unmap', 'min-context', 'auto'].includes(action)) {
|
|
77
|
-
throw new Error('Usage: delegate shared status|on|off|map <T1|T2>|unmap <T1|T2>|min-context <tokens|default>|auto on|off --agent codex');
|
|
77
|
+
throw new Error('Usage: delegate shared status|on|off|map <T1|T1H|T2>|unmap <T1|T1H|T2>|min-context <tokens|default>|auto on|off --agent codex');
|
|
78
78
|
}
|
|
79
79
|
if (hasFlag('--global') || hasFlag('--project')) throw new Error('Shared policies retain their approved scope; mappings do not change it.');
|
|
80
80
|
const provided = (flag) => args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
@@ -86,7 +86,7 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
86
86
|
const tier = args[3];
|
|
87
87
|
const from = getArg('--from');
|
|
88
88
|
const provider = getArg('--provider') ?? codexProviderName();
|
|
89
|
-
if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1 or T2.');
|
|
89
|
+
if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1, T1H or T2.');
|
|
90
90
|
const same = (t) => t.tier === tier && t.from === from && t.provider === provider;
|
|
91
91
|
if (action === 'map') {
|
|
92
92
|
const target = validateCodexSharedTarget({ tier, from, provider, model: getArg('--model'), effort: getArg('--effort') });
|
package/src/commands/codex.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { envVar } from '../env.js';
|
|
1
2
|
import { createArgs } from '../cli-args.js';
|
|
2
3
|
import { configureCodexHooks } from '../codex/installer.js';
|
|
3
4
|
import { initCodexHarness, uninitCodexHarness, codexHarnessStatus, syncCodexKoreanBlock } from '../codex/harness.js';
|
|
@@ -30,9 +31,9 @@ Usage: sprag <command> --agent codex [options]
|
|
|
30
31
|
delegate implementation After design: resolve T1 with --from, --provider, --session
|
|
31
32
|
delegate rules Codex rules and shared delegation policies
|
|
32
33
|
delegate shared on|off|status Reuse approved policies; keep original scopes
|
|
33
|
-
delegate shared map T1|T2
|
|
34
|
+
delegate shared map T1|T1H|T2 Map a tier: --from <parent> --model <target>
|
|
34
35
|
Optional --provider <id> and --effort <level>
|
|
35
|
-
delegate shared unmap T1|T2
|
|
36
|
+
delegate shared unmap T1|T1H|T2 Remove a mapping: --from <parent> [--provider <id>]
|
|
36
37
|
delegate shared min-context N Parent input tokens before shared guidance; default resets
|
|
37
38
|
delegate shared auto on|off Resolve unmapped tiers from priced models already run in Codex
|
|
38
39
|
route-scan [--refresh] Discover recurring simple turns in local logs
|
|
@@ -202,7 +203,7 @@ export async function run({ args, version }) {
|
|
|
202
203
|
for (const c of open) {
|
|
203
204
|
const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (~${signedUsd(c.estSavedUsd, 4)} over ${c.count} turns)` : ''}`
|
|
204
205
|
: `-> choose --model${c.alternatives.length ? ` (priced cheaper: ${c.alternatives.join(', ')})` : ''}`;
|
|
205
|
-
console.log(`R${c.id} ${c.category} x${c.count} [T2 ${c.tiers.T2} / T1 ${c.tiers.T1}] ${c.provider || 'unknown provider'} / ${c.from} ${target}`);
|
|
206
|
+
console.log(`R${c.id} ${c.category} x${c.count} [T2 ${c.tiers.T2} / T1 ${c.tiers.T1} / T1H ${c.tiers.T1H ?? 0}] ${c.provider || 'unknown provider'} / ${c.from} ${target}`);
|
|
206
207
|
console.log(` ${c.projectRoot}`);
|
|
207
208
|
console.log(` e.g. ${JSON.stringify(c.example)}`);
|
|
208
209
|
}
|
|
@@ -412,7 +413,7 @@ export async function run({ args, version }) {
|
|
|
412
413
|
const hook = configureCodexHooks({ panelAuto });
|
|
413
414
|
console.log(`Codex hooks: ${hook.file} (${hook.action})`);
|
|
414
415
|
for (const line of describePanelSetup(panel)) console.log(`Codex ${line}`);
|
|
415
|
-
if (
|
|
416
|
+
if (envVar('NO_HARNESS') !== '1') {
|
|
416
417
|
const h = initCodexHarness({ root, scope: 'global' });
|
|
417
418
|
console.log(`Codex harness: ${h.file}`);
|
|
418
419
|
try {
|
|
@@ -544,7 +545,7 @@ export async function run({ args, version }) {
|
|
|
544
545
|
const { createRequire } = await import('node:module');
|
|
545
546
|
const doc2md = createRequire(import.meta.url)('../doc2md.cjs');
|
|
546
547
|
const { codexHookStatus } = await import('../codex/installer.js');
|
|
547
|
-
console.log(`Codex document conversion: ${loadConfig()?.codex?.doc2md === false ||
|
|
548
|
+
console.log(`Codex document conversion: ${loadConfig()?.codex?.doc2md === false || envVar('NO_DOC2MD') === '1' ? 'off' : 'on'}`);
|
|
548
549
|
console.log(`Formats: ${doc2md.TARGET_EXTENSIONS.join(' ')}`);
|
|
549
550
|
console.log(`Converter: ${doc2md.findInterpreter() || 'missing; run sprag doc2md install-converter --agent codex'}`);
|
|
550
551
|
console.log(`Cache: ${doc2md.cacheDir()}`);
|
package/src/commands/harness.js
CHANGED
|
@@ -255,7 +255,7 @@ export async function run({ args, hasFlag }) {
|
|
|
255
255
|
example: routeCandidate.example,
|
|
256
256
|
count: routeCandidate.count,
|
|
257
257
|
// Calibrated budget snapshot — ratchet-model.md restates it when it
|
|
258
|
-
// merges a category's T2 and
|
|
258
|
+
// merges a category's T2, T1 and T1H rules into one conditional rule.
|
|
259
259
|
budget: routeCandidate.budget || null,
|
|
260
260
|
promotedAt: new Date().toISOString().slice(0, 10),
|
|
261
261
|
lastSeen: new Date().toISOString().slice(0, 10),
|
|
@@ -274,9 +274,8 @@ export async function run({ args, hasFlag }) {
|
|
|
274
274
|
// Event-triggered refresh: establish the new rule's stat baseline
|
|
275
275
|
// right away instead of waiting for the next data-gated rescan.
|
|
276
276
|
try {
|
|
277
|
-
const {
|
|
278
|
-
|
|
279
|
-
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
277
|
+
const { spawnDetachedCli } = await import('../cli-entry.js');
|
|
278
|
+
spawnDetachedCli(['route-scan', '--refresh', '--quiet']);
|
|
280
279
|
} catch (e) { debug('promote:spawn-refresh', e); /* baseline arrives on the next gated rescan */ }
|
|
281
280
|
return;
|
|
282
281
|
}
|
package/src/commands/install.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* terminal (postinstall, CI, pipes) it keeps theirs, as before.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { envVar } from '../env.js';
|
|
21
22
|
import { debug } from '../debug.js';
|
|
22
23
|
|
|
23
24
|
/** Thrown when the user declines an optional step, so it is not reported as a failure. */
|
|
@@ -50,7 +51,7 @@ export async function run({ hasFlag }) {
|
|
|
50
51
|
// English — the one outcome a Korean-locale machine should not get by
|
|
51
52
|
// default. Already decided means never asked again.
|
|
52
53
|
try {
|
|
53
|
-
if (!languageDecided() && !
|
|
54
|
+
if (!languageDecided() && !envVar('LANG')) {
|
|
54
55
|
const { koreanLocaleDetected } = await import('../korean-style.js');
|
|
55
56
|
const detected = koreanLocaleDetected() ? 'ko' : 'en';
|
|
56
57
|
console.log('');
|
|
@@ -63,14 +64,14 @@ export async function run({ hasFlag }) {
|
|
|
63
64
|
}
|
|
64
65
|
setUserLanguage(chosen);
|
|
65
66
|
console.log(` language: set to ${chosen === 'ko' ? '한국어' : 'English'} — change it any time with \`sprag mode lang=${chosen === 'ko' ? 'en' : 'ko'}\``);
|
|
66
|
-
} else if (
|
|
67
|
+
} else if (envVar('LANG')) {
|
|
67
68
|
// Escape hatch for scripted installs, which cannot answer a prompt but
|
|
68
69
|
// do know which language the machine's user reads.
|
|
69
|
-
const forced = setUserLanguage(
|
|
70
|
+
const forced = setUserLanguage(envVar('LANG'));
|
|
70
71
|
console.log('');
|
|
71
72
|
console.log(forced
|
|
72
|
-
? ` language: set to ${forced} (
|
|
73
|
-
: ` language: ignored
|
|
73
|
+
? ` language: set to ${forced} (SPRAG_LANG)`
|
|
74
|
+
: ` language: ignored SPRAG_LANG=${envVar('LANG')} — use 'ko' or 'en'`);
|
|
74
75
|
}
|
|
75
76
|
} catch (e) {
|
|
76
77
|
debug('install:language', e); // the English fallback still works
|
|
@@ -166,16 +167,16 @@ export async function run({ hasFlag }) {
|
|
|
166
167
|
// any one project, and the harness principles are project-independent.
|
|
167
168
|
// Only ever ADDS — harnessInit appends its own marked block, backs up the
|
|
168
169
|
// previous file, and leaves surrounding content untouched. Skipped when a
|
|
169
|
-
// block is already there (nothing to do) and when
|
|
170
|
+
// block is already there (nothing to do) and when SPRAG_NO_HARNESS=1 is set,
|
|
170
171
|
// for anyone who wants the statusline without the CLAUDE.md rules.
|
|
171
172
|
try {
|
|
172
173
|
const { harnessInit, harnessStatus } = await import('../harness.js');
|
|
173
174
|
const before = harnessStatus(undefined, { scope: 'global' });
|
|
174
|
-
if (
|
|
175
|
+
if (envVar('NO_HARNESS') === '1') {
|
|
175
176
|
console.log('');
|
|
176
177
|
console.log(lang === 'ko'
|
|
177
|
-
? ' harness:
|
|
178
|
-
: ' harness: skipped (
|
|
178
|
+
? ' harness: SPRAG_NO_HARNESS=1 이므로 건너뜁니다 (나중에 `harness init --global`).'
|
|
179
|
+
: ' harness: skipped (SPRAG_NO_HARNESS=1) — run `harness init --global` later.');
|
|
179
180
|
} else if (before.hasBlock) {
|
|
180
181
|
console.log('');
|
|
181
182
|
console.log(lang === 'ko'
|
|
@@ -242,11 +243,11 @@ export async function run({ hasFlag }) {
|
|
|
242
243
|
// upgrade never re-enables something they turned off.
|
|
243
244
|
try {
|
|
244
245
|
const ks = await import('../korean-style.js');
|
|
245
|
-
if (
|
|
246
|
+
if (envVar('NO_KOREAN') === '1') {
|
|
246
247
|
console.log('');
|
|
247
248
|
console.log(lang === 'ko'
|
|
248
|
-
? ' korean:
|
|
249
|
-
: ' korean: skipped (
|
|
249
|
+
? ' korean: SPRAG_NO_KOREAN=1 이므로 건너뜁니다 (나중에 `korean on`).'
|
|
250
|
+
: ' korean: skipped (SPRAG_NO_KOREAN=1) — run `korean on` later.');
|
|
250
251
|
} else if (ks.koreanStyleDecided()) {
|
|
251
252
|
console.log('');
|
|
252
253
|
console.log(lang === 'ko'
|
|
@@ -328,11 +329,11 @@ export async function run({ hasFlag }) {
|
|
|
328
329
|
const { createRequire } = await import('node:module');
|
|
329
330
|
const require = createRequire(import.meta.url);
|
|
330
331
|
const doc2md = require('../doc2md.cjs');
|
|
331
|
-
if (
|
|
332
|
+
if (envVar('NO_DOC2MD') === '1') {
|
|
332
333
|
console.log('');
|
|
333
334
|
console.log(lang === 'ko'
|
|
334
|
-
? ' doc2md:
|
|
335
|
-
: ' doc2md: skipped (
|
|
335
|
+
? ' doc2md: SPRAG_NO_DOC2MD=1 이므로 건너뜁니다 (나중에 `doc2md on`).'
|
|
336
|
+
: ' doc2md: skipped (SPRAG_NO_DOC2MD=1) — run `doc2md on` later.');
|
|
336
337
|
} else {
|
|
337
338
|
const { installDoc2mdHook } = await import('../installer.js');
|
|
338
339
|
const res = installDoc2mdHook();
|
|
@@ -430,7 +431,7 @@ export async function run({ hasFlag }) {
|
|
|
430
431
|
try {
|
|
431
432
|
const { codexUserDir } = await import('../agent.js');
|
|
432
433
|
const { existsSync } = await import('node:fs');
|
|
433
|
-
if (
|
|
434
|
+
if (envVar('NO_CODEX') !== '1' && existsSync(codexUserDir())) {
|
|
434
435
|
let proceed = true;
|
|
435
436
|
if (interactive) {
|
|
436
437
|
console.log('');
|
|
@@ -451,7 +452,7 @@ export async function run({ hasFlag }) {
|
|
|
451
452
|
if (!tmuxAvailable() && !await confirm(lang === 'ko'
|
|
452
453
|
? ' codex: tmux 가 없습니다. Homebrew 로 설치해 같은 창에 패널을 붙일까요?'
|
|
453
454
|
: ' codex: tmux is missing. Install it with Homebrew so the panel shares the Codex window?', { defaultValue: true })) {
|
|
454
|
-
env = { ...process.env,
|
|
455
|
+
env = { ...process.env, SPRAG_NO_TMUX: '1' };
|
|
455
456
|
}
|
|
456
457
|
}
|
|
457
458
|
let panel;
|
|
@@ -465,7 +466,7 @@ export async function run({ hasFlag }) {
|
|
|
465
466
|
console.log('');
|
|
466
467
|
console.log(` codex: hooks ${hook.action} (${hook.file})`);
|
|
467
468
|
for (const line of describePanelSetup(panel, lang)) console.log(` codex: ${line}`);
|
|
468
|
-
if (
|
|
469
|
+
if (envVar('NO_HARNESS') !== '1') {
|
|
469
470
|
const h = initCodexHarness({ scope: 'global' });
|
|
470
471
|
console.log(` codex: harness ${h.file}`);
|
|
471
472
|
try {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: presets — which presets this machine uses, and fetching them.
|
|
3
|
+
*
|
|
4
|
+
* sprag presets [status] source, version, fetch times, signing key, next check
|
|
5
|
+
* sprag presets sync [--force] fetch the published bundle now (read-only)
|
|
6
|
+
* sprag presets show [--json] the effective ratchet, model-routing and agent presets
|
|
7
|
+
*
|
|
8
|
+
* The published bundle comes from the /ops console and applies only when its
|
|
9
|
+
* signature verifies; otherwise the presets bundled with this version are used.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const REASONS = {
|
|
13
|
+
en: {
|
|
14
|
+
'no-cache': 'no published bundle has been fetched yet',
|
|
15
|
+
disabled: 'remote presets are off (SPRAG_NO_PRESET_SYNC=1 or SPRAG_NO_UPDATE_CHECK=1)',
|
|
16
|
+
'no-keys': 'no signing keys yet: this version ships no public key, so a published bundle is not applied',
|
|
17
|
+
unsigned: 'the published bundle is not signed',
|
|
18
|
+
'unknown-kid': 'the bundle is signed with a key this version does not know',
|
|
19
|
+
'bad-sig': 'the signature does not match the bundle',
|
|
20
|
+
'min-cli': 'the bundle needs a newer sprag (min_cli)',
|
|
21
|
+
format: 'the bundle format is newer than this version understands',
|
|
22
|
+
shape: 'the bundle is malformed',
|
|
23
|
+
'too-large': 'the bundle is larger than 200 KB',
|
|
24
|
+
older: 'the bundle is older than the one already in use',
|
|
25
|
+
},
|
|
26
|
+
ko: {
|
|
27
|
+
'no-cache': '아직 받아 온 게시 번들이 없습니다',
|
|
28
|
+
disabled: '원격 프리셋이 꺼져 있습니다 (SPRAG_NO_PRESET_SYNC=1 또는 SPRAG_NO_UPDATE_CHECK=1)',
|
|
29
|
+
'no-keys': '아직 서명 키가 없습니다. 이 버전에 공개 키가 들어 있지 않아 게시 번들을 적용하지 않습니다',
|
|
30
|
+
unsigned: '게시 번들에 서명이 없습니다',
|
|
31
|
+
'unknown-kid': '이 버전이 모르는 키로 서명된 번들입니다',
|
|
32
|
+
'bad-sig': '서명이 번들 내용과 맞지 않습니다',
|
|
33
|
+
'min-cli': '더 새 버전의 sprag 가 필요한 번들입니다 (min_cli)',
|
|
34
|
+
format: '이 버전이 읽지 못하는 새 번들 형식입니다',
|
|
35
|
+
shape: '번들 형식이 잘못됐습니다',
|
|
36
|
+
'too-large': '번들이 200 KB 를 넘습니다',
|
|
37
|
+
older: '이미 쓰고 있는 번들보다 오래된 번들입니다',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function when(ms) {
|
|
42
|
+
if (!ms) return null;
|
|
43
|
+
const d = new Date(ms);
|
|
44
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
45
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function run({ args, hasFlag }) {
|
|
49
|
+
const sub = args[1] || 'status';
|
|
50
|
+
const { userLanguage } = await import('../config.js');
|
|
51
|
+
const ko = userLanguage() === 'ko';
|
|
52
|
+
const R = REASONS[ko ? 'ko' : 'en'];
|
|
53
|
+
const why = (r) => R[r] || r;
|
|
54
|
+
const src = await import('../presets-source.js');
|
|
55
|
+
const sync = await import('../preset-sync.js');
|
|
56
|
+
const { PRESET_SIGNING_KEYS } = await import('../preset-sign.js');
|
|
57
|
+
|
|
58
|
+
if (sub === 'sync') {
|
|
59
|
+
const res = await sync.syncPresets({ force: hasFlag('--force') });
|
|
60
|
+
if (hasFlag('--background')) return;
|
|
61
|
+
const v = res.version ? `v${res.version}` : '';
|
|
62
|
+
const lines = {
|
|
63
|
+
disabled: ko ? `받지 않았습니다. ${R.disabled}` : `Not fetched: ${R.disabled}`,
|
|
64
|
+
none: ko ? '서버에 게시된 프리셋 번들이 없습니다. 패키지에 들어 있는 프리셋을 씁니다' : 'Nothing is published on the server. The bundled presets stay in use.',
|
|
65
|
+
current: ko ? `이미 최신입니다 (${v})` : `Already current (${v}).`,
|
|
66
|
+
updated: ko ? `${v} 을 받았습니다. 다음 세션부터 적용됩니다` : `Fetched ${v}. It applies from the next session start.`,
|
|
67
|
+
rejected: ko ? `${v} 을 받았지만 적용하지 않습니다. ${why(res.reason)}` : `Fetched ${v} but did not apply it: ${why(res.reason)}.`,
|
|
68
|
+
error: ko ? `가져오지 못했습니다 (${res.reason}). 패키지에 들어 있는 프리셋을 계속 씁니다` : `Could not fetch (${res.reason}). The presets in use stay as they are.`,
|
|
69
|
+
};
|
|
70
|
+
console.log(lines[res.status] || res.status);
|
|
71
|
+
if (res.status === 'error') process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (sub === 'show') {
|
|
76
|
+
const out = {
|
|
77
|
+
source: src.presetSource(),
|
|
78
|
+
ratchet_rules: src.ratchetPresetEntries(),
|
|
79
|
+
model_rules: src.modelPresets(),
|
|
80
|
+
agents: src.agentDefinitions(),
|
|
81
|
+
};
|
|
82
|
+
if (hasFlag('--json')) { console.log(JSON.stringify(out, null, 2)); return; }
|
|
83
|
+
const s = out.source;
|
|
84
|
+
console.log(s.kind === 'remote'
|
|
85
|
+
? (ko ? `출처: 게시 번들 v${s.version}` : `Source: published bundle v${s.version}`)
|
|
86
|
+
: (ko ? `출처: sprag ${src.cliVersion()} 동봉 프리셋` : `Source: bundled with sprag ${src.cliVersion()}`));
|
|
87
|
+
console.log('');
|
|
88
|
+
console.log(ko ? `래칫 프리셋 ${out.ratchet_rules.length}건` : `Ratchet presets (${out.ratchet_rules.length})`);
|
|
89
|
+
for (const r of out.ratchet_rules) {
|
|
90
|
+
const text = (ko ? r.ko : r.en).replace(/\s+/g, ' ');
|
|
91
|
+
console.log(` [${r.id}]${r.agents ? ` (${r.agents.join(', ')})` : ''} ${text.length > 110 ? text.slice(0, 109) + '…' : text}`);
|
|
92
|
+
}
|
|
93
|
+
console.log('');
|
|
94
|
+
console.log(ko ? `모델 라우팅 프리셋 ${out.model_rules.length}건` : `Model-routing presets (${out.model_rules.length})`);
|
|
95
|
+
for (const m of out.model_rules) {
|
|
96
|
+
console.log(` [${m.id}] ${m.tier} · ${ko ? m.label : m.labelEn} → ${m.agent}`);
|
|
97
|
+
}
|
|
98
|
+
console.log('');
|
|
99
|
+
console.log(ko ? `위임 에이전트 ${out.agents.length}개` : `Delegation agents (${out.agents.length})`);
|
|
100
|
+
for (const a of out.agents) {
|
|
101
|
+
console.log(` ${a.name} model=${a.model} effort=${a.effort} tools=${a.tools.join(', ')}`);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (sub !== 'status') {
|
|
107
|
+
console.error(ko ? '사용법: sprag presets [status|sync [--force]|show [--json]]' : 'Usage: sprag presets [status|sync [--force]|show [--json]]');
|
|
108
|
+
process.exitCode = 2;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const cache = src.readRemoteCache();
|
|
113
|
+
const s = src.presetSource();
|
|
114
|
+
const disabled = src.presetSyncDisabled();
|
|
115
|
+
const lines = [];
|
|
116
|
+
if (s.kind === 'remote') {
|
|
117
|
+
lines.push(ko ? `프리셋 출처: 게시 번들 v${s.version} (받은 시각 ${when(s.fetchedAt)})`
|
|
118
|
+
: `Presets source: published bundle v${s.version} (fetched ${when(s.fetchedAt)})`);
|
|
119
|
+
} else {
|
|
120
|
+
lines.push(ko ? `프리셋 출처: sprag ${src.cliVersion()} 동봉 프리셋 (대체 경로)`
|
|
121
|
+
: `Presets source: bundled with sprag ${src.cliVersion()} (fallback)`);
|
|
122
|
+
// Nothing applied but something was fetched: the rejection is the answer.
|
|
123
|
+
const reason = s.reason === 'no-cache' && cache.rejected && cache.rejected.reason ? cache.rejected.reason : s.reason;
|
|
124
|
+
lines.push(ko ? ` 이유: ${why(reason)}` : ` Why: ${why(reason)}`);
|
|
125
|
+
}
|
|
126
|
+
// Signing key status, independent of whether anything has been fetched.
|
|
127
|
+
const seenKid = (cache.bundle && cache.bundle.sig && cache.bundle.sig.kid) || null;
|
|
128
|
+
let keyLine;
|
|
129
|
+
if (PRESET_SIGNING_KEYS.length === 0) {
|
|
130
|
+
keyLine = ko ? '서명 키: 아직 없음. 공개 키가 들어간 버전이 나오기 전까지 게시 번들을 적용하지 않습니다'
|
|
131
|
+
: 'Signing keys: none yet. Published bundles are not applied until a release ships the public key.';
|
|
132
|
+
} else if (s.kind === 'remote') {
|
|
133
|
+
keyLine = ko ? `서명 키: 정상 (${s.kid})` : `Signing key: ok (${s.kid})`;
|
|
134
|
+
} else if (seenKid && !PRESET_SIGNING_KEYS.some((k) => k.kid === seenKid)) {
|
|
135
|
+
keyLine = ko ? `서명 키: 모르는 키 (${seenKid})` : `Signing key: unknown kid (${seenKid})`;
|
|
136
|
+
} else {
|
|
137
|
+
keyLine = ko ? `서명 키: ${PRESET_SIGNING_KEYS.map((k) => k.kid).join(', ')}` : `Signing keys: ${PRESET_SIGNING_KEYS.map((k) => k.kid).join(', ')}`;
|
|
138
|
+
}
|
|
139
|
+
lines.push(keyLine);
|
|
140
|
+
if (cache.rejected && cache.rejected.version != null) {
|
|
141
|
+
lines.push(ko ? `마지막으로 거절한 번들: v${cache.rejected.version} (${why(cache.rejected.reason)})`
|
|
142
|
+
: `Last rejected: v${cache.rejected.version} (${why(cache.rejected.reason)})`);
|
|
143
|
+
}
|
|
144
|
+
if (cache.lastError && cache.lastError.message) {
|
|
145
|
+
lines.push(ko ? `마지막 오류: ${cache.lastError.message} (${when(cache.lastError.at)})`
|
|
146
|
+
: `Last error: ${cache.lastError.message} (${when(cache.lastError.at)})`);
|
|
147
|
+
}
|
|
148
|
+
lines.push(ko ? `마지막 확인: ${when(cache.checkedAt) || '없음'}` : `Last check: ${when(cache.checkedAt) || 'never'}`);
|
|
149
|
+
if (disabled) {
|
|
150
|
+
lines.push(ko ? '다음 확인: 꺼져 있음' : 'Next check: off');
|
|
151
|
+
} else {
|
|
152
|
+
const next = sync.nextPresetCheckAt(cache);
|
|
153
|
+
lines.push(!next || next <= Date.now()
|
|
154
|
+
? (ko ? '다음 확인: 다음 세션 시작 때' : 'Next check: at the next session start')
|
|
155
|
+
: (ko ? `다음 확인: ${when(next)} 이후 세션 시작 때` : `Next check: at the first session start after ${when(next)}`));
|
|
156
|
+
}
|
|
157
|
+
console.log(lines.join('\n'));
|
|
158
|
+
}
|
|
@@ -56,8 +56,8 @@ export async function run({ args, hasFlag }) {
|
|
|
56
56
|
const result = await refreshGatewayModelMap({ base, key });
|
|
57
57
|
if (!result) {
|
|
58
58
|
say(ko
|
|
59
|
-
? `LiteLLM 에서 모델 정보를 가져오지 못했습니다 (GET ${base}/model/info).
|
|
60
|
-
: `Could not fetch model info from LiteLLM (GET ${base}/model/info). Re-run with
|
|
59
|
+
? `LiteLLM 에서 모델 정보를 가져오지 못했습니다 (GET ${base}/model/info). SPRAG_DEBUG=1 로 다시 실행하면 원인이 보입니다.`
|
|
60
|
+
: `Could not fetch model info from LiteLLM (GET ${base}/model/info). Re-run with SPRAG_DEBUG=1 to see why.`);
|
|
61
61
|
process.exitCode = 1;
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
@@ -259,9 +259,8 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
259
259
|
try {
|
|
260
260
|
const cache = rs.readRouteScan();
|
|
261
261
|
if (!(await rs.shouldRescan(cache, { afterDelegation: true }))) return;
|
|
262
|
-
const {
|
|
263
|
-
|
|
264
|
-
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
262
|
+
const { spawnDetachedCli } = await import('../cli-entry.js');
|
|
263
|
+
spawnDetachedCli(['route-scan', '--refresh', '--quiet']);
|
|
265
264
|
} catch (e) {
|
|
266
265
|
// A missed refresh only means the figure lags, which is what happened
|
|
267
266
|
// before this hook existed. Never fail the tool call over it.
|
|
@@ -278,9 +277,8 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
278
277
|
let cache = rs.readRouteScan();
|
|
279
278
|
if (await rs.shouldRescan(cache)) {
|
|
280
279
|
try {
|
|
281
|
-
const {
|
|
282
|
-
|
|
283
|
-
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
280
|
+
const { spawnDetachedCli } = await import('../cli-entry.js');
|
|
281
|
+
spawnDetachedCli(['route-scan', '--refresh', '--quiet']);
|
|
284
282
|
} catch (e) { debug('route-scan:spawn-refresh', e); /* stale cache is still usable below */ }
|
|
285
283
|
}
|
|
286
284
|
const open = rs.openCandidates(cache);
|
|
@@ -296,6 +294,10 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
296
294
|
// would keep reading the old wording indefinitely. The call writes only
|
|
297
295
|
// when the rendering differs, so the common case is a read and a string
|
|
298
296
|
// compare.
|
|
297
|
+
// Central presets: kick the detached, read-only fetch when a day has
|
|
298
|
+
// passed. What it fetches is applied by the renders below at the NEXT
|
|
299
|
+
// session start; this one uses whatever was already cached.
|
|
300
|
+
try { (await import('../preset-sync.js')).spawnPresetSyncIfDue(); } catch (e) { debug('route-scan:preset-sync', e); }
|
|
299
301
|
// Agent files come first so the ratchet-model.md rendered next names them.
|
|
300
302
|
try { (await import('../delegation-agents.js')).syncDelegationAgents(); } catch (e) { debug('route-scan:delegation-agents', e); }
|
|
301
303
|
try { mrHook.syncAllFiles(); } catch (e) { debug('route-scan:sync-ratchet', e); }
|
|
@@ -303,6 +305,17 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
303
305
|
// bundled rule reaches ratchet-preset.md here, without a reinstall. It
|
|
304
306
|
// also moves presets older versions appended to ratchet.md.
|
|
305
307
|
try { (await import('../preset-ratchet.js')).preparePresetRatchet(); } catch (e) { debug('route-scan:sync-preset', e); }
|
|
308
|
+
// Team layer (opt-in): re-render the team ratchet file from the cached rules,
|
|
309
|
+
// keep the CLAUDE.md import in step with the file, and kick the detached sync
|
|
310
|
+
// when it is due. Nothing here touches the network.
|
|
311
|
+
let teamBlock = null;
|
|
312
|
+
try {
|
|
313
|
+
const tr = await import('../team-ratchet.js');
|
|
314
|
+
tr.syncTeamRatchet({ lang });
|
|
315
|
+
tr.ensureTeamImport();
|
|
316
|
+
tr.spawnTeamSyncIfDue();
|
|
317
|
+
teamBlock = tr.autoJoinNoticeBlock(undefined, lang);
|
|
318
|
+
} catch (e) { debug('route-scan:team', e); }
|
|
306
319
|
// Cowork cannot follow the ~/.claude imports, so it gets a pasted copy.
|
|
307
320
|
try { (await import('../cowork-sync.js')).syncCoworkRatchets({ discover: true }); } catch (e) { debug('route-scan:cowork-sync', e); }
|
|
308
321
|
// Registered rules whose delegated-category error rate crossed the
|
|
@@ -375,6 +388,7 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
375
388
|
} catch (e) { debug('route-scan:update-check', e); /* the offer is optional */ }
|
|
376
389
|
|
|
377
390
|
if (open.length === 0 && reviewRules.length === 0) {
|
|
391
|
+
if (teamBlock) console.log(teamBlock);
|
|
378
392
|
if (updateBlock) console.log(updateBlock);
|
|
379
393
|
if (seedBlock) console.log(seedBlock);
|
|
380
394
|
if (doc2mdBlock) console.log(doc2mdBlock);
|
|
@@ -382,6 +396,7 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
382
396
|
if (cohesionBlock) console.log(cohesionBlock);
|
|
383
397
|
return; // nothing else to inject
|
|
384
398
|
}
|
|
399
|
+
if (teamBlock) console.log(teamBlock);
|
|
385
400
|
if (updateBlock) console.log(updateBlock);
|
|
386
401
|
if (seedBlock) console.log(seedBlock);
|
|
387
402
|
if (doc2mdBlock) console.log(doc2mdBlock);
|
|
@@ -392,10 +407,10 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
392
407
|
if (open.length > 0) {
|
|
393
408
|
if (lang === 'ko') {
|
|
394
409
|
lines.push(`[sprag route-scan] 최근 ${cache.days}일 세션에서 비싼 모델(opus/fable)이 반복 처리해 온, 더 싼 모델로 넘겨도 되는 작업이 감지되었습니다.`);
|
|
395
|
-
lines.push('(R<N>은 후보 번호, T2/T1
|
|
410
|
+
lines.push('(R<N>은 후보 번호, T2/T1/T1H는 난이도 등급입니다 — 사용자에게 전달할 때는 코드가 아니라 아래 풀어쓴 설명으로 브리핑하세요)');
|
|
396
411
|
} else {
|
|
397
412
|
lines.push(`[sprag route-scan] Over the last ${cache.days} days, expensive models (opus/fable) repeatedly handled work that a cheaper model could take.`);
|
|
398
|
-
lines.push('(R<N> is the candidate id, T2/T1 the difficulty tier — brief the user with the spelled-out wording below, not the codes.)');
|
|
413
|
+
lines.push('(R<N> is the candidate id, T2/T1/T1H the difficulty tier — brief the user with the spelled-out wording below, not the codes.)');
|
|
399
414
|
}
|
|
400
415
|
for (const c of open) {
|
|
401
416
|
const tier = c.tier || 'T2';
|
|
@@ -490,7 +505,7 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
490
505
|
(await import('../first-run-note.js')).printOnce('route-scan', lang);
|
|
491
506
|
return;
|
|
492
507
|
}
|
|
493
|
-
console.log(lang === 'ko' ? '\n위임 후보 (R<N>=후보 번호, T2/T1=난이도 등급):' : '\nDelegation candidates (R<N> = candidate id, T2/T1 = difficulty tier):');
|
|
508
|
+
console.log(lang === 'ko' ? '\n위임 후보 (R<N>=후보 번호, T2/T1/T1H=난이도 등급):' : '\nDelegation candidates (R<N> = candidate id, T2/T1/T1H = difficulty tier):');
|
|
494
509
|
for (const c of open) {
|
|
495
510
|
const tier = c.tier || 'T2';
|
|
496
511
|
if (lang === 'ko') {
|
|
@@ -509,7 +524,7 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
509
524
|
console.log(lang === 'ko' ? '등록 / 무시:' : 'Promote / dismiss:');
|
|
510
525
|
console.log(' sprag harness promote R<N> --global|--project');
|
|
511
526
|
console.log(' sprag route-scan dismiss <N>');
|
|
512
|
-
// 최초 1회만 — 매번 찍으면 도구가 광고판이 된다 (
|
|
527
|
+
// 최초 1회만 — 매번 찍으면 도구가 광고판이 된다 (SPRAG_NO_NOTE=1 로 끔)
|
|
513
528
|
(await import('../first-run-note.js')).printOnce('route-scan', lang);
|
|
514
529
|
return;
|
|
515
530
|
}
|