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.
- package/README.ko.md +54 -3
- package/README.md +63 -4
- package/bin/cli.js +30 -7
- package/docs/CODEX.ko.md +184 -0
- package/docs/CODEX.md +497 -0
- package/package.json +15 -4
- package/presets/ratchet-rules.json +4 -1
- package/src/agent.js +33 -0
- package/src/codex-brief.js +89 -0
- package/src/codex-budget.js +73 -0
- package/src/codex-cache-policy.js +203 -0
- package/src/codex-cache.js +46 -0
- package/src/codex-delegation.js +154 -0
- package/src/codex-doc2md-ledger.js +45 -0
- package/src/codex-doc2md.js +99 -0
- package/src/codex-doctor.js +53 -0
- package/src/codex-harness.js +200 -0
- package/src/codex-hooks.js +138 -0
- package/src/codex-installer.js +79 -0
- package/src/codex-ledger.js +143 -0
- package/src/codex-panel-launcher.js +48 -0
- package/src/codex-panel-runner.js +74 -0
- package/src/codex-panel-session.js +35 -0
- package/src/codex-panel-setup.js +98 -0
- package/src/codex-panel-shell.js +51 -0
- package/src/codex-panel-state.js +40 -0
- package/src/codex-panel.js +448 -0
- package/src/codex-parser.js +397 -0
- package/src/codex-route-scan.js +171 -0
- package/src/commands/codex-delegate.js +59 -0
- package/src/commands/codex.js +579 -0
- package/src/commands/harness.js +13 -2
- package/src/commands/install.js +106 -10
- package/src/commands/route-scan.js +4 -0
- package/src/commands/seed.js +16 -12
- package/src/commands/uninstall.js +23 -0
- package/src/commands/upgrade.js +13 -0
- package/src/doc2md.cjs +72 -28
- package/src/handoff.js +27 -7
- package/src/harness-templates.js +33 -1
- package/src/harness.js +51 -17
- package/src/preset-ratchet.js +133 -0
- package/src/seed-rules.js +88 -39
- 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
|
-
|
|
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 =
|
|
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
|
-
:
|
|
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
|
-
|
|
50
|
-
|
|
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()
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
/**
|
|
216
|
-
|
|
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 (
|
|
219
|
-
|
|
220
|
-
|
|
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 = [];
|
package/src/update-check.js
CHANGED
|
@@ -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
|
|
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
|
});
|