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
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);
@@ -52,6 +52,16 @@ export function presetRatchetImportLine() {
52
52
  }
53
53
  export const PRESET_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-preset\.md\s*$/m;
54
54
 
55
+ /**
56
+ * The team ratchet: rules the user's team approved, cached by `sprag team sync`
57
+ * and rendered into a tool-owned file. Unlike the others its import is not in
58
+ * the rendered block; it is added only for members, when the file exists.
59
+ */
60
+ export function teamRatchetImportLine() {
61
+ return '@~/.claude/ratchet-team.md';
62
+ }
63
+ export const TEAM_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-team\.md\s*$/m;
64
+
55
65
  /**
56
66
  * Render ratchet-preset.md. `rules` is [{ id, text }] in display order. The
57
67
  * file is loaded into every session, so the header stays short.
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
 
@@ -72,7 +72,8 @@ export function wilsonLowerBound(errs, n) {
72
72
  // route-scan's pre-calibration defaults. Kept as local constants rather than
73
73
  // imported: route-scan imports this module, and a static back-import would
74
74
  // close the cycle.
75
- const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 } };
75
+ const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 },
76
+ T1H: { calls: null, out: 30000 } };
76
77
 
77
78
  /** The budget a rule is held to: its calibrated one, or the default of its tier. */
78
79
  export function ruleBudget(rule) {
@@ -132,20 +133,27 @@ function shortExample(text) {
132
133
  return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
133
134
  }
134
135
 
135
- // T1 is named through its delegation agent only when that file exists; without
136
- // it the literal `model: sonnet` stays so output on such machines is unchanged.
137
- function t1Target(root, ko) {
138
- if (resolveAgentName('sonnet', root) === null) return null;
139
- return (ko ? agentPhrase : agentPhraseEn)('sonnet', { root });
136
+ // T1 (and T1H) is named through its delegation agent only when that file
137
+ // exists; without it the literal `model: sonnet` / `model: opus` stays so
138
+ // output on such machines is unchanged.
139
+ function t1Target(root, ko, tier = 'sonnet') {
140
+ if (resolveAgentName(tier, root) === null) return null;
141
+ return (ko ? agentPhrase : agentPhraseEn)(tier, { root });
140
142
  }
141
143
 
142
144
  export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
143
145
  if (lang === 'ko') {
146
+ if (g.tier === 'T1H') {
147
+ return `"${g.label}" 고난도 구현 요청(예: "${shortExample(g.example)}")은 Fable·Mythos 세션에 한해 ${t1Target(root, true, 'opus') ?? 'model: opus'}에 위임`;
148
+ }
144
149
  return g.tier === 'T2'
145
150
  ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
146
151
  : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
147
152
  }
148
153
  const example = g.exampleEn || g.example;
154
+ if (g.tier === 'T1H') {
155
+ return `Heavy "${g.labelEn}" implementation requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false, 'opus') ?? 'a model: opus subagent'}, in Fable/Mythos sessions only`;
156
+ }
149
157
  return g.tier === 'T2'
150
158
  ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
151
159
  : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
@@ -275,8 +283,12 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
275
283
  '- **effort**: 모델 티어와 다른 축이다. 설치된 서브에이전트는 검증 형태에 따라',
276
284
  ' effort가 고정되어 있다. 명령 출력이나 파일 대조로 검증되는 위임(조회·명령',
277
285
  ' 실행·정형 변환을 맡는 haiku 에이전트)은 low, 확정된 설계를 테스트에 맞춰',
278
- ' 구현하는 위임(sonnet 워커)은 medium이다. 메인 세션의 effort는 바꾸지 않고,',
286
+ ' 구현하는 위임(sonnet 워커)은 medium, 범위가 큰 구현·디버깅 위임(opus 워커)은',
287
+ ' high다. 메인 세션의 effort는 바꾸지 않고,',
279
288
  ' CLAUDE_CODE_EFFORT_LEVEL은 서브에이전트의 effort를 덮어쓰므로 설정하지 않는다.',
289
+ '- **고난도 구현(T1H)**: 설계는 메인이 확정하고, 범위가 크거나 sonnet 이 틀린',
290
+ ' 적 있는 구현·디버깅은 opus 에 위임한다. Fable·Mythos 세션에서만 적용되며,',
291
+ ' 설계 판단과 되돌릴 수 없는 작업은 메인 모델에 남긴다.',
280
292
  '- **위임 배치**: 서브에이전트 비용의 지배 항은 생성이 아니라 컨텍스트',
281
293
  ' 적재다. 같은 파일들을 읽어야 하는 작업 여러 개는 에이전트 하나에 묶어',
282
294
  ' 보내고, 읽는 영역이 다른 작업만 나눈다.',
@@ -337,8 +349,13 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
337
349
  ' by how their work is verified: low where the check is command output or a',
338
350
  ' file comparison (the haiku agents for lookups, command runs and mechanical',
339
351
  ' conversion), medium where a fixed design is implemented against tests (the',
340
- ' sonnet worker). Leave the main session\'s effort alone, and never set',
352
+ ' sonnet worker), high for large implementation or debugging (the opus',
353
+ ' worker). Leave the main session\'s effort alone, and never set',
341
354
  ' CLAUDE_CODE_EFFORT_LEVEL: it overrides every subagent\'s own value.',
355
+ '- **Heavy implementation (T1H)**: the main model fixes the design; large-scope',
356
+ ' implementation or debugging, or work sonnet has already got wrong, goes to',
357
+ ' opus. Applies in Fable/Mythos sessions only; design judgement and',
358
+ ' irreversible work stay on the main model.',
342
359
  '- **Batching**: a subagent\'s dominant cost is context ingestion, not',
343
360
  ' generation. Bundle tasks that read the same files into one agent; split',
344
361
  ' only when the areas they read differ.',
@@ -384,13 +401,13 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
384
401
  const saved = r.savedUsd ? `, saved ~${signedUsd(r.savedUsd)}` : '';
385
402
  return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
386
403
  };
387
- // Merged T2+T1 rules carry two caps, so they state both once rather than
404
+ // Merged rules carry one cap per tier, so they state them once rather than
388
405
  // repeating the whole stop-condition per tier.
389
- const mergedBudget = (t2, t1) => ko
390
- ? `상한 haiku ${budgetCapPhrase(t2, 'ko')} / sonnet ${budgetCapPhrase(t1, 'ko')}`
391
- : `cap haiku ${budgetCapPhrase(t2, 'en')} / sonnet ${budgetCapPhrase(t1, 'en')}`;
406
+ const TIER_MODEL = { T2: 'haiku', T1: 'sonnet', T1H: 'opus' };
407
+ const mergedBudget = (tiers) => (ko ? '상한 ' : 'cap ') + tiers
408
+ .map((r) => `${TIER_MODEL[r.tier]} ${budgetCapPhrase(r, ko ? 'ko' : 'en')}`).join(' / ');
392
409
 
393
- // A category can carry both a T2 (haiku) and a T1 (sonnet) rule. Tier is
410
+ // A category can carry a T2 (haiku), a T1 (sonnet) and a T1H (opus) rule. Tier is
394
411
  // only known after an episode finishes, so two separate bullets give the
395
412
  // reading LLM no way to pick one at request time — merge such pairs into a
396
413
  // single conditional rule with explicit request-time criteria (default to
@@ -405,38 +422,50 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
405
422
  // text stored on the rule is whatever wording and language were current when
406
423
  // it was promoted, which is how an English line sat in a Korean file. A rule
407
424
  // missing what the template needs still falls back to its stored text.
408
- const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1') && r.label && (ko || r.labelEn)
425
+ const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1' || r.tier === 'T1H') && r.label && (ko || r.labelEn)
409
426
  ? modelRuleBaseText(r, lang, { root }) : r.rule);
427
+ const DELEGATION_TIERS = ['T2', 'T1', 'T1H'];
410
428
  for (const group of byCategory.values()) {
411
- const t2 = group.find((r) => r.tier === 'T2');
412
- const t1 = group.find((r) => r.tier === 'T1');
429
+ const picked = DELEGATION_TIERS.map((t) => group.find((r) => r.tier === t)).filter(Boolean);
413
430
  // A second rule of a tier the line already covers (two global rules of one
414
431
  // category, promoted from different projects) says nothing the line does
415
432
  // not, so it adds no line of its own. Its review flag and its numbers stay
416
433
  // visible on the line that speaks for it.
417
- const extras = t2 || t1 ? group.filter((r) => r !== t2 && r !== t1 && (r.tier === 'T2' || r.tier === 'T1')) : [];
418
- const unknown = group.filter((r) => r !== t2 && r !== t1 && !extras.includes(r));
434
+ const extras = picked.length ? group.filter((r) => !picked.includes(r) && DELEGATION_TIERS.includes(r.tier)) : [];
435
+ const unknown = group.filter((r) => !picked.includes(r) && !extras.includes(r));
419
436
  const extraHealth = extras.map(healthOf).join('');
420
437
  const extraStats = extras.map((r) => ` / +${r.tier} ${statsOf(r)}`).join('');
421
- if (t2 && t1) {
438
+ if (picked.length >= 2) {
422
439
  // Seeded rules carry a localized example; scan-produced ones only have the
423
440
  // user's own prompt, so exampleEn is a preference, not a requirement.
424
441
  const ex = (r) => shortExample(ko ? r.example : (r.exampleEn || r.example));
425
- // One line per category, stating only what differs between the two tiers.
442
+ const target = (r) => {
443
+ if (r.tier === 'T2') return (ko ? agentPhrase : agentPhraseEn)(r.agent, { root });
444
+ const name = r.tier === 'T1H' ? 'opus' : 'sonnet';
445
+ return t1Target(root, ko, name) ?? `model: ${name}`;
446
+ };
447
+ // One line per category, stating only what differs between the tiers.
426
448
  // The exceptions this used to spell out (design judgement, irreversible
427
449
  // work) are identical in every rule and now sit in the header's shared
428
450
  // clauses, so a rule the model re-reads on every request is short enough to
429
451
  // match at a glance.
452
+ const when = (r, first) => {
453
+ if (first) return ko ? '기본' : 'by default';
454
+ if (r.tier === 'T1H') return ko ? '범위가 크면(Fable·Mythos 세션 한정)' : 'for large scope (Fable/Mythos sessions only)';
455
+ return ko ? '여러 단계·여러 파일이 얽히면' : 'when it spans multiple steps or file edits';
456
+ };
457
+ const parts = picked.map((r, i) => (ko
458
+ ? `${when(r, i === 0)} ${target(r)}(예: "${ex(r)}")`
459
+ : `${i === 0 ? `${target(r)} ${when(r, true)}` : `${target(r)} ${when(r, false)}`} (e.g. "${ex(r)}")`));
460
+ const head = picked[0];
430
461
  const rule = ko
431
- ? `"${t2.label}": 기본 ${agentPhrase(t2.agent, { root })}(예: "${ex(t2)}"), ` +
432
- `여러 단계·여러 파일이 얽히면 ${t1Target(root, true) ?? 'model: sonnet'}(예: "${ex(t1)}"). ` +
433
- mergedBudget(t2, t1)
434
- : `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent, { root })} by default (e.g. "${ex(t2)}"), ` +
435
- `${t1Target(root, false) ?? 'model: sonnet'} when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
436
- mergedBudget(t2, t1);
437
- lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)}${extraHealth} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)}${extraStats} -->`);
438
- } else if (t2 || t1) {
439
- const r = t2 || t1;
462
+ ? `"${head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`
463
+ : `"${head.labelEn || head.label}": ${parts.join(', ')}. ${mergedBudget(picked)}`;
464
+ const health = picked.map(healthOf).join('');
465
+ const stats = picked.map((r) => `${r.tier} ${statsOf(r)}`).join(' / ');
466
+ lines.push(`- ${rule}${health}${extraHealth} <!-- ${stats}${extraStats} -->`);
467
+ } else if (picked.length === 1) {
468
+ const r = picked[0];
440
469
  lines.push(`- ${composeRuleText(baseTextOf(r), r, lang)}${healthOf(r)}${extraHealth} <!-- ${statsOf(r)}${extraStats} -->`);
441
470
  }
442
471
  for (const r of unknown) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
@@ -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
+ }