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.
Files changed (58) hide show
  1. package/NOTICE +5 -2
  2. package/README.ko.md +16 -0
  3. package/README.md +16 -0
  4. package/bin/cli.js +53 -4
  5. package/docs/CODEX.ko.md +3 -2
  6. package/docs/CODEX.md +5 -4
  7. package/docs/TEAM.ko.md +174 -0
  8. package/docs/TEAM.md +175 -0
  9. package/package.json +5 -1
  10. package/presets/agents.json +118 -0
  11. package/presets/doc2md/convert.py +1 -1
  12. package/presets/ratchet-rules.json +12 -2
  13. package/src/agents.js +3 -1
  14. package/src/cli-entry.js +40 -0
  15. package/src/codex/delegation.js +8 -5
  16. package/src/codex/hooks.js +25 -12
  17. package/src/codex/panel-setup.js +2 -1
  18. package/src/codex/panel.js +2 -1
  19. package/src/codex/route-scan.js +6 -4
  20. package/src/commands/brief.js +2 -0
  21. package/src/commands/codex-delegate.js +3 -3
  22. package/src/commands/codex.js +6 -5
  23. package/src/commands/harness.js +3 -4
  24. package/src/commands/install.js +19 -18
  25. package/src/commands/presets.js +158 -0
  26. package/src/commands/profile-map.js +2 -2
  27. package/src/commands/route-scan.js +25 -10
  28. package/src/commands/team.js +334 -0
  29. package/src/commands/update-check.js +2 -2
  30. package/src/cost.js +6 -2
  31. package/src/cowork-sync.js +5 -4
  32. package/src/debug.js +3 -2
  33. package/src/delegation-agents.js +49 -49
  34. package/src/delegation-guard.js +3 -2
  35. package/src/doc2md.cjs +4 -3
  36. package/src/env.cjs +7 -0
  37. package/src/env.js +19 -0
  38. package/src/first-run-note.js +3 -2
  39. package/src/harness-templates.js +10 -0
  40. package/src/harness.js +4 -14
  41. package/src/hook.cjs +4 -3
  42. package/src/model-rules.js +58 -29
  43. package/src/preset-sign.js +106 -0
  44. package/src/preset-sync.js +133 -0
  45. package/src/presets-source.js +209 -0
  46. package/src/prompt.js +2 -1
  47. package/src/route-caps.js +3 -3
  48. package/src/route-inject.js +24 -15
  49. package/src/route-scan.js +39 -15
  50. package/src/seed-rules.js +14 -16
  51. package/src/shared-model-rules.js +1 -1
  52. package/src/star-note.js +3 -2
  53. package/src/statusline-guard.js +144 -0
  54. package/src/stdin-payload.js +33 -4
  55. package/src/team-prompts.js +353 -0
  56. package/src/team-ratchet.js +276 -0
  57. package/src/team-sync.js +625 -0
  58. package/src/update-check.js +3 -2
@@ -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.CTS_NO_TMUX === '1' || (env.CI && env.CI !== 'false')) return { status: 'skipped' };
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)' };
@@ -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 && process.env.CTS_NO_DOC2MD !== '1',
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
  };
@@ -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
- const pick = group.tiers.T2 >= group.tiers.T1 ? eligible[0] : eligible.at(-1);
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;
@@ -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') });
@@ -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 Map a tier: --from <parent> --model <target>
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 Remove a mapping: --from <parent> [--provider <id>]
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 (process.env.CTS_NO_HARNESS !== '1') {
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 || process.env.CTS_NO_DOC2MD === '1' ? 'off' : 'on'}`);
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()}`);
@@ -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 T1 rules into one conditional rule.
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 { spawn } = await import('node:child_process');
278
- spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
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
  }
@@ -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() && !process.env.CTS_LANG) {
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 (process.env.CTS_LANG) {
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(process.env.CTS_LANG);
70
+ const forced = setUserLanguage(envVar('LANG'));
70
71
  console.log('');
71
72
  console.log(forced
72
- ? ` language: set to ${forced} (CTS_LANG)`
73
- : ` language: ignored CTS_LANG=${process.env.CTS_LANG} — use 'ko' or 'en'`);
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 CTS_NO_HARNESS=1 is set,
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 (process.env.CTS_NO_HARNESS === '1') {
175
+ if (envVar('NO_HARNESS') === '1') {
175
176
  console.log('');
176
177
  console.log(lang === 'ko'
177
- ? ' harness: CTS_NO_HARNESS=1 이므로 건너뜁니다 (나중에 `harness init --global`).'
178
- : ' harness: skipped (CTS_NO_HARNESS=1) — run `harness init --global` later.');
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 (process.env.CTS_NO_KOREAN === '1') {
246
+ if (envVar('NO_KOREAN') === '1') {
246
247
  console.log('');
247
248
  console.log(lang === 'ko'
248
- ? ' korean: CTS_NO_KOREAN=1 이므로 건너뜁니다 (나중에 `korean on`).'
249
- : ' korean: skipped (CTS_NO_KOREAN=1) — run `korean on` later.');
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 (process.env.CTS_NO_DOC2MD === '1') {
332
+ if (envVar('NO_DOC2MD') === '1') {
332
333
  console.log('');
333
334
  console.log(lang === 'ko'
334
- ? ' doc2md: CTS_NO_DOC2MD=1 이므로 건너뜁니다 (나중에 `doc2md on`).'
335
- : ' doc2md: skipped (CTS_NO_DOC2MD=1) — run `doc2md on` later.');
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 (process.env.CTS_NO_CODEX !== '1' && existsSync(codexUserDir())) {
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, CTS_NO_TMUX: '1' };
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 (process.env.CTS_NO_HARNESS !== '1') {
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). CTS_DEBUG=1 로 다시 실행하면 원인이 보입니다.`
60
- : `Could not fetch model info from LiteLLM (GET ${base}/model/info). Re-run with CTS_DEBUG=1 to see why.`);
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 { spawn } = await import('node:child_process');
263
- spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
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 { spawn } = await import('node:child_process');
282
- spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
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회만 — 매번 찍으면 도구가 광고판이 된다 (CTS_NO_NOTE=1 로 끔)
527
+ // 최초 1회만 — 매번 찍으면 도구가 광고판이 된다 (SPRAG_NO_NOTE=1 로 끔)
513
528
  (await import('../first-run-note.js')).printOnce('route-scan', lang);
514
529
  return;
515
530
  }