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.
- package/README.ko.md +7 -1
- package/README.md +7 -1
- package/bin/cli.js +47 -5
- package/docs/CODEX.ko.md +2 -2
- package/docs/CODEX.md +4 -4
- package/docs/TEAM.ko.md +74 -19
- package/docs/TEAM.md +75 -19
- package/package.json +2 -1
- package/presets/agents.json +118 -0
- package/presets/doc2md/convert.py +1 -1
- package/presets/ratchet-rules.json +17 -2
- package/src/cli-entry.js +40 -0
- package/src/codex/hooks.js +11 -10
- package/src/codex/panel-setup.js +2 -1
- package/src/codex/panel.js +2 -1
- package/src/commands/brief.js +2 -0
- package/src/commands/codex.js +3 -2
- package/src/commands/harness.js +2 -3
- 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 +9 -7
- package/src/commands/team.js +82 -26
- package/src/commands/update-check.js +2 -2
- package/src/debug.js +3 -2
- package/src/delegation-agents.js +48 -60
- 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.js +4 -14
- package/src/hook.cjs +4 -3
- 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-scan.js +14 -1
- package/src/seed-rules.js +14 -16
- 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 +116 -16
- package/src/team-sync.js +336 -37
- 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 (
|
|
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
|
-
&&
|
|
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 (
|
|
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
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
|
+
}
|
package/src/first-run-note.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
16
|
+
// but be visible under SPRAG_DEBUG=1, otherwise a broken path is
|
|
17
17
|
// indistinguishable from "nothing to do".
|
|
18
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
651
|
+
agent: tier === 'T2' ? t2AgentFor(cat) : tier === 'T1H' ? 'opus' : 'sonnet',
|
|
639
652
|
project: projectDir,
|
|
640
653
|
projectPath: '',
|
|
641
654
|
count: 0,
|