sprag-cli 3.70.0 → 3.72.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 (45) hide show
  1. package/README.ko.md +7 -1
  2. package/README.md +7 -1
  3. package/bin/cli.js +47 -5
  4. package/docs/CODEX.ko.md +2 -2
  5. package/docs/CODEX.md +4 -4
  6. package/docs/TEAM.ko.md +74 -19
  7. package/docs/TEAM.md +75 -19
  8. package/package.json +2 -1
  9. package/presets/agents.json +118 -0
  10. package/presets/doc2md/convert.py +1 -1
  11. package/presets/ratchet-rules.json +17 -2
  12. package/src/cli-entry.js +40 -0
  13. package/src/codex/hooks.js +11 -10
  14. package/src/codex/panel-setup.js +2 -1
  15. package/src/codex/panel.js +2 -1
  16. package/src/commands/brief.js +2 -0
  17. package/src/commands/codex.js +3 -2
  18. package/src/commands/harness.js +2 -3
  19. package/src/commands/install.js +19 -18
  20. package/src/commands/presets.js +158 -0
  21. package/src/commands/profile-map.js +2 -2
  22. package/src/commands/route-scan.js +9 -7
  23. package/src/commands/team.js +82 -26
  24. package/src/commands/update-check.js +2 -2
  25. package/src/debug.js +3 -2
  26. package/src/delegation-agents.js +48 -60
  27. package/src/doc2md.cjs +4 -3
  28. package/src/env.cjs +7 -0
  29. package/src/env.js +19 -0
  30. package/src/first-run-note.js +3 -2
  31. package/src/harness.js +4 -14
  32. package/src/hook.cjs +4 -3
  33. package/src/preset-sign.js +106 -0
  34. package/src/preset-sync.js +133 -0
  35. package/src/presets-source.js +209 -0
  36. package/src/prompt.js +2 -1
  37. package/src/route-scan.js +14 -1
  38. package/src/seed-rules.js +14 -16
  39. package/src/star-note.js +3 -2
  40. package/src/statusline-guard.js +144 -0
  41. package/src/stdin-payload.js +33 -4
  42. package/src/team-prompts.js +353 -0
  43. package/src/team-ratchet.js +116 -16
  44. package/src/team-sync.js +336 -37
  45. package/src/update-check.js +3 -2
package/src/doc2md.cjs CHANGED
@@ -28,6 +28,7 @@ const path = require('node:path');
28
28
  const crypto = require('node:crypto');
29
29
  const { spawn, spawnSync } = require('node:child_process');
30
30
  const ledger = require('./doc2md-ledger.cjs');
31
+ const { envVar } = require('./env.cjs');
31
32
 
32
33
  // Formats where the original is of no use to the model. Images are absent
33
34
  // deliberately: markitdown returns nothing for them, and OCR misread resource
@@ -247,7 +248,7 @@ const IS_WINDOWS = process.platform === 'win32';
247
248
  */
248
249
  function interpreterCandidates() {
249
250
  const out = [];
250
- if (process.env.CTS_DOC2MD_PYTHON) out.push({ bin: process.env.CTS_DOC2MD_PYTHON, args: [] });
251
+ if (envVar('DOC2MD_PYTHON')) out.push({ bin: envVar('DOC2MD_PYTHON'), args: [] });
251
252
  // The tool's own venv, created by `doc2md install-converter`. First because
252
253
  // it is the only one this tool controls: telling people to `pip install`
253
254
  // into the system interpreter is how a token-saving CLI ends up owning a
@@ -477,7 +478,7 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
477
478
  // The .fig parser is an npm install of a few hundred KB, fast enough to
478
479
  // wait for inline the first time a Figma file turns up.
479
480
  if (!result.ok && result.reason === 'no-figparser'
480
- && process.env.CTS_DOC2MD_NO_AUTOINSTALL !== '1') {
481
+ && envVar('DOC2MD_NO_AUTOINSTALL') !== '1') {
481
482
  fig2md.installFigParser(userDataDir());
482
483
  result = spawnFigConvert(fig2md, filePath);
483
484
  }
@@ -565,7 +566,7 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
565
566
  * Returns true when a converter is ready to use right now.
566
567
  */
567
568
  function ensureConverterInstalled({ waitMs = 15_000 } = {}) {
568
- if (process.env.CTS_DOC2MD_NO_AUTOINSTALL === '1') return false;
569
+ if (envVar('DOC2MD_NO_AUTOINSTALL') === '1') return false;
569
570
  if (findInterpreter()) return true;
570
571
 
571
572
  const lock = path.join(userDataDir(), 'doc2md-install.lock');
package/src/env.cjs ADDED
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+ // CommonJS twin of src/env.js. Keep the two identical in behaviour;
3
+ // test/env.test.js pins them together.
4
+ function envVar(name, env = process.env) {
5
+ return env['SPRAG_' + name] ?? env['CTS_' + name];
6
+ }
7
+ module.exports = { envVar };
package/src/env.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Reads a SPRAG_ environment variable, falling back to its legacy CTS_ name.
3
+ *
4
+ * The package used to be called claude-token-saver and its variables carried a
5
+ * CTS_ prefix. Users still have those set in shells, CI and Claude Code managed
6
+ * settings, so every read takes the SPRAG_ name first and the CTS_ name second.
7
+ * Pass the suffix: envVar('NO_TEAM_SYNC') answers env.SPRAG_NO_TEAM_SYNC ??
8
+ * env.CTS_NO_TEAM_SYNC.
9
+ *
10
+ * src/env.cjs is the CommonJS twin for the .cjs files; test/env.test.js pins
11
+ * the two together.
12
+ *
13
+ * @param {string} name the variable name without its prefix
14
+ * @param {Record<string, string | undefined>} [env]
15
+ * @returns {string | undefined}
16
+ */
17
+ export function envVar(name, env = process.env) {
18
+ return env['SPRAG_' + name] ?? env['CTS_' + name];
19
+ }
@@ -5,10 +5,11 @@
5
5
  * ONCE per note key and then never again: the shown-at timestamp is persisted
6
6
  * next to the other state files and checked before anything is printed.
7
7
  *
8
- * Opt out entirely with CTS_NO_NOTE=1 (also honoured by anything that pipes
8
+ * Opt out entirely with SPRAG_NO_NOTE=1 (also honoured by anything that pipes
9
9
  * our output somewhere it does not belong).
10
10
  */
11
11
 
12
+ import { envVar } from './env.js';
12
13
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
13
14
  import { join } from 'node:path';
14
15
  import { userDataDir } from './paths.js';
@@ -53,7 +54,7 @@ export function markNoteShown(key, now = Date.now()) {
53
54
  /** True only the first time this key is asked about. Best-effort — a
54
55
  * read-only state dir just means the note repeats, never that we crash. */
55
56
  export function shouldShowOnce(key, { now = Date.now() } = {}) {
56
- if (process.env.CTS_NO_NOTE === '1') return false;
57
+ if (envVar('NO_NOTE') === '1') return false;
57
58
  if (load()[key]) return false;
58
59
  try {
59
60
  markShown(key, now);
package/src/harness.js CHANGED
@@ -11,7 +11,6 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'no
11
11
  import { join, resolve, dirname } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
13
  import { createRequire } from 'node:module';
14
- import { fileURLToPath } from 'node:url';
15
14
  import {
16
15
  HARNESS_SECTIONS,
17
16
  HARNESS_BLOCK_BEGIN,
@@ -28,6 +27,7 @@ import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet
28
27
  import { compactWindowWarningForStatusline } from './compact-window.js';
29
28
  import { userLanguage } from './config.js';
30
29
  import { codexUserDir } from './agent.js';
30
+ import { ratchetPresetEntries } from './presets-source.js';
31
31
 
32
32
  const require = createRequire(import.meta.url);
33
33
  function readHarnessState() {
@@ -441,19 +441,9 @@ export async function harnessPull({ root = findProjectRoot(), scope = 'global',
441
441
  * turns a rule the user already answered into a new one.
442
442
  */
443
443
  export function presetRuleEntries() {
444
- try {
445
- // fileURLToPath, not `.pathname`: the latter yields `/D:/repo/src` on
446
- // Windows, where the read fails and the catch below turns a missing
447
- // file into an empty rule set without ever saying so.
448
- const path = join(dirname(fileURLToPath(import.meta.url)), '..', 'presets', 'ratchet-rules.json');
449
- const data = JSON.parse(readFileSync(path, 'utf8'));
450
- return (Array.isArray(data.rules) ? data.rules : [])
451
- .filter((r) => r && typeof r.ko === 'string' && r.ko.trim())
452
- .map((r) => ({ ko: r.ko.trim(), en: (r.en || r.ko).trim(),
453
- ...(Array.isArray(r.agents) ? { agents: r.agents.filter((a) => typeof a === 'string') } : {}) }));
454
- } catch {
455
- return [];
456
- }
444
+ // The published bundle when it verifies, else presets/ratchet-rules.json
445
+ // (presets-source.js). Each entry carries its stable id.
446
+ return ratchetPresetEntries();
457
447
  }
458
448
 
459
449
  /** Preset rule text in the user's configured language. */
package/src/hook.cjs CHANGED
@@ -13,11 +13,12 @@
13
13
  'use strict';
14
14
 
15
15
  // CJS twin of src/debug.js \u2014 hook failures must stay silent for the session
16
- // but be visible under CTS_DEBUG=1, otherwise a broken path is
16
+ // but be visible under SPRAG_DEBUG=1, otherwise a broken path is
17
17
  // indistinguishable from "nothing to do".
18
- var CTS_DEBUG = !!process.env.CTS_DEBUG;
18
+ // Standalone hook (no requires), so the SPRAG_ -> CTS_ fallback is inline.
19
+ var DEBUG_ON = !!(process.env.SPRAG_DEBUG ?? process.env.CTS_DEBUG);
19
20
  function dbg(scope, err) {
20
- if (!CTS_DEBUG) return;
21
+ if (!DEBUG_ON) return;
21
22
  process.stderr.write('[cts:' + scope + '] ' + ((err && err.stack) || String(err)) + '\n');
22
23
  }
23
24
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * preset-sign — the signature on a centrally published preset bundle.
3
+ *
4
+ * The superuser edits presets in the /ops console and signs each published
5
+ * bundle in the browser with an ECDSA P-256 key (WebCrypto, SHA-256). The CLI
6
+ * applies a remote bundle only when that signature verifies against a public key
7
+ * listed here, so a compromised server or a man in the middle cannot push
8
+ * ratchet text or agent prompts into every session: the worst it can do is
9
+ * withhold updates, and then the presets bundled in the npm package are used.
10
+ *
11
+ * The signing input is canonical JSON of the six bundle fields that carry
12
+ * meaning (format, version, min_cli, ratchet_rules, model_rules, agents): keys
13
+ * sorted recursively, no whitespace. site/assets/preset-sign.js is the browser
14
+ * copy of canonicalJson(); test/preset-sign.test.js signs with that copy and
15
+ * verifies with this one, so the two cannot drift apart.
16
+ *
17
+ * Verification here is synchronous (node:crypto with IEEE P1363 signatures,
18
+ * the encoding WebCrypto produces), because the presets are read on the
19
+ * session-start path by functions that are synchronous today. It takes well
20
+ * under a millisecond.
21
+ */
22
+
23
+ import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
24
+
25
+ /**
26
+ * Public keys allowed to sign preset bundles: [{ kid, jwk }].
27
+ *
28
+ * EMPTY until the superuser creates the signing key in the /ops console
29
+ * (sprag.io/ops/#presets → Signing key → Create). After that, paste the public
30
+ * JWK shown there as one entry, with `kid` set to the fingerprint the console
31
+ * shows next to it, e.g.
32
+ *
33
+ * { kid: '0123456789abcdef', jwk: { kty: 'EC', crv: 'P-256', x: '…', y: '…' } }
34
+ *
35
+ * While this list is empty every remote bundle is rejected and the bundled
36
+ * presets are used; `sprag presets status` says so. Rotating the key means
37
+ * adding the new entry, releasing, then publishing with the new key; remove the
38
+ * old entry one release later.
39
+ */
40
+ export const PRESET_SIGNING_KEYS = [];
41
+
42
+ export const PRESET_BUNDLE_FORMAT = 1;
43
+ export const PRESET_MAX_BYTES = 200 * 1024;
44
+
45
+ /** JSON with object keys sorted at every depth and no whitespace. */
46
+ export function canonicalJson(value) {
47
+ if (value === null || typeof value !== 'object') {
48
+ if (typeof value === 'number' && !Number.isFinite(value)) return 'null';
49
+ return value === undefined ? 'null' : JSON.stringify(value);
50
+ }
51
+ if (Array.isArray(value)) {
52
+ return '[' + value.map((v) => (v === undefined || typeof v === 'function' ? 'null' : canonicalJson(v))).join(',') + ']';
53
+ }
54
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined && typeof value[k] !== 'function').sort();
55
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
56
+ }
57
+
58
+ /** The exact bytes that are signed. Fields outside the six are not covered. */
59
+ export function signingInput(bundle) {
60
+ const b = bundle || {};
61
+ return canonicalJson({
62
+ format: b.format ?? null,
63
+ version: b.version ?? null,
64
+ min_cli: b.min_cli ?? null,
65
+ ratchet_rules: b.ratchet_rules ?? null,
66
+ model_rules: b.model_rules ?? null,
67
+ agents: b.agents ?? null,
68
+ });
69
+ }
70
+
71
+ /** sha256 of the canonical public JWK (kty, crv, x, y only), first 16 hex. */
72
+ export function jwkFingerprint(jwk) {
73
+ const pub = { crv: jwk?.crv, kty: jwk?.kty, x: jwk?.x, y: jwk?.y };
74
+ return createHash('sha256').update(canonicalJson(pub)).digest('hex').slice(0, 16);
75
+ }
76
+
77
+ function b64urlDecode(s) {
78
+ if (typeof s !== 'string' || !/^[A-Za-z0-9_-]+={0,2}$/.test(s)) return null;
79
+ return Buffer.from(s.replace(/=+$/, '').replace(/-/g, '+').replace(/_/g, '/'), 'base64');
80
+ }
81
+
82
+ /**
83
+ * Check a bundle's signature. Returns { ok: true, kid } or
84
+ * { ok: false, reason } where reason is one of
85
+ * 'unsigned' the bundle carries no signature (version 1 is seeded unsigned)
86
+ * 'no-keys' this CLI has no signing key yet (PRESET_SIGNING_KEYS is empty)
87
+ * 'unknown-kid' signed by a key this CLI does not list
88
+ * 'bad-sig' the signature does not match the content
89
+ */
90
+ export function verifyBundleSignature(bundle, keys = PRESET_SIGNING_KEYS) {
91
+ const sig = bundle && bundle.sig;
92
+ if (!sig || typeof sig !== 'object' || !sig.value) return { ok: false, reason: 'unsigned' };
93
+ if (!Array.isArray(keys) || keys.length === 0) return { ok: false, reason: 'no-keys' };
94
+ if (sig.alg !== 'ES256') return { ok: false, reason: 'bad-sig' };
95
+ const entry = keys.find((k) => k && k.kid === sig.kid);
96
+ if (!entry) return { ok: false, reason: 'unknown-kid' };
97
+ const raw = b64urlDecode(sig.value);
98
+ if (!raw || raw.length !== 64) return { ok: false, reason: 'bad-sig' };
99
+ try {
100
+ const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: entry.jwk.x, y: entry.jwk.y }, format: 'jwk' });
101
+ const ok = cryptoVerify('sha256', Buffer.from(signingInput(bundle), 'utf8'), { key, dsaEncoding: 'ieee-p1363' }, raw);
102
+ return ok ? { ok: true, kid: entry.kid } : { ok: false, reason: 'bad-sig' };
103
+ } catch {
104
+ return { ok: false, reason: 'bad-sig' };
105
+ }
106
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * preset-sync — fetch the centrally published preset bundle.
3
+ *
4
+ * Read-only and anonymous: two RPC calls to the same Supabase project the team
5
+ * layer uses, with the public anon key and no member key, so the request is all
6
+ * that leaves the machine. `presets_version()` is polled first because it is a
7
+ * few bytes; `presets_published()` is fetched only when the version moved.
8
+ *
9
+ * Cadence: at most once every 24 hours, from a detached child spawned on the
10
+ * SessionStart path (the same pattern as the update check and the team sync),
11
+ * so a session never waits on the network. The attempt is stamped before the
12
+ * spawn, so an offline machine does not spawn a doomed child every session.
13
+ *
14
+ * A fetched bundle is stored only when this CLI would apply it (see
15
+ * presets-source.js evaluateBundle). A rejected one is recorded with its reason
16
+ * so `sprag presets status` can say why the bundled presets are still in use.
17
+ *
18
+ * Opt out with SPRAG_NO_PRESET_SYNC=1 or SPRAG_NO_UPDATE_CHECK=1.
19
+ */
20
+
21
+ import * as childProcess from 'node:child_process';
22
+ import { userDataDir } from './paths.js';
23
+ import { writeStateFile } from './state-file.js';
24
+ import { debug } from './debug.js';
25
+ import {
26
+ cliVersion, evaluateBundle, presetSyncDisabled, readRemoteCache, remoteCachePath, resetPresetSourceMemo,
27
+ } from './presets-source.js';
28
+ import { PRESET_SIGNING_KEYS } from './preset-sign.js';
29
+ import { CLI_ENTRY, detachedChildEnv, isDetachedChild } from './cli-entry.js';
30
+
31
+ export const PRESET_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
32
+
33
+ function writeCache(cache, dir) {
34
+ writeStateFile(remoteCachePath(dir), JSON.stringify(cache, null, 2) + '\n');
35
+ resetPresetSourceMemo();
36
+ }
37
+
38
+ /** Due when the last attempt (success or not) is 24 hours old, or there was none. */
39
+ export function presetSyncDue(cache, now = Date.now()) {
40
+ const at = Number(cache && cache.checkedAt);
41
+ return !Number.isFinite(at) || at <= 0 || now - at >= PRESET_SYNC_INTERVAL_MS || now < at;
42
+ }
43
+
44
+ export function nextPresetCheckAt(cache) {
45
+ const at = Number(cache && cache.checkedAt);
46
+ return Number.isFinite(at) && at > 0 ? at + PRESET_SYNC_INTERVAL_MS : null;
47
+ }
48
+
49
+ /**
50
+ * Called on session start. Never awaits the network. Returns true when a child
51
+ * was spawned.
52
+ */
53
+ export function spawnPresetSyncIfDue({
54
+ env = process.env, now = Date.now(), dir = userDataDir(),
55
+ spawn = childProcess.spawn, cliPath = CLI_ENTRY, execPath = process.execPath,
56
+ } = {}) {
57
+ if (isDetachedChild(env) || presetSyncDisabled(env)) return false;
58
+ const cache = readRemoteCache(dir);
59
+ if (!presetSyncDue(cache, now)) return false;
60
+ try {
61
+ writeCache({ ...cache, checkedAt: now }, dir);
62
+ } catch (e) {
63
+ debug('preset-sync:stamp', e);
64
+ return false;
65
+ }
66
+ try {
67
+ spawn(execPath, [cliPath, 'presets', 'sync', '--background'],
68
+ { detached: true, stdio: 'ignore', windowsHide: true, env: detachedChildEnv(env) }).unref();
69
+ return true;
70
+ } catch (e) {
71
+ debug('preset-sync:spawn', e);
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Fetch now. Returns { status, version?, reason? } where status is
78
+ * 'disabled' | 'none' (nothing published) | 'current' (cached version is the
79
+ * published one) | 'updated' | 'rejected' | 'error'
80
+ * `force` refetches the bundle even when the version did not move.
81
+ */
82
+ export async function syncPresets({
83
+ env = process.env, now = Date.now(), dir = userDataDir(), fetch, force = false,
84
+ version = cliVersion(), keys = PRESET_SIGNING_KEYS,
85
+ } = {}) {
86
+ if (presetSyncDisabled(env)) return { status: 'disabled' };
87
+ const { rpc, teamApiUrl, teamAnonKey } = await import('./team-sync.js');
88
+ const call = (fn) => rpc(fn, {}, { fetch, url: teamApiUrl(env), key: teamAnonKey(env) });
89
+ const cache = readRemoteCache(dir);
90
+ const base = { ...cache, checkedAt: now };
91
+ // The cached bundle counts only while it still verifies: after a key rotation
92
+ // the same version has to be fetched again rather than skipped as current.
93
+ const cachedOk = cache.bundle && evaluateBundle(cache.bundle, { version, keys }).ok;
94
+ const have = cachedOk ? cache.bundle.version : 0;
95
+ try {
96
+ const head = await call('presets_version');
97
+ if (!head || !Number.isInteger(head.version)) {
98
+ writeCache({ ...base, lastError: null }, dir);
99
+ return { status: 'none' };
100
+ }
101
+ if (!force && head.version === have) {
102
+ writeCache({ ...base, lastError: null }, dir);
103
+ return { status: 'current', version: have };
104
+ }
105
+ if (!force && cache.rejected && cache.rejected.version === head.version && cache.rejected.cli === version
106
+ && cache.rejected.keys === keys.length) {
107
+ writeCache({ ...base, lastError: null }, dir);
108
+ return { status: 'rejected', version: head.version, reason: cache.rejected.reason };
109
+ }
110
+ const bundle = await call('presets_published');
111
+ if (!bundle) {
112
+ writeCache({ ...base, lastError: null }, dir);
113
+ return { status: 'none' };
114
+ }
115
+ if (Number.isInteger(bundle.version) && bundle.version < have) {
116
+ const rejected = { version: bundle.version, reason: 'older', at: now, cli: version, keys: keys.length };
117
+ writeCache({ ...base, rejected, lastError: null }, dir);
118
+ return { status: 'rejected', version: bundle.version, reason: 'older' };
119
+ }
120
+ const ev = evaluateBundle(bundle, { version, keys });
121
+ if (!ev.ok) {
122
+ const rejected = { version: Number.isInteger(bundle.version) ? bundle.version : null, reason: ev.reason, at: now, cli: version, keys: keys.length };
123
+ writeCache({ ...base, rejected, lastError: null }, dir);
124
+ return { status: 'rejected', version: rejected.version, reason: ev.reason };
125
+ }
126
+ writeCache({ checkedAt: now, fetchedAt: now, version: bundle.version, bundle, rejected: null, lastError: null }, dir);
127
+ return { status: 'updated', version: bundle.version };
128
+ } catch (e) {
129
+ debug('preset-sync', e);
130
+ writeCache({ ...base, lastError: { message: String(e && e.message || e).slice(0, 200), at: now } }, dir);
131
+ return { status: 'error', reason: String(e && e.message || e) };
132
+ }
133
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * presets-source — which presets this machine uses: the centrally published
3
+ * bundle or the copy shipped in the npm package.
4
+ *
5
+ * The superuser publishes ratchet presets, model-routing presets and the
6
+ * delegation agent definitions from the /ops console as one signed bundle.
7
+ * preset-sync.js fetches it (read-only, at most daily, in a detached child) into
8
+ * <userDataDir>/presets-remote.json. Everything that reads presets comes through
9
+ * here, so a newer bundle takes effect at the next session start after a fetch.
10
+ *
11
+ * The remote bundle wins only while it is valid, checked again on every read:
12
+ * its signature verifies against a key in PRESET_SIGNING_KEYS, its format is
13
+ * one this CLI understands, its min_cli is not newer than this CLI, its shape is
14
+ * right and it is at most 200 KB. Anything else falls back to the bundled
15
+ * presets in presets/*.json, which have the same shape. So a blocked network, a
16
+ * server outage or a tampered response leaves the machine exactly where a fresh
17
+ * install of this version would be.
18
+ *
19
+ * SPRAG_NO_PRESET_SYNC=1 (or SPRAG_NO_UPDATE_CHECK=1) turns the remote source
20
+ * off entirely: no fetch, and a cached bundle is ignored.
21
+ */
22
+
23
+ import { readFileSync, statSync } from 'node:fs';
24
+ import { join, dirname } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { createHash } from 'node:crypto';
27
+ import { createRequire } from 'node:module';
28
+ import { envVar } from './env.js';
29
+ import { userDataDir } from './paths.js';
30
+ import {
31
+ PRESET_BUNDLE_FORMAT, PRESET_MAX_BYTES, PRESET_SIGNING_KEYS, verifyBundleSignature,
32
+ } from './preset-sign.js';
33
+
34
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
35
+ const require = createRequire(import.meta.url);
36
+
37
+ export function cliVersion() {
38
+ try { return require('../package.json').version; } catch { return '0.0.0'; }
39
+ }
40
+
41
+ export function presetSyncDisabled(env = process.env) {
42
+ return envVar('NO_PRESET_SYNC', env) === '1' || envVar('NO_UPDATE_CHECK', env) === '1';
43
+ }
44
+
45
+ export function remoteCachePath(dir = userDataDir()) { return join(dir, 'presets-remote.json'); }
46
+
47
+ /** -1, 0 or 1. Pre-release suffixes are ignored; unparsable versions compare as 0.0.0. */
48
+ export function compareVersions(a, b) {
49
+ const p = (v) => (String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/) || [0, 0, 0, 0]).slice(1, 4).map(Number);
50
+ const x = p(a), y = p(b);
51
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1;
52
+ return 0;
53
+ }
54
+
55
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
56
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
57
+ const MODEL_RE = /^[A-Za-z0-9._-]{1,80}$/;
58
+ const LINE_RE = /^[^\r\n]{1,600}$/;
59
+ const TOOL_RE = /^[A-Za-z0-9_*().: -]{1,80}$/;
60
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
61
+ const TIERS = ['T1', 'T2', 'T1H'];
62
+ const str = (v) => typeof v === 'string' && v.trim() !== '';
63
+
64
+ function uniq(list, key) {
65
+ const seen = new Set();
66
+ for (const x of list) { if (seen.has(x[key])) return false; seen.add(x[key]); }
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * Shape check for a bundle. Returns null when valid, else a short reason code.
72
+ * Rejected here rather than when rendering: an agent name with a slash would be
73
+ * a path outside ~/.claude/agents, and a newline in a frontmatter field would
74
+ * let the description write its own `tools:` line.
75
+ */
76
+ export function bundleShapeError(b) {
77
+ if (!b || typeof b !== 'object' || Array.isArray(b)) return 'shape';
78
+ let bytes;
79
+ try { bytes = Buffer.byteLength(JSON.stringify(b), 'utf8'); } catch { return 'shape'; }
80
+ if (bytes > PRESET_MAX_BYTES) return 'too-large';
81
+ if (!Number.isInteger(b.format) || b.format < 1) return 'shape';
82
+ if (b.format > PRESET_BUNDLE_FORMAT) return 'format';
83
+ if (!Number.isInteger(b.version) || b.version < 1) return 'shape';
84
+ if (b.min_cli != null && typeof b.min_cli !== 'string') return 'shape';
85
+ const { ratchet_rules: rr, model_rules: mr, agents: ag } = b;
86
+ if (!Array.isArray(rr) || !Array.isArray(mr) || !Array.isArray(ag)) return 'shape';
87
+ for (const r of rr) {
88
+ if (!r || !ID_RE.test(r.id) || !str(r.ko) || !str(r.en)) return 'shape';
89
+ if (r.agents != null && (!Array.isArray(r.agents) || r.agents.some((a) => a !== 'claude' && a !== 'codex'))) return 'shape';
90
+ }
91
+ for (const m of mr) {
92
+ if (!m || !ID_RE.test(m.id) || !TIERS.includes(m.tier) || !ID_RE.test(m.category)) return 'shape';
93
+ if (!str(m.label) || !str(m.labelEn) || !str(m.agent) || !LINE_RE.test(m.agent)) return 'shape';
94
+ if (m.budget != null && (typeof m.budget !== 'object' || Array.isArray(m.budget))) return 'shape';
95
+ }
96
+ for (const a of ag) {
97
+ if (!a || !NAME_RE.test(a.name) || !MODEL_RE.test(a.model) || !EFFORTS.includes(a.effort)) return 'shape';
98
+ if (!Array.isArray(a.tools) || a.tools.length === 0 || a.tools.some((t) => typeof t !== 'string' || !TOOL_RE.test(t))) return 'shape';
99
+ if (!a.description || !LINE_RE.test(a.description.en || '') || (a.description.ko != null && !LINE_RE.test(a.description.ko))) return 'shape';
100
+ if (!a.body || !str(a.body.en)) return 'shape';
101
+ }
102
+ if (!uniq(rr, 'id') || !uniq(mr, 'id') || !uniq(ag, 'name')) return 'shape';
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Whether this CLI may apply a bundle. { ok, reason?, kid? } with reason one of
108
+ * shape, too-large, format, min-cli, no-keys, unsigned, unknown-kid, bad-sig.
109
+ * No keys is checked before the signature: it is the more basic answer.
110
+ */
111
+ export function evaluateBundle(b, { version = cliVersion(), keys = PRESET_SIGNING_KEYS } = {}) {
112
+ const shape = bundleShapeError(b);
113
+ if (shape) return { ok: false, reason: shape };
114
+ if (!Array.isArray(keys) || keys.length === 0) return { ok: false, reason: 'no-keys' };
115
+ if (b.min_cli && compareVersions(b.min_cli, version) > 0) return { ok: false, reason: 'min-cli' };
116
+ const sig = verifyBundleSignature(b, keys);
117
+ return sig.ok ? { ok: true, kid: sig.kid } : { ok: false, reason: sig.reason };
118
+ }
119
+
120
+ export function readRemoteCache(dir = userDataDir()) {
121
+ try {
122
+ const c = JSON.parse(readFileSync(remoteCachePath(dir), 'utf8'));
123
+ return c && typeof c === 'object' && !Array.isArray(c) ? c : {};
124
+ } catch {
125
+ return {};
126
+ }
127
+ }
128
+
129
+ // One evaluation per cache-file revision: the session-start path reads the
130
+ // presets several times (seed offer, preset file, agents, route-scan).
131
+ let memo = null;
132
+ export function resetPresetSourceMemo() { memo = null; }
133
+
134
+ function activeRemote({ env = process.env, dir = userDataDir(), keys = PRESET_SIGNING_KEYS, version = cliVersion() } = {}) {
135
+ if (presetSyncDisabled(env)) return { bundle: null, reason: 'disabled', cache: {} };
136
+ const file = remoteCachePath(dir);
137
+ let sig;
138
+ try { const s = statSync(file); sig = `${file}|${s.mtimeMs}|${s.size}|${keys.length}|${version}`; } catch { return { bundle: null, reason: 'no-cache', cache: {} }; }
139
+ if (memo && memo.sig === sig && memo.keys === keys) return memo.value;
140
+ const cache = readRemoteCache(dir);
141
+ let value;
142
+ if (!cache.bundle) value = { bundle: null, reason: 'no-cache', cache };
143
+ else {
144
+ const ev = evaluateBundle(cache.bundle, { version, keys });
145
+ value = ev.ok ? { bundle: cache.bundle, kid: ev.kid, cache } : { bundle: null, reason: ev.reason, cache };
146
+ }
147
+ memo = { sig, keys, value };
148
+ return value;
149
+ }
150
+
151
+ /** { kind: 'remote'|'bundled', version, fetchedAt, kid?, reason? } */
152
+ export function presetSource(opts = {}) {
153
+ const r = activeRemote(opts);
154
+ if (r.bundle) return { kind: 'remote', version: r.bundle.version, fetchedAt: r.cache.fetchedAt || null, kid: r.kid };
155
+ return { kind: 'bundled', version: null, fetchedAt: r.cache.fetchedAt || null, reason: r.reason };
156
+ }
157
+
158
+ function readBundled(name) {
159
+ try { return JSON.parse(readFileSync(join(packageRoot, 'presets', name), 'utf8')); } catch { return null; }
160
+ }
161
+
162
+ const derivedId = (ko) => `fix-${createHash('sha1').update(ko).digest('hex').slice(0, 6)}`;
163
+
164
+ function normaliseRatchet(list) {
165
+ return (Array.isArray(list) ? list : [])
166
+ .filter((r) => r && typeof r.ko === 'string' && r.ko.trim())
167
+ .map((r) => ({
168
+ // Explicit ids are stable across rewording. A rule without one (an older
169
+ // file) gets the id the Korean text always produced.
170
+ id: typeof r.id === 'string' && r.id ? r.id : derivedId(r.ko.trim()),
171
+ ko: r.ko.trim(), en: (r.en || r.ko).trim(),
172
+ ...(Array.isArray(r.agents) ? { agents: r.agents.filter((a) => typeof a === 'string') } : {}),
173
+ }));
174
+ }
175
+
176
+ /** Ratchet presets: [{ id, ko, en, agents? }] */
177
+ export function ratchetPresetEntries(opts = {}) {
178
+ const r = activeRemote(opts);
179
+ if (r.bundle) return normaliseRatchet(r.bundle.ratchet_rules);
180
+ return normaliseRatchet(readBundled('ratchet-rules.json')?.rules);
181
+ }
182
+
183
+ /** Model-routing presets, the objects of presets/model-rules.json `presets`. */
184
+ export function modelPresets(opts = {}) {
185
+ const r = activeRemote(opts);
186
+ if (r.bundle) return r.bundle.model_rules.map((p) => ({ ...p }));
187
+ const data = readBundled('model-rules.json');
188
+ return Array.isArray(data?.presets) ? data.presets : [];
189
+ }
190
+
191
+ /** Delegation agents: [{ name, model, effort, tools[], description{ko,en}, body{ko,en} }] */
192
+ export function agentDefinitions(opts = {}) {
193
+ const r = activeRemote(opts);
194
+ if (r.bundle) return r.bundle.agents.map((a) => ({ ...a }));
195
+ const data = readBundled('agents.json');
196
+ return Array.isArray(data?.agents) ? data.agents : [];
197
+ }
198
+
199
+ /** The bundle the bundled files make: the shape the server seeds as version 1. */
200
+ export function bundledBundle({ version = 1, minCli = null } = {}) {
201
+ return {
202
+ format: PRESET_BUNDLE_FORMAT,
203
+ version,
204
+ min_cli: minCli,
205
+ ratchet_rules: normaliseRatchet(readBundled('ratchet-rules.json')?.rules),
206
+ model_rules: readBundled('model-rules.json')?.presets || [],
207
+ agents: readBundled('agents.json')?.agents || [],
208
+ };
209
+ }
package/src/prompt.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * decide-it-for-them behavior untouched.
10
10
  */
11
11
 
12
+ import { envVar } from './env.js';
12
13
  import { createInterface } from 'node:readline';
13
14
 
14
15
  /**
@@ -19,7 +20,7 @@ import { createInterface } from 'node:readline';
19
20
  * checked because build agents deadlock rather than answer.
20
21
  */
21
22
  export function canPrompt({ env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
22
- if (env.CTS_NO_INPUT === '1') return false;
23
+ if (envVar('NO_INPUT', env) === '1') return false;
23
24
  if (env.CI && env.CI !== 'false') return false;
24
25
  if (env.npm_lifecycle_event === 'postinstall') return false;
25
26
  return Boolean(stdin.isTTY && stdout.isTTY);
package/src/route-scan.js CHANGED
@@ -30,6 +30,7 @@ import { sessionCost } from './claude-price.js';
30
30
  import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
31
31
  import { modelRuleBaseText } from './model-rules.js';
32
32
  import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
33
+ import { modelPresets as sourceModelPresets } from './presets-source.js';
33
34
 
34
35
  // ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
35
36
  // T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
@@ -162,6 +163,18 @@ const CATEGORIES = [
162
163
  kw: [[/\bgit |\bcommit(?:s|ted)?\b|\bpush(?:ed|es)?\b|\bnpm |\bpip3? |빌드해|빌드 돌/i, 2], [/실행|돌려|\brun\b|\bbuild\b|빌드|테스트|설치/i, 1]],
163
164
  },
164
165
  ];
166
+ // The T2 agent a category's candidates name follows the published model presets
167
+ // when one covers that category (presets-source.js), so renaming an agent in the
168
+ // /ops console renames it in new candidates too. Categories without a preset
169
+ // (translate) keep the name above.
170
+ function t2AgentFor(cat) {
171
+ try {
172
+ const p = sourceModelPresets().find((x) => x.tier === 'T2' && x.category === cat.id);
173
+ if (p && typeof p.agent === 'string' && p.agent) return p.agent;
174
+ } catch { /* the built-in name below */ }
175
+ return cat.agent;
176
+ }
177
+
165
178
  // English keywords are matched as whole words. As bare substrings, `pip`
166
179
  // claimed "pipeline", `commit` "commitment", `find` "findings" and `status`
167
180
  // "statusline", which filed questions about those under run, explore or check.
@@ -635,7 +648,7 @@ export async function runRouteScan({ days = 14 } = {}) {
635
648
  category: cat.id,
636
649
  label: cat.label,
637
650
  labelEn: cat.labelEn,
638
- agent: tier === 'T2' ? cat.agent : tier === 'T1H' ? 'opus' : 'sonnet',
651
+ agent: tier === 'T2' ? t2AgentFor(cat) : tier === 'T1H' ? 'opus' : 'sonnet',
639
652
  project: projectDir,
640
653
  projectPath: '',
641
654
  count: 0,