sprag-cli 3.53.0 → 3.55.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 (44) hide show
  1. package/README.ko.md +54 -3
  2. package/README.md +63 -4
  3. package/bin/cli.js +30 -7
  4. package/docs/CODEX.ko.md +184 -0
  5. package/docs/CODEX.md +497 -0
  6. package/package.json +15 -4
  7. package/presets/ratchet-rules.json +4 -1
  8. package/src/agent.js +33 -0
  9. package/src/codex-brief.js +89 -0
  10. package/src/codex-budget.js +73 -0
  11. package/src/codex-cache-policy.js +203 -0
  12. package/src/codex-cache.js +46 -0
  13. package/src/codex-delegation.js +154 -0
  14. package/src/codex-doc2md-ledger.js +45 -0
  15. package/src/codex-doc2md.js +99 -0
  16. package/src/codex-doctor.js +53 -0
  17. package/src/codex-harness.js +200 -0
  18. package/src/codex-hooks.js +138 -0
  19. package/src/codex-installer.js +79 -0
  20. package/src/codex-ledger.js +143 -0
  21. package/src/codex-panel-launcher.js +48 -0
  22. package/src/codex-panel-runner.js +74 -0
  23. package/src/codex-panel-session.js +35 -0
  24. package/src/codex-panel-setup.js +98 -0
  25. package/src/codex-panel-shell.js +51 -0
  26. package/src/codex-panel-state.js +40 -0
  27. package/src/codex-panel.js +448 -0
  28. package/src/codex-parser.js +397 -0
  29. package/src/codex-route-scan.js +171 -0
  30. package/src/commands/codex-delegate.js +59 -0
  31. package/src/commands/codex.js +579 -0
  32. package/src/commands/harness.js +13 -2
  33. package/src/commands/install.js +106 -10
  34. package/src/commands/route-scan.js +4 -0
  35. package/src/commands/seed.js +16 -12
  36. package/src/commands/uninstall.js +23 -0
  37. package/src/commands/upgrade.js +13 -0
  38. package/src/doc2md.cjs +72 -28
  39. package/src/handoff.js +27 -7
  40. package/src/harness-templates.js +33 -1
  41. package/src/harness.js +51 -17
  42. package/src/preset-ratchet.js +133 -0
  43. package/src/seed-rules.js +88 -39
  44. package/src/update-check.js +37 -2
package/src/harness.js CHANGED
@@ -21,11 +21,13 @@ import {
21
21
  appendRatchetRule,
22
22
  RATCHET_IMPORT_RE,
23
23
  MODEL_RATCHET_IMPORT_RE,
24
+ renderPresetRatchet,
24
25
  } from './harness-templates.js';
25
26
  import { routeWarningForStatusline } from './route-scan.js';
26
27
  import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet } from './model-rules.js';
27
28
  import { compactWindowWarningForStatusline } from './compact-window.js';
28
29
  import { userLanguage } from './config.js';
30
+ import { codexUserDir } from './agent.js';
29
31
 
30
32
  const require = createRequire(import.meta.url);
31
33
  function readHarnessState() {
@@ -42,11 +44,13 @@ function readHarnessState() {
42
44
  * or package.json). Falls back to `start` itself so harness commands always
43
45
  * have *some* directory to write into, even outside a repo.
44
46
  */
45
- export function findProjectRoot(start = process.cwd()) {
47
+ export function findProjectRoot(start = process.cwd(), { agent = 'claude' } = {}) {
46
48
  let dir = resolve(start);
47
49
  for (;;) {
48
50
  if (
49
- existsSync(join(dir, 'CLAUDE.md')) ||
51
+ (agent === 'codex'
52
+ ? existsSync(join(dir, 'AGENTS.md')) || existsSync(join(dir, 'AGENTS.override.md'))
53
+ : existsSync(join(dir, 'CLAUDE.md'))) ||
50
54
  existsSync(join(dir, '.git')) ||
51
55
  existsSync(join(dir, 'package.json'))
52
56
  ) {
@@ -70,10 +74,15 @@ function globalRatchetMdPath() {
70
74
  return join(homedir(), '.claude', 'ratchet.md');
71
75
  }
72
76
 
73
- function resolveRatchetPath(scope, root) {
77
+ function resolveRatchetPath(scope, root, agent = 'claude') {
78
+ if (agent === 'codex') return scope === 'global' ? join(codexUserDir(), 'ratchet.md') : join(root, '.codex', 'ratchet.md');
74
79
  return scope === 'global' ? globalRatchetMdPath() : ratchetMdPath(root);
75
80
  }
76
81
 
82
+ function initialRatchet(agent) {
83
+ return agent === 'codex' ? '# Codex Ratchet Rules\n\n## Rules\n\n' : harnessRatchetMdInitial();
84
+ }
85
+
77
86
  // Global harness lives in ~/.claude/CLAUDE.md — Claude Code loads this for every
78
87
  // project, so a global init makes the 5 harness sections apply everywhere
79
88
  // (mirrors the project/global split that ratchet.md already has).
@@ -257,6 +266,18 @@ export function harnessInit({ root = findProjectRoot(), force = false, scope = '
257
266
  result.skipped.push(mrPath + ' (already exists)');
258
267
  }
259
268
 
269
+ // ratchet-preset.md — tool-owned and imported by the global block only, so
270
+ // it is seeded here for the same reason as ratchet-model.md above.
271
+ if (scope === 'global') {
272
+ const prPath = join(homedir(), '.claude', 'ratchet-preset.md');
273
+ if (!existsSync(prPath)) {
274
+ try {
275
+ writeFileSync(prPath, renderPresetRatchet([], { lang: userLanguage() }));
276
+ result.wrote.push(prPath);
277
+ } catch { /* unwritable — the next session start renders it */ }
278
+ }
279
+ }
280
+
260
281
  return result;
261
282
  }
262
283
 
@@ -312,14 +333,14 @@ export function harnessUninit({ root = findProjectRoot(), purgeRatchet = false,
312
333
  * harness promote — append a one-line rule to .claude/ratchet.md.
313
334
  * Creates the file from the initial template if missing.
314
335
  */
315
- export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'project' } = {}) {
316
- const rmPath = resolveRatchetPath(scope, root);
336
+ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'project', agent = 'claude' } = {}) {
337
+ const rmPath = resolveRatchetPath(scope, root, agent);
317
338
  let existing = '';
318
339
  if (existsSync(rmPath)) {
319
340
  existing = readFileSync(rmPath, 'utf8');
320
341
  } else {
321
342
  mkdirSync(dirname(rmPath), { recursive: true });
322
- existing = harnessRatchetMdInitial();
343
+ existing = initialRatchet(agent);
323
344
  }
324
345
  const next = appendRatchetRule(existing, ruleText);
325
346
  writeFileSync(rmPath, next);
@@ -339,17 +360,29 @@ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'pr
339
360
  * Deduped by rule text (ignoring the YYYY-MM-DD stamp) — idempotent.
340
361
  * Returns { path, scope, added, skippedRules, presets }.
341
362
  */
342
- export function harnessPull({ root = findProjectRoot(), scope = 'global' } = {}) {
363
+ export async function harnessPull({ root = findProjectRoot(), scope = 'global', agent = 'claude' } = {}) {
364
+ // Global presets live in ratchet-preset.md now, rendered from recorded
365
+ // answers, so pulling them globally is accepting every one of them.
366
+ if (scope === 'global') {
367
+ const { acceptedGlobalPresets, presetRatchetPath } = await import('./preset-ratchet.js');
368
+ const { pendingSeeds, acceptSeed } = await import('./seed-rules.js');
369
+ const added = [];
370
+ for (const s of pendingSeeds({ root, agent }).filter((x) => x.kind === 'ratchet')) {
371
+ if (await acceptSeed(s.id, { scope: 'global', root, agent })) added.push(s.ruleText);
372
+ }
373
+ const all = acceptedGlobalPresets({ agent }).length;
374
+ return { path: presetRatchetPath({ agent }), scope, added, skippedRules: all - added.length, presets: presetRuleEntries().length };
375
+ }
343
376
  const presets = presetRules();
344
- const rmPath = resolveRatchetPath(scope, root);
377
+ const rmPath = resolveRatchetPath(scope, root, agent);
345
378
  const result = { path: rmPath, scope, added: [], skippedRules: 0, presets: presets.length };
346
379
  const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}:\s*/, '').trim();
347
380
 
348
381
  let content = existsSync(rmPath)
349
382
  ? readFileSync(rmPath, 'utf8')
350
- : harnessRatchetMdInitial();
383
+ : initialRatchet(agent);
351
384
  const have = new Set(
352
- harnessListRules({ root, scope }).rules.map((r) => stripDate(r.text)),
385
+ harnessListRules({ root, scope, agent }).rules.map((r) => stripDate(r.text)),
353
386
  );
354
387
  for (const rule of presets) {
355
388
  if (have.has(rule)) {
@@ -385,7 +418,8 @@ export function presetRuleEntries() {
385
418
  const data = JSON.parse(readFileSync(path, 'utf8'));
386
419
  return (Array.isArray(data.rules) ? data.rules : [])
387
420
  .filter((r) => r && typeof r.ko === 'string' && r.ko.trim())
388
- .map((r) => ({ ko: r.ko.trim(), en: (r.en || r.ko).trim() }));
421
+ .map((r) => ({ ko: r.ko.trim(), en: (r.en || r.ko).trim(),
422
+ ...(Array.isArray(r.agents) ? { agents: r.agents.filter((a) => typeof a === 'string') } : {}) }));
389
423
  } catch {
390
424
  return [];
391
425
  }
@@ -400,8 +434,8 @@ export function presetRules(lang = userLanguage()) {
400
434
  * harness list — return numbered ratchet rules from .claude/ratchet.md.
401
435
  * Numbering is 1-based and matches `harness rm <N>`.
402
436
  */
403
- export function harnessListRules({ root = findProjectRoot(), scope = 'project' } = {}) {
404
- const rmPath = resolveRatchetPath(scope, root);
437
+ export function harnessListRules({ root = findProjectRoot(), scope = 'project', agent = 'claude' } = {}) {
438
+ const rmPath = resolveRatchetPath(scope, root, agent);
405
439
  if (!existsSync(rmPath)) return { path: rmPath, rules: [] };
406
440
  const lines = readFileSync(rmPath, 'utf8').split('\n');
407
441
  const rules = [];
@@ -482,8 +516,8 @@ export function contextWeightStatus({ root = findProjectRoot() } = {}) {
482
516
  * it. Selection is by tag and/or age; nothing is deleted, so a pruned rule can
483
517
  * be pasted back. Returns the pruned rules for the CLI to echo.
484
518
  */
485
- export function harnessPrune({ root = findProjectRoot(), scope = 'project', tag = null, olderThanMonths = null, dryRun = false } = {}) {
486
- const { path: rmPath, rules } = harnessListRules({ root, scope });
519
+ export function harnessPrune({ root = findProjectRoot(), scope = 'project', agent = 'claude', tag = null, olderThanMonths = null, dryRun = false } = {}) {
520
+ const { path: rmPath, rules } = harnessListRules({ root, scope, agent });
487
521
  if (!existsSync(rmPath)) return { ok: false, error: `ratchet.md not found at ${rmPath}` };
488
522
  if (!tag && !olderThanMonths) return { ok: false, error: 'Nothing selected — pass --tag <t> and/or --older-than <months>' };
489
523
  const cutoff = olderThanMonths ? Date.now() - olderThanMonths * 30 * 24 * 60 * 60 * 1000 : null;
@@ -519,8 +553,8 @@ export function harnessPrune({ root = findProjectRoot(), scope = 'project', tag
519
553
  * value is one-way accumulation; deleting should feel deliberate. The CLI
520
554
  * surfaces a "narrow the condition instead" reminder around this call.
521
555
  */
522
- export function harnessRmRule(n, { root = findProjectRoot(), scope = 'project' } = {}) {
523
- const { path: rmPath, rules } = harnessListRules({ root, scope });
556
+ export function harnessRmRule(n, { root = findProjectRoot(), scope = 'project', agent = 'claude' } = {}) {
557
+ const { path: rmPath, rules } = harnessListRules({ root, scope, agent });
524
558
  if (!existsSync(rmPath)) {
525
559
  return { ok: false, error: `ratchet.md not found at ${rmPath}` };
526
560
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * preset-ratchet — the bundled ratchet presets the user accepted globally,
3
+ * kept in a file of their own.
4
+ *
5
+ * Presets used to be appended to ratchet.md next to the user's own rules. Once
6
+ * there, an upgrade could not reword a preset, drop one that turned out wrong,
7
+ * or tell which lines were ours: every line looked like the user's. This file
8
+ * holds only presets and is regenerated whole from the recorded answers in
9
+ * seed-state, the same way ratchet-model.md is regenerated from the rule
10
+ * registry. So an upgrade updates it by itself, `seed skip` removes one, and
11
+ * ratchet.md stays the user's.
12
+ *
13
+ * Claude Code ~/.claude/ratchet-preset.md (imported by the global harness block)
14
+ * Codex <CODEX_HOME>/ratchet-preset.md (injected by the SessionStart hook)
15
+ *
16
+ * Presets accepted into a project scope still go to that project's ratchet.md:
17
+ * a project rule is the user's placement decision, not a tool-wide default.
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join } from 'node:path';
23
+ import { codexUserDir } from './agent.js';
24
+ import { userLanguage } from './config.js';
25
+ import {
26
+ HARNESS_BLOCK_BEGIN,
27
+ HARNESS_BLOCK_END,
28
+ PRESET_RATCHET_IMPORT_RE,
29
+ presetRatchetImportLine,
30
+ renderPresetRatchet,
31
+ } from './harness-templates.js';
32
+ import { loadSeedState, ratchetPresets, saveSeedState } from './seed-rules.js';
33
+
34
+ export function presetRatchetPath({ agent = 'claude' } = {}) {
35
+ return agent === 'codex'
36
+ ? join(codexUserDir(), 'ratchet-preset.md')
37
+ : join(homedir(), '.claude', 'ratchet-preset.md');
38
+ }
39
+
40
+ function globalRatchetPath(agent) {
41
+ return agent === 'codex' ? join(codexUserDir(), 'ratchet.md') : join(homedir(), '.claude', 'ratchet.md');
42
+ }
43
+
44
+ function writeIfChanged(file, text) {
45
+ let before = null;
46
+ try { before = readFileSync(file, 'utf8'); } catch { /* first write */ }
47
+ if (before === text) return false;
48
+ mkdirSync(dirname(file), { recursive: true });
49
+ const tmp = `${file}.tmp-${process.pid}`;
50
+ writeFileSync(tmp, text);
51
+ renameSync(tmp, file);
52
+ return true;
53
+ }
54
+
55
+ /** The accepted-global presets, in the bundled order, with this version's wording. */
56
+ export function acceptedGlobalPresets({ agent = 'claude', lang = userLanguage() } = {}) {
57
+ const { decided } = loadSeedState({ agent });
58
+ return ratchetPresets(lang, { agent })
59
+ .filter((p) => decided[p.id]?.action === 'accepted' && decided[p.id]?.scope === 'global')
60
+ .map((p) => ({ id: p.id, text: p.text }));
61
+ }
62
+
63
+ /**
64
+ * Rewrite the preset file from the recorded answers. A preset that a newer
65
+ * release removed simply stops being rendered; its answer stays in seed-state
66
+ * so it is not offered again if it comes back under the same text.
67
+ */
68
+ export function syncPresetRatchet({ agent = 'claude', lang = userLanguage() } = {}) {
69
+ const file = presetRatchetPath({ agent });
70
+ const rules = acceptedGlobalPresets({ agent, lang });
71
+ const changed = writeIfChanged(file, renderPresetRatchet(rules, { lang, agent }));
72
+ return { file, rules: rules.length, changed };
73
+ }
74
+
75
+ /**
76
+ * Move presets that earlier versions appended to the global ratchet.md into
77
+ * the preset file. A line counts as a preset only when its text, without the
78
+ * date stamp, equals a bundled rule in either language, so nothing the user
79
+ * wrote themselves is touched. The original is backed up once before the
80
+ * first change.
81
+ */
82
+ export function migrateLegacyPresetRules({ agent = 'claude' } = {}) {
83
+ const file = globalRatchetPath(agent);
84
+ if (!existsSync(file)) return { file, moved: 0 };
85
+ const byText = new Map();
86
+ for (const lang of ['ko', 'en']) {
87
+ for (const p of ratchetPresets(lang, { agent })) byText.set(p.text.trim(), p.id);
88
+ }
89
+ const stamp = /^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/;
90
+ const original = readFileSync(file, 'utf8');
91
+ const moved = [];
92
+ const kept = original.split('\n').filter((line) => {
93
+ const m = line.match(/^\s*-\s+(.*)$/);
94
+ if (!m) return true;
95
+ const id = byText.get(m[1].replace(stamp, '').trim());
96
+ if (!id) return true;
97
+ moved.push(id);
98
+ return false;
99
+ });
100
+ if (!moved.length) return { file, moved: 0 };
101
+ writeFileSync(`${file}.bak-preset-migration`, original);
102
+ writeIfChanged(file, kept.join('\n'));
103
+ const state = loadSeedState({ agent });
104
+ const today = new Date().toISOString().slice(0, 10);
105
+ for (const id of moved) state.decided[id] = { action: 'accepted', at: today, scope: 'global', migrated: true };
106
+ saveSeedState(state, { agent });
107
+ return { file, moved: moved.length };
108
+ }
109
+
110
+ /**
111
+ * Add the preset import to a global harness block written before the file
112
+ * existed. Only the tool's own marked block is edited; a CLAUDE.md without the
113
+ * block is left alone, since the user chose not to install the harness there.
114
+ */
115
+ export function ensurePresetImport() {
116
+ const file = join(homedir(), '.claude', 'CLAUDE.md');
117
+ let text;
118
+ try { text = readFileSync(file, 'utf8'); } catch { return { file, added: false }; }
119
+ const begin = text.indexOf(HARNESS_BLOCK_BEGIN);
120
+ const end = text.indexOf(HARNESS_BLOCK_END);
121
+ if (begin < 0 || end < begin) return { file, added: false };
122
+ if (PRESET_RATCHET_IMPORT_RE.test(text.slice(begin, end))) return { file, added: false };
123
+ writeFileSync(file, `${text.slice(0, end)}${presetRatchetImportLine()}\n${text.slice(end)}`);
124
+ return { file, added: true };
125
+ }
126
+
127
+ /** Everything a session start or an install does: migrate, render, and wire the import. */
128
+ export function preparePresetRatchet({ agent = 'claude', lang = userLanguage() } = {}) {
129
+ const migrated = migrateLegacyPresetRules({ agent });
130
+ const synced = syncPresetRatchet({ agent, lang });
131
+ const imported = agent === 'codex' ? { added: false } : ensurePresetImport();
132
+ return { ...synced, moved: migrated.moved, importAdded: imported.added };
133
+ }
package/src/seed-rules.js CHANGED
@@ -46,23 +46,25 @@ import {
46
46
 
47
47
  const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
48
48
 
49
- export function seedStatePath() {
50
- return join(userDataDir(), 'seed-state.json');
49
+ // Codex answers are kept apart: saying no in one agent must not consume the
50
+ // other agent's offer, and the two agents write to different ratchet files.
51
+ export function seedStatePath({ agent = 'claude' } = {}) {
52
+ return join(userDataDir(), agent === 'codex' ? 'codex-seed-state.json' : 'seed-state.json');
51
53
  }
52
54
 
53
- export function loadSeedState() {
55
+ export function loadSeedState(opts = {}) {
54
56
  try {
55
- const data = JSON.parse(readFileSync(seedStatePath(), 'utf8'));
57
+ const data = JSON.parse(readFileSync(seedStatePath(opts), 'utf8'));
56
58
  return data && typeof data.decided === 'object' && data.decided ? data : { decided: {} };
57
59
  } catch {
58
60
  return { decided: {} };
59
61
  }
60
62
  }
61
63
 
62
- export function saveSeedState(state) {
64
+ export function saveSeedState(state, opts = {}) {
63
65
  const dir = userDataDir();
64
66
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
65
- writeFileSync(seedStatePath(), JSON.stringify(state, null, 2) + '\n');
67
+ writeFileSync(seedStatePath(opts), JSON.stringify(state, null, 2) + '\n');
66
68
  }
67
69
 
68
70
  /** Bundled tier-delegation presets (empty array when the file is unreadable). */
@@ -80,15 +82,19 @@ export function modelPresets() {
80
82
  * ids would renumber the moment a rule is inserted above them, silently moving
81
83
  * a user's "no" onto a different rule.
82
84
  */
83
- export function ratchetPresets(lang = userLanguage()) {
84
- return presetRuleEntries().map((r) => ({
85
- // Hashed from the Korean text in both languages: the id has to survive a
86
- // reworded translation, or a user who declined a rule would be asked again
87
- // the next time its English copy changes.
88
- id: `fix-${createHash('sha1').update(r.ko).digest('hex').slice(0, 6)}`,
89
- text: lang === 'ko' ? r.ko : r.en,
90
- ko: r.ko,
91
- }));
85
+ export function ratchetPresets(lang = userLanguage(), { agent = 'claude' } = {}) {
86
+ return presetRuleEntries()
87
+ // A preset without `agents` applies everywhere; one naming Claude model
88
+ // tiers would only mislead a Codex session.
89
+ .filter((r) => !r.agents || r.agents.includes(agent))
90
+ .map((r) => ({
91
+ // Hashed from the Korean text in both languages: the id has to survive a
92
+ // reworded translation, or a user who declined a rule would be asked again
93
+ // the next time its English copy changes.
94
+ id: `fix-${createHash('sha1').update(r.ko).digest('hex').slice(0, 6)}`,
95
+ text: lang === 'ko' ? r.ko : r.en,
96
+ ko: r.ko,
97
+ }));
92
98
  }
93
99
 
94
100
  const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/, '').trim();
@@ -102,9 +108,11 @@ const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}(\s*\([^)]*\))?:\s*/, '').
102
108
  * whose own route-scan already promoted that shape does not need ours, and
103
109
  * offering it anyway would read as the tool failing to notice its own state.
104
110
  */
105
- export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() } = {}) {
106
- const { decided } = loadSeedState();
107
- const registered = (() => {
111
+ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
112
+ const { decided } = loadSeedState({ agent });
113
+ // Codex model routing comes from its own measured route-scan candidates;
114
+ // Claude tier presets name models Codex does not run.
115
+ const registered = agent === 'codex' ? [] : (() => {
108
116
  try {
109
117
  return loadModelRules().rules;
110
118
  } catch {
@@ -116,12 +124,12 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() }
116
124
  const haveText = new Set();
117
125
  for (const scope of ['global', 'project']) {
118
126
  try {
119
- for (const r of harnessListRules({ root, scope }).rules) haveText.add(stripDate(r.text));
127
+ for (const r of harnessListRules({ root, scope, agent }).rules) haveText.add(stripDate(r.text));
120
128
  } catch { /* a missing ratchet file just means nothing is registered yet */ }
121
129
  }
122
130
 
123
131
  const out = [];
124
- for (const p of modelPresets()) {
132
+ for (const p of agent === 'codex' ? [] : modelPresets()) {
125
133
  if (decided[p.id]) continue;
126
134
  if (haveModel.has(`${p.tier}|${p.category}`)) continue;
127
135
  out.push({
@@ -134,7 +142,7 @@ export function pendingSeeds({ lang = userLanguage(), root = findProjectRoot() }
134
142
  preset: p,
135
143
  });
136
144
  }
137
- for (const p of ratchetPresets(lang)) {
145
+ for (const p of ratchetPresets(lang, { agent })) {
138
146
  if (decided[p.id]) continue;
139
147
  // Either language counts as already registered: `harness pull` may have
140
148
  // written the other one.
@@ -149,10 +157,10 @@ export function findSeed(id, opts = {}) {
149
157
  return pendingSeeds(opts).find((s) => s.id === id) || null;
150
158
  }
151
159
 
152
- function recordDecision(id, action, extra = {}) {
153
- const state = loadSeedState();
160
+ function recordDecision(id, action, extra = {}, { agent = 'claude' } = {}) {
161
+ const state = loadSeedState({ agent });
154
162
  state.decided[id] = { action, at: new Date().toISOString().slice(0, 10), ...extra };
155
- saveSeedState(state);
163
+ saveSeedState(state, { agent });
156
164
  }
157
165
 
158
166
  /**
@@ -162,13 +170,24 @@ function recordDecision(id, action, extra = {}) {
162
170
  *
163
171
  * Returns { id, kind, scope, path?, rule } or null when the id is not pending.
164
172
  */
165
- export async function acceptSeed(id, { scope = 'global', root = findProjectRoot(), lang = userLanguage() } = {}) {
166
- const seed = findSeed(id, { lang, root });
173
+ export async function acceptSeed(id, { scope, root = findProjectRoot(), lang = userLanguage(), agent = 'claude' } = {}) {
174
+ // No default: a rule in the wrong scope is noise everywhere or missing where it mattered.
175
+ if (scope !== 'global' && scope !== 'project') throw new Error('acceptSeed needs an explicit scope: global or project');
176
+ const seed = findSeed(id, { lang, root, agent });
167
177
  if (!seed) return null;
168
178
 
169
179
  if (seed.kind === 'ratchet') {
170
- const res = harnessPromote(seed.ruleText, { root, scope });
171
- recordDecision(id, 'accepted', { scope });
180
+ // A global preset goes to the tool-owned preset file, which is rendered
181
+ // from the recorded answer; a project one is the user's placement and is
182
+ // appended to that project's ratchet.md as before.
183
+ if (scope === 'global') {
184
+ recordDecision(id, 'accepted', { scope }, { agent });
185
+ const { syncPresetRatchet } = await import('./preset-ratchet.js');
186
+ const res = syncPresetRatchet({ agent, lang });
187
+ return { id, kind: 'ratchet', scope, path: res.file, rule: seed.ruleText };
188
+ }
189
+ const res = harnessPromote(seed.ruleText, { root, scope, agent });
190
+ recordDecision(id, 'accepted', { scope }, { agent });
172
191
  return { id, kind: 'ratchet', scope, path: res.path, rule: seed.ruleText };
173
192
  }
174
193
 
@@ -208,23 +227,39 @@ export async function acceptSeed(id, { scope = 'global', root = findProjectRoot(
208
227
  lastSeen: today,
209
228
  });
210
229
  const written = syncAllFiles();
211
- recordDecision(id, 'accepted', { scope });
230
+ recordDecision(id, 'accepted', { scope }, { agent });
212
231
  return { id, kind: 'model', scope, paths: written, rule: composeRuleText(entry.rule, entry, lang) };
213
232
  }
214
233
 
215
- /** Record a "no" so the rule is never offered again (until `seed reset`). */
216
- export function skipSeed(id, opts = {}) {
234
+ /**
235
+ * Record a "no" so the rule is never offered again (until `seed reset`).
236
+ *
237
+ * A preset already accepted globally can be skipped too: that is how one comes
238
+ * out of the preset file, which is rendered from these answers.
239
+ */
240
+ export async function skipSeed(id, opts = {}) {
217
241
  const seed = findSeed(id, opts);
218
- if (!seed) return null;
219
- recordDecision(id, 'skipped');
220
- return { id, kind: seed.kind, rule: seed.ruleText };
242
+ if (seed) {
243
+ recordDecision(id, 'skipped', {}, { agent: opts.agent });
244
+ return { id, kind: seed.kind, rule: seed.ruleText };
245
+ }
246
+ const { decided } = loadSeedState({ agent: opts.agent });
247
+ const preset = ratchetPresets(opts.lang, { agent: opts.agent }).find((p) => p.id === id);
248
+ if (!preset || decided[id]?.action !== 'accepted' || decided[id]?.scope !== 'global') return null;
249
+ recordDecision(id, 'skipped', { was: 'accepted' }, { agent: opts.agent });
250
+ const { syncPresetRatchet } = await import('./preset-ratchet.js');
251
+ syncPresetRatchet({ agent: opts.agent, lang: opts.lang });
252
+ return { id, kind: 'ratchet', rule: preset.text, removed: true };
221
253
  }
222
254
 
223
255
  /** Forget every recorded answer — the full set becomes pending again. */
224
- export function resetSeeds() {
225
- const state = loadSeedState();
256
+ export async function resetSeeds({ agent = 'claude' } = {}) {
257
+ const state = loadSeedState({ agent });
226
258
  const count = Object.keys(state.decided).length;
227
- saveSeedState({ decided: {} });
259
+ saveSeedState({ decided: {} }, { agent });
260
+ // The preset file mirrors the answers, so forgetting them empties it.
261
+ const { syncPresetRatchet } = await import('./preset-ratchet.js');
262
+ syncPresetRatchet({ agent });
228
263
  return count;
229
264
  }
230
265
 
@@ -236,9 +271,23 @@ export function resetSeeds() {
236
271
  * asked for, and because a single "register all of these?" question invites a
237
272
  * yes to rules the user never read.
238
273
  */
239
- export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot() } = {}) {
240
- const pending = pendingSeeds({ lang, root });
274
+ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
275
+ const pending = pendingSeeds({ lang, root, agent });
241
276
  if (pending.length === 0) return null;
277
+ if (agent === 'codex') {
278
+ // Codex hook text stays English; the rule text itself follows the user's language.
279
+ const cmd = (s) => `${CLI_NAME} seed ${s} --agent codex`;
280
+ return [
281
+ `[Sprag seed] ${pending.length} recommended Codex ratchet rule(s) from the bundled presets are not registered yet.`,
282
+ 'Ask in your first reply, after answering any request the user opened with. Offer these choices in this order:',
283
+ ` 1. Register all, globally: ${cmd('accept all --global')}`,
284
+ ` 2. Register all, this project only: ${cmd('accept all --project')}`,
285
+ ' 3. Decide one at a time: ask about each rule below and run its command as soon as they answer',
286
+ ` 4. Register none: ${cmd('skip all')} (never offered again)`,
287
+ 'Always confirm the scope with the user before running an accept command.',
288
+ ...pending.map((s) => ` [${s.id}] ${s.ruleText}`),
289
+ ].join('\n');
290
+ }
242
291
  const models = pending.filter((s) => s.kind === 'model');
243
292
  const fixes = pending.filter((s) => s.kind === 'ratchet');
244
293
  const lines = [];
@@ -266,16 +266,51 @@ export function releaseHighlights(body, lang = 'en') {
266
266
  return out;
267
267
  }
268
268
 
269
+ /**
270
+ * The releases endpoint for one version, or a throw.
271
+ *
272
+ * The version that arrives here came out of a registry response rather than out
273
+ * of this process, so it is checked rather than trusted: a value carrying a
274
+ * slash or a scheme would otherwise have a say in which URL this function
275
+ * requests. Two checks are needed, because either one alone leaves a gap. The
276
+ * shape test rejects anything that is not a version, and the prefix test runs
277
+ * against the parsed URL rather than against the string it was built from,
278
+ * because `new URL` has collapsed `../` segments by that point and the same
279
+ * test on the raw string would pass a value that walks back out of the path.
280
+ * The origin is a constant either way, so no input can move the request to
281
+ * another host; this states that guarantee in code instead of leaving it
282
+ * implied.
283
+ */
284
+ function releaseTagUrl(version) {
285
+ const tag = String(version);
286
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(tag)) {
287
+ throw new Error(`refusing to request notes for ${JSON.stringify(tag)}: not a version`);
288
+ }
289
+ const url = new URL(`${RELEASES_API}/v${tag}`);
290
+ if (!url.href.startsWith(`${RELEASES_API}/`)) {
291
+ throw new Error(`refusing to request ${url.href}: outside ${RELEASES_API}`);
292
+ }
293
+ return url;
294
+ }
295
+
269
296
  /**
270
297
  * Fetch the highlights for one version. Returns [] on any failure: the notice
271
298
  * is worth showing with a bare version number, so a missing release, a rate
272
- * limit, or an offline machine must not cost the user the notice itself.
299
+ * limit, or an offline machine must not cost the user the notice itself. A
300
+ * version that fails the endpoint check above lands in that same place: no
301
+ * highlights, and the notice still goes out.
273
302
  */
274
303
  export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS, lang = 'en' } = {}) {
275
304
  const controller = new AbortController();
276
305
  const timer = setTimeout(() => controller.abort(), timeoutMs);
277
306
  try {
278
- const res = await fetch(`${RELEASES_API}/v${version}`, {
307
+ const endpoint = releaseTagUrl(version);
308
+ // The URL is this module's own constant plus a checked version. The
309
+ // scanner's node_ssrf heuristic reads any two-argument function as an HTTP
310
+ // handler and its first argument as request input, which is what it matched
311
+ // on here; this module serves nothing and has no request to read.
312
+ // nosemgrep: rules.lgpl.nodejs_scan.javascript-ssrf-rule-node_ssrf
313
+ const res = await fetch(endpoint, {
279
314
  signal: controller.signal,
280
315
  headers: { accept: 'application/vnd.github+json' },
281
316
  });