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/commands/install.js
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* a legacy command file is removed automatically. Cross-platform.
|
|
6
6
|
* sprag install # install/update the skill
|
|
7
7
|
* sprag install --force # overwrite existing skill file
|
|
8
|
-
* sprag install --
|
|
9
|
-
*
|
|
8
|
+
* sprag install --manual # ask before each optional step instead
|
|
9
|
+
*
|
|
10
|
+
* The default is automatic: nothing is asked, the bundled presets are
|
|
11
|
+
* registered globally, and Codex is set up too when ~/.codex exists. `--manual`
|
|
12
|
+
* restores the step-by-step questions (they still need a terminal attached).
|
|
13
|
+
* `--yes` and `--no-input` are accepted as the explicit spelling of the default.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import { debug } from '../debug.js';
|
|
@@ -19,12 +23,13 @@ export async function run({ hasFlag }) {
|
|
|
19
23
|
const { userLanguage, languageDecided, setUserLanguage } = await import('../config.js');
|
|
20
24
|
const { canPrompt, confirm } = await import('../prompt.js');
|
|
21
25
|
const force = hasFlag('--force');
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
const
|
|
26
|
+
// Automatic unless asked otherwise. A question per step turned the install
|
|
27
|
+
// into a dozen answers before any work, and every answer has a sensible
|
|
28
|
+
// default: the harness and presets are what make the first session save
|
|
29
|
+
// tokens. `--manual` brings the questions back, and only with a terminal
|
|
30
|
+
// attached, so postinstall, CI and pipes never wait on input.
|
|
31
|
+
const manual = hasFlag('--manual') && !hasFlag('--yes') && !hasFlag('--no-input');
|
|
32
|
+
const interactive = manual && canPrompt();
|
|
28
33
|
|
|
29
34
|
// Output language, decided first because every line below it — and every
|
|
30
35
|
// briefing the hooks inject from here on — is written in it. Until now it
|
|
@@ -342,10 +347,37 @@ export async function run({ hasFlag }) {
|
|
|
342
347
|
// postinstall cannot hold a conversation, and a prompt here would be
|
|
343
348
|
// answered by whoever happens to be at the terminal for a set of rules
|
|
344
349
|
// they have not read. All the install does is say what is waiting.
|
|
350
|
+
// Presets now live in ratchet-preset.md. Moving the ones older versions
|
|
351
|
+
// appended to ratchet.md comes first, so they are not offered again below.
|
|
345
352
|
try {
|
|
346
|
-
const {
|
|
353
|
+
const { preparePresetRatchet } = await import('../preset-ratchet.js');
|
|
354
|
+
const p = preparePresetRatchet();
|
|
355
|
+
if (p.moved) {
|
|
356
|
+
console.log('');
|
|
357
|
+
console.log(lang === 'ko'
|
|
358
|
+
? ` preset: ratchet.md 에 있던 추천 룰 ${p.moved}건을 ${p.file} 로 옮겼습니다 (원본은 ratchet.md.bak-preset-migration).`
|
|
359
|
+
: ` preset: moved ${p.moved} bundled rule(s) from ratchet.md to ${p.file} (original kept as ratchet.md.bak-preset-migration).`);
|
|
360
|
+
}
|
|
361
|
+
} catch (e) {
|
|
362
|
+
debug('install:preset-ratchet', e); // the next session start retries
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const { pendingSeeds, acceptSeed } = await import('../seed-rules.js');
|
|
347
367
|
const pending = pendingSeeds();
|
|
348
|
-
if (pending.length > 0) {
|
|
368
|
+
if (pending.length > 0 && !manual) {
|
|
369
|
+
// Automatic install registers every preset globally. Global because a
|
|
370
|
+
// preset is a tool-wide rule, not a project's; `sprag seed` lists them
|
|
371
|
+
// and `sprag harness rm` removes any one afterwards.
|
|
372
|
+
let n = 0;
|
|
373
|
+
for (const p of pending) {
|
|
374
|
+
try { if (await acceptSeed(p.id, { scope: 'global' })) n += 1; } catch (e) { debug('install:seed-accept', e); }
|
|
375
|
+
}
|
|
376
|
+
console.log('');
|
|
377
|
+
console.log(lang === 'ko'
|
|
378
|
+
? ` seed: 추천 룰 ${n}건을 전체 프로젝트에 등록했습니다 (모델 피팅 + 래칫 프리셋). 목록: sprag seed`
|
|
379
|
+
: ` seed: registered ${n} recommended rule(s) for all projects (model-fitting + ratchet presets). List: sprag seed`);
|
|
380
|
+
} else if (pending.length > 0) {
|
|
349
381
|
console.log('');
|
|
350
382
|
console.log(lang === 'ko'
|
|
351
383
|
? ` seed: 추천 룰 ${pending.length}건이 대기 중입니다 (모델 피팅 + 래칫 프리셋).`
|
|
@@ -358,6 +390,70 @@ export async function run({ hasFlag }) {
|
|
|
358
390
|
debug('install:seed-offer', e); // optional feature; never fail install
|
|
359
391
|
}
|
|
360
392
|
|
|
393
|
+
// Codex, when it is installed on this machine. Without this a Codex user
|
|
394
|
+
// had to know to run `sprag install --agent codex` after the package
|
|
395
|
+
// install. Registration is all we can do: Codex still asks the user to
|
|
396
|
+
// trust the hooks in /hooks before they run, and that step stays theirs.
|
|
397
|
+
try {
|
|
398
|
+
const { codexUserDir } = await import('../agent.js');
|
|
399
|
+
const { existsSync } = await import('node:fs');
|
|
400
|
+
if (process.env.CTS_NO_CODEX !== '1' && existsSync(codexUserDir())) {
|
|
401
|
+
let proceed = true;
|
|
402
|
+
if (interactive) {
|
|
403
|
+
console.log('');
|
|
404
|
+
proceed = await confirm(lang === 'ko'
|
|
405
|
+
? ' codex: Codex 가 설치되어 있습니다. Codex 훅과 하네스도 함께 설정할까요?'
|
|
406
|
+
: ' codex: Codex is installed here. Set up its hooks and harness too?', { defaultValue: true });
|
|
407
|
+
}
|
|
408
|
+
if (proceed) {
|
|
409
|
+
const { configureCodexHooks } = await import('../codex-installer.js');
|
|
410
|
+
const { initCodexHarness } = await import('../codex-harness.js');
|
|
411
|
+
const { loadConfig } = await import('../config.js');
|
|
412
|
+
const { setupCodexPanel, describePanelSetup } = await import('../codex-panel-setup.js');
|
|
413
|
+
const config = loadConfig() || {};
|
|
414
|
+
// A manual install asks before touching the system package manager.
|
|
415
|
+
let env = process.env;
|
|
416
|
+
if (interactive && process.platform === 'darwin') {
|
|
417
|
+
const { tmuxAvailable } = await import('../codex-panel-setup.js');
|
|
418
|
+
if (!tmuxAvailable() && !await confirm(lang === 'ko'
|
|
419
|
+
? ' codex: tmux 가 없습니다. Homebrew 로 설치해 같은 창에 패널을 붙일까요?'
|
|
420
|
+
: ' codex: tmux is missing. Install it with Homebrew so the panel shares the Codex window?', { defaultValue: true })) {
|
|
421
|
+
env = { ...process.env, CTS_NO_TMUX: '1' };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
let panel;
|
|
425
|
+
try { panel = setupCodexPanel({ config, env }); }
|
|
426
|
+
catch (e) {
|
|
427
|
+
debug('install:codex-panel', e);
|
|
428
|
+
const auto = process.platform === 'darwin' && config.codex?.panelAuto !== false;
|
|
429
|
+
panel = { panelAuto: auto, mode: auto ? 'window' : 'off' };
|
|
430
|
+
}
|
|
431
|
+
const hook = configureCodexHooks({ panelAuto: panel.panelAuto });
|
|
432
|
+
console.log('');
|
|
433
|
+
console.log(` codex: hooks ${hook.action} (${hook.file})`);
|
|
434
|
+
for (const line of describePanelSetup(panel, lang)) console.log(` codex: ${line}`);
|
|
435
|
+
if (process.env.CTS_NO_HARNESS !== '1') {
|
|
436
|
+
const h = initCodexHarness({ scope: 'global' });
|
|
437
|
+
console.log(` codex: harness ${h.file}`);
|
|
438
|
+
}
|
|
439
|
+
try { (await import('../preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch (e) { debug('install:codex-preset', e); }
|
|
440
|
+
if (!manual) {
|
|
441
|
+
const { pendingSeeds, acceptSeed } = await import('../seed-rules.js');
|
|
442
|
+
let n = 0;
|
|
443
|
+
for (const p of pendingSeeds({ agent: 'codex' })) {
|
|
444
|
+
try { if (await acceptSeed(p.id, { scope: 'global', agent: 'codex' })) n += 1; } catch (e) { debug('install:codex-seed', e); }
|
|
445
|
+
}
|
|
446
|
+
if (n) console.log(lang === 'ko' ? ` codex: 추천 룰 ${n}건 등록` : ` codex: registered ${n} recommended rule(s)`);
|
|
447
|
+
}
|
|
448
|
+
console.log(lang === 'ko'
|
|
449
|
+
? ' codex: Codex 에서 /hooks 를 열어 Sprag 훅을 신뢰해야 실행됩니다 (Codex 보안 정책이라 자동화할 수 없습니다).'
|
|
450
|
+
: " codex: open /hooks in Codex and trust the Sprag hooks; Codex requires that step and it cannot be automated.");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} catch (e) {
|
|
454
|
+
debug('install:codex', e); // optional; `sprag install --agent codex` still works
|
|
455
|
+
}
|
|
456
|
+
|
|
361
457
|
console.log('');
|
|
362
458
|
console.log('Open Claude Code in any directory and just mention:');
|
|
363
459
|
console.log(' "cache hit rate" / "1M context" / "5H cap" — the skill auto-activates.');
|
|
@@ -277,6 +277,10 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
277
277
|
// when the rendering differs, so the common case is a read and a string
|
|
278
278
|
// compare.
|
|
279
279
|
try { mrHook.syncAllFiles(); } catch (e) { debug('route-scan:sync-ratchet', e); }
|
|
280
|
+
// Same reason for the preset ratchet: an upgrade that rewords or drops a
|
|
281
|
+
// bundled rule reaches ratchet-preset.md here, without a reinstall. It
|
|
282
|
+
// also moves presets older versions appended to ratchet.md.
|
|
283
|
+
try { (await import('../preset-ratchet.js')).preparePresetRatchet(); } catch (e) { debug('route-scan:sync-preset', e); }
|
|
280
284
|
// Registered rules whose delegated-category error rate crossed the
|
|
281
285
|
// health threshold since promotion — the user approved these, so a
|
|
282
286
|
// status change must be briefed, not just written into the md file.
|
package/src/commands/seed.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* sprag seed accept <id> --global # register one rule
|
|
6
6
|
* sprag seed accept <id> --project # ... into this project only
|
|
7
7
|
* sprag seed accept all --global # when the user says "all of them"
|
|
8
|
-
* sprag seed skip <id> | skip all # never offer it again
|
|
8
|
+
* sprag seed skip <id> | skip all # never offer it again; for an
|
|
9
|
+
* # accepted preset, drop it from ratchet-preset.md
|
|
9
10
|
* sprag seed reset # make every preset pending again
|
|
10
11
|
*
|
|
11
12
|
* Scope is explicit on purpose, exactly as `harness promote` requires it: the
|
|
@@ -13,19 +14,20 @@
|
|
|
13
14
|
* flag rather than let a default decide where a rule lands.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
|
-
export async function run({ args, hasFlag }) {
|
|
17
|
+
export async function run({ args, hasFlag, agent = 'claude', root: rootArg }) {
|
|
17
18
|
const sub = args[1];
|
|
18
19
|
const seed = await import('../seed-rules.js');
|
|
19
20
|
const { userLanguage } = await import('../config.js');
|
|
20
21
|
const { findProjectRoot } = await import('../harness.js');
|
|
21
22
|
const lang = userLanguage();
|
|
22
23
|
const ko = lang === 'ko';
|
|
23
|
-
const root = findProjectRoot();
|
|
24
|
+
const root = rootArg || findProjectRoot(process.cwd(), { agent });
|
|
25
|
+
const suffix = agent === 'codex' ? ' --agent codex' : '';
|
|
24
26
|
|
|
25
27
|
const scopeFlag = () => (hasFlag('--global') ? 'global' : hasFlag('--project') ? 'project' : null);
|
|
26
28
|
|
|
27
29
|
if (sub === 'reset') {
|
|
28
|
-
const n = seed.resetSeeds();
|
|
30
|
+
const n = await seed.resetSeeds({ agent });
|
|
29
31
|
console.log(ko
|
|
30
32
|
? `기록된 응답 ${n}건을 지웠습니다 — 프리셋 전체가 다시 제안 대상이 됩니다.`
|
|
31
33
|
: `Cleared ${n} recorded answer(s) — every preset is pending again.`);
|
|
@@ -40,14 +42,16 @@ export async function run({ args, hasFlag }) {
|
|
|
40
42
|
: 'Pass an id (list them with `sprag seed`), or `all`.');
|
|
41
43
|
process.exit(1);
|
|
42
44
|
}
|
|
43
|
-
const pending = seed.pendingSeeds({ lang, root });
|
|
45
|
+
const pending = seed.pendingSeeds({ lang, root, agent });
|
|
44
46
|
const targets = ids.includes('all') ? pending.map((s) => s.id) : ids;
|
|
45
47
|
|
|
46
48
|
if (sub === 'skip') {
|
|
47
49
|
for (const id of targets) {
|
|
48
|
-
const r = seed.skipSeed(id, { lang, root });
|
|
50
|
+
const r = await seed.skipSeed(id, { lang, root, agent });
|
|
49
51
|
console.log(r
|
|
50
|
-
? (
|
|
52
|
+
? (r.removed
|
|
53
|
+
? (ko ? `프리셋 파일에서 뺐습니다: ${id}` : `removed from the preset file: ${id}`)
|
|
54
|
+
: (ko ? `건너뜀: ${id}` : `skipped: ${id}`))
|
|
51
55
|
: (ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`));
|
|
52
56
|
}
|
|
53
57
|
return;
|
|
@@ -61,7 +65,7 @@ export async function run({ args, hasFlag }) {
|
|
|
61
65
|
process.exit(1);
|
|
62
66
|
}
|
|
63
67
|
for (const id of targets) {
|
|
64
|
-
const r = await seed.acceptSeed(id, { scope, root, lang });
|
|
68
|
+
const r = await seed.acceptSeed(id, { scope, root, lang, agent });
|
|
65
69
|
if (!r) {
|
|
66
70
|
console.log(ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`);
|
|
67
71
|
continue;
|
|
@@ -77,8 +81,8 @@ export async function run({ args, hasFlag }) {
|
|
|
77
81
|
|
|
78
82
|
// Default: status. Lists what is pending and what was already answered, so a
|
|
79
83
|
// user who said no can see that they did and reverse it with `reset`.
|
|
80
|
-
const pending = seed.pendingSeeds({ lang, root });
|
|
81
|
-
const { decided } = seed.loadSeedState();
|
|
84
|
+
const pending = seed.pendingSeeds({ lang, root, agent });
|
|
85
|
+
const { decided } = seed.loadSeedState({ agent });
|
|
82
86
|
const answered = Object.entries(decided);
|
|
83
87
|
console.log(ko
|
|
84
88
|
? `seed — 동봉 프리셋 중 대기 ${pending.length}건, 응답 완료 ${answered.length}건`
|
|
@@ -99,7 +103,7 @@ export async function run({ args, hasFlag }) {
|
|
|
99
103
|
if (pending.length > 0) {
|
|
100
104
|
console.log('');
|
|
101
105
|
console.log(ko
|
|
102
|
-
?
|
|
103
|
-
:
|
|
106
|
+
? `등록: sprag seed accept <id> --global|--project${suffix} · 거절: seed skip <id>${suffix}`
|
|
107
|
+
: `register: sprag seed accept <id> --global|--project${suffix} · decline: seed skip <id>${suffix}`);
|
|
104
108
|
}
|
|
105
109
|
}
|
|
@@ -35,6 +35,29 @@ export async function run({ hasFlag, args = [] }) {
|
|
|
35
35
|
for (const item of r.kept) {
|
|
36
36
|
console.log(lang === 'ko' ? ` 유지: ${item}` : ` kept: ${item}`);
|
|
37
37
|
}
|
|
38
|
+
// The install now registers Codex too when it finds it, so removal has to
|
|
39
|
+
// undo that as well; otherwise Codex keeps calling a command that may no
|
|
40
|
+
// longer exist. Only our own entries go, exactly as `uninstall --agent codex`.
|
|
41
|
+
try {
|
|
42
|
+
const { codexHookStatus, configureCodexHooks } = await import('../codex-installer.js');
|
|
43
|
+
const { codexHarnessStatus, uninitCodexHarness } = await import('../codex-harness.js');
|
|
44
|
+
const hooked = codexHookStatus().some((h) => h.registered);
|
|
45
|
+
let harness = false;
|
|
46
|
+
try { harness = codexHarnessStatus({ scope: 'global' }).configured > 0; } catch { /* no AGENTS.md */ }
|
|
47
|
+
if (hooked || harness) {
|
|
48
|
+
const hook = configureCodexHooks({ remove: true });
|
|
49
|
+
const h = uninitCodexHarness({ scope: 'global' });
|
|
50
|
+
console.log(lang === 'ko'
|
|
51
|
+
? ` Codex: 훅 ${hook.action} (${hook.file}), 하네스 ${h.removed ? '제거' : '없음'}`
|
|
52
|
+
: ` Codex: hooks ${hook.action} (${hook.file}), harness ${h.removed ? 'removed' : 'absent'}`);
|
|
53
|
+
if (process.platform === 'darwin') {
|
|
54
|
+
try {
|
|
55
|
+
const { installPanelShell } = await import('../codex-panel-shell.js');
|
|
56
|
+
installPanelShell({ remove: true });
|
|
57
|
+
} catch (e) { /* a malformed or foreign block stays for the user to repair */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* Codex state is optional; the Claude removal above already succeeded */ }
|
|
38
61
|
if (!purge) {
|
|
39
62
|
console.log(lang === 'ko'
|
|
40
63
|
? ` 기록된 절감액과 설정까지 지우려면 \`${CLI_NAME} uninstall --purge\` 를 실행하십시오.`
|
package/src/commands/upgrade.js
CHANGED
|
@@ -31,6 +31,17 @@ export async function run({ hasFlag, version }) {
|
|
|
31
31
|
const lang = userLanguage();
|
|
32
32
|
const cmd = upgradeCommand();
|
|
33
33
|
|
|
34
|
+
// pnpm and bun skip lifecycle scripts by default, and npm skips them under
|
|
35
|
+
// ignore-scripts, so the new copy's setup (Codex hooks, tmux panel) runs here.
|
|
36
|
+
// The install is idempotent, so npm running it twice changes nothing.
|
|
37
|
+
async function runSetup() {
|
|
38
|
+
console.log('$ sprag install');
|
|
39
|
+
const installCode = await runCommand('sprag install');
|
|
40
|
+
if (installCode !== 0) console.error(lang === 'ko'
|
|
41
|
+
? '설치 후 설정(sprag install)이 실패했습니다. 새 셸에서 sprag install 을 직접 실행하십시오.'
|
|
42
|
+
: 'Post-upgrade setup (sprag install) failed. Run sprag install in a fresh shell.');
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
if (hasFlag('--print')) {
|
|
35
46
|
console.log(cmd);
|
|
36
47
|
return;
|
|
@@ -55,6 +66,7 @@ export async function run({ hasFlag, version }) {
|
|
|
55
66
|
: `Migration command failed with exit code ${migrateCode}. Check the output above.`);
|
|
56
67
|
process.exit(migrateCode);
|
|
57
68
|
}
|
|
69
|
+
await runSetup();
|
|
58
70
|
console.log(lang === 'ko'
|
|
59
71
|
? '이전이 끝났습니다. 새 셸에서 sprag --version 으로 확인하십시오.'
|
|
60
72
|
: 'Migration done. Confirm with sprag --version in a fresh shell.');
|
|
@@ -86,6 +98,7 @@ export async function run({ hasFlag, version }) {
|
|
|
86
98
|
// still reports the old version. Refresh the cache against the new latest so
|
|
87
99
|
// the statusline chip clears on the next render instead of lingering.
|
|
88
100
|
await refreshUpdateState(s.latest);
|
|
101
|
+
await runSetup();
|
|
89
102
|
console.log(lang === 'ko'
|
|
90
103
|
? `설치가 끝났습니다. 새 셸에서 sprag --version 으로 ${s.latest} 인지 확인하십시오.`
|
|
91
104
|
: `Done. In a fresh shell, run sprag --version to confirm ${s.latest}.`);
|
package/src/doc2md.cjs
CHANGED
|
@@ -182,7 +182,7 @@ function banner(sourcePath, saving) {
|
|
|
182
182
|
return lines.join('\n');
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
function writeCache(filePath, markdown, extra) {
|
|
185
|
+
function writeCache(filePath, markdown, extra, { agent } = {}) {
|
|
186
186
|
ensureCacheDir();
|
|
187
187
|
const cacheFile = cachePathFor(filePath);
|
|
188
188
|
const src = fs.statSync(filePath);
|
|
@@ -197,7 +197,7 @@ function writeCache(filePath, markdown, extra) {
|
|
|
197
197
|
{ markdown },
|
|
198
198
|
);
|
|
199
199
|
fs.writeFileSync(cacheFile, banner(filePath, saving) + markdown, { encoding: 'utf8', mode: 0o600 });
|
|
200
|
-
ledger.recordConversion(userDataDir(), {
|
|
200
|
+
if (agent !== 'codex') ledger.recordConversion(userDataDir(), {
|
|
201
201
|
key: path.resolve(filePath),
|
|
202
202
|
ts: Date.now(),
|
|
203
203
|
usd: saving.usd,
|
|
@@ -447,7 +447,7 @@ function spawnFigConvert(fig2md, filePath) {
|
|
|
447
447
|
}
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
function convert(filePath, { converter = CONVERTER, python: pythonOverride = null } = {}) {
|
|
450
|
+
function convert(filePath, { converter = CONVERTER, python: pythonOverride = null, agent } = {}) {
|
|
451
451
|
if (!isTargetPath(filePath)) return { ok: false, reason: 'not-target' };
|
|
452
452
|
if (isSensitivePath(filePath)) return { ok: false, reason: 'sensitive' };
|
|
453
453
|
|
|
@@ -478,7 +478,7 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
|
|
|
478
478
|
if (!result.ok) return result;
|
|
479
479
|
const written = writeCache(filePath, result.markdown, {
|
|
480
480
|
note: result.note, truncated: false, rows: 0, pages: 0, markupBytes: 0,
|
|
481
|
-
});
|
|
481
|
+
}, { agent });
|
|
482
482
|
return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
|
|
483
483
|
}
|
|
484
484
|
|
|
@@ -537,7 +537,7 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
|
|
|
537
537
|
pages: payload.pages || 0,
|
|
538
538
|
markupBytes: payload.markup_bytes || 0,
|
|
539
539
|
clipped,
|
|
540
|
-
});
|
|
540
|
+
}, { agent });
|
|
541
541
|
return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
|
|
542
542
|
}
|
|
543
543
|
|
|
@@ -763,19 +763,38 @@ function decideForRead(context, opts = {}) {
|
|
|
763
763
|
* Quoted, `@`-prefixed and bare paths all count. Windows drive letters are
|
|
764
764
|
* matched too, since the rest of the module is path-agnostic.
|
|
765
765
|
*/
|
|
766
|
-
function documentPathsIn(text) {
|
|
766
|
+
function documentPathsIn(text, cwd = process.cwd()) {
|
|
767
767
|
if (typeof text !== 'string' || !text) return [];
|
|
768
768
|
const exts = TARGET_EXTENSIONS.map((e) => e.slice(1)).join('|');
|
|
769
769
|
// Backslashes count as path characters, not just separators to tolerate:
|
|
770
770
|
// `C:\\Users\\me\\deck.pptx` and `\\\\server\\share\\deck.pptx` are how
|
|
771
771
|
// Windows users write a path, and without them the scanner silently sees no
|
|
772
772
|
// documents at all on that platform.
|
|
773
|
-
|
|
773
|
+
//
|
|
774
|
+
// The extension may be followed by anything except more of a filename, so
|
|
775
|
+
// `deck.pptx를` and `(./a.pdf)` still count while `a.pdfx` does not. Korean
|
|
776
|
+
// attaches a particle straight to the path, and a whitelist of punctuation
|
|
777
|
+
// after the extension silently dropped every such prompt.
|
|
778
|
+
const tail = '(?![A-Za-z0-9_])';
|
|
779
|
+
const forms = [
|
|
780
|
+
// A path that starts like one (`~`, `.`, `/`, `\`, a drive letter), found
|
|
781
|
+
// anywhere in the text. This is the original scanner, kept whole so that a
|
|
782
|
+
// new form can only add paths, never lose one it used to find.
|
|
783
|
+
new RegExp(`((?:[A-Za-z]:)?[~./\\\\][^\\s'"\x60]*\\.(?:${exts}))${tail}`, 'gi'),
|
|
784
|
+
// A quoted path, which may contain spaces and needs no leading `./`.
|
|
785
|
+
new RegExp(`["'\x60]([^"'\x60]+\\.(?:${exts}))["'\x60]`, 'gi'),
|
|
786
|
+
// A bare name such as `book.pdf` or `docs/a.pdf`, starting a word. An
|
|
787
|
+
// opening bracket or an `@` is a word boundary, not part of the name.
|
|
788
|
+
new RegExp(`(?:^|[\\s@(\\[{<])([^\\s'"\x60()\\[\\]{}<>@][^\\s'"\x60]*\\.(?:${exts}))${tail}`, 'gi'),
|
|
789
|
+
];
|
|
774
790
|
const found = [];
|
|
775
|
-
for (const
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
791
|
+
for (const re of forms) {
|
|
792
|
+
for (const m of text.matchAll(re)) {
|
|
793
|
+
const raw = m[1];
|
|
794
|
+
if (raw.includes('://')) continue;
|
|
795
|
+
const abs = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(cwd, raw);
|
|
796
|
+
if (!found.includes(abs)) found.push(abs);
|
|
797
|
+
}
|
|
779
798
|
}
|
|
780
799
|
return found;
|
|
781
800
|
}
|
|
@@ -812,25 +831,30 @@ function conversionSummary(sourcePath, meta = {}) {
|
|
|
812
831
|
function contextForPrompt(payload, opts = {}) {
|
|
813
832
|
const lang = opts.lang === 'ko' ? 'ko' : 'en';
|
|
814
833
|
if (!payload || typeof payload.prompt !== 'string') return null;
|
|
815
|
-
const paths = documentPathsIn(payload.prompt).filter((p) => {
|
|
834
|
+
const paths = documentPathsIn(payload.prompt, payload.cwd || process.cwd()).filter((p) => {
|
|
816
835
|
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
817
836
|
});
|
|
818
837
|
if (paths.length === 0) return null;
|
|
819
838
|
|
|
820
839
|
const lines = [];
|
|
821
840
|
const targets = paths.slice(0, MAX_PROMPT_CONVERSIONS);
|
|
841
|
+
const { convert: convertFn = convert, ...convertOptions } = opts;
|
|
822
842
|
for (const p of targets) {
|
|
823
843
|
const name = path.basename(p);
|
|
824
|
-
const result =
|
|
844
|
+
const result = convertFn(p, convertOptions);
|
|
825
845
|
if (result.ok) {
|
|
826
846
|
lines.push(` ${name} → ${result.cacheFile}`);
|
|
827
|
-
lines.push(` ${conversionSummary(p, result.meta)}`);
|
|
847
|
+
lines.push(` ${conversionSummary(p, opts.agent === 'codex' ? { ...result.meta, savedUsd: 0 } : result.meta)}`);
|
|
828
848
|
if (result.meta && result.meta.note) lines.push(` ${result.meta.note}`);
|
|
829
|
-
if (result.meta
|
|
849
|
+
if (opts.agent === 'codex' && (result.meta?.clipped || result.meta?.truncated)) {
|
|
850
|
+
lines.push(' Text extraction is incomplete; inspect the source for omitted content.');
|
|
851
|
+
} else if (result.meta && result.meta.clipped) {
|
|
830
852
|
lines.push(' 변환 결과가 너무 커서 뒷부분을 잘랐습니다. 전체가 필요하면 원본을 직접 다루십시오.');
|
|
831
853
|
}
|
|
832
854
|
} else if (result.reason === 'no-markitdown') {
|
|
833
|
-
lines.push(
|
|
855
|
+
lines.push(opts.agent === 'codex'
|
|
856
|
+
? ` ${name}: converter unavailable. Run sprag doc2md install-converter --agent codex, then retry.`
|
|
857
|
+
: lang === 'ko'
|
|
834
858
|
? ` ${name}: 변환기를 설치하는 중입니다(첫 실행에만 걸립니다). 설치가 끝나면 다음 요청부터 자동 변환됩니다.`
|
|
835
859
|
: ` ${name}: the converter is installing now (first run only). It will convert automatically from the next request.`);
|
|
836
860
|
} else if (result.reason === 'drm-protected') {
|
|
@@ -865,6 +889,12 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
865
889
|
: ` (converted the first ${targets.length} of ${paths.length} documents; run \`sprag doc2md <path>\` for the rest.)`);
|
|
866
890
|
}
|
|
867
891
|
|
|
892
|
+
if (opts.agent === 'codex') return [
|
|
893
|
+
'[doc2md] Document conversion results:', ...lines,
|
|
894
|
+
'Read each successfully converted Markdown file instead of extracting the original document again.',
|
|
895
|
+
'Report conversion failures accurately. For successful conversions, mention the source and Markdown sizes.',
|
|
896
|
+
'For other documents, run sprag doc2md <path> --agent codex. Text extraction does not verify images or layout.',
|
|
897
|
+
].join('\n');
|
|
868
898
|
return lang === 'ko'
|
|
869
899
|
? [
|
|
870
900
|
'[doc2md] 이 프롬프트에 문서 경로가 있어 Markdown 으로 변환해 두었습니다.',
|
|
@@ -919,12 +949,13 @@ function sessionNote(lang = 'en') {
|
|
|
919
949
|
* .fig overwritten with text is destroyed, not edited. Nothing this tool
|
|
920
950
|
* ships can write those formats back.
|
|
921
951
|
*/
|
|
922
|
-
function decideForWrite(context) {
|
|
952
|
+
function decideForWrite(context, opts = {}) {
|
|
923
953
|
if (!context || (context.tool_name !== 'Edit' && context.tool_name !== 'Write')) return null;
|
|
924
954
|
const toolInput = context.tool_input;
|
|
925
955
|
const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
|
|
926
956
|
if (!filePath) return null;
|
|
927
957
|
const abs = path.resolve(filePath);
|
|
958
|
+
const codex = opts.agent === 'codex';
|
|
928
959
|
|
|
929
960
|
if (abs.startsWith(cacheDir() + path.sep)) {
|
|
930
961
|
let source = null;
|
|
@@ -933,23 +964,36 @@ function decideForWrite(context) {
|
|
|
933
964
|
} catch { /* the deny stands on its own */ }
|
|
934
965
|
return {
|
|
935
966
|
deny: true,
|
|
936
|
-
reason:
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
967
|
+
reason: codex
|
|
968
|
+
? '[doc2md] This file is a conversion cache. Editing it changes nothing in the source document; '
|
|
969
|
+
+ 'it only corrupts the cache, which keeps being served on every later read.\n'
|
|
970
|
+
+ (source ? ` Source: ${source}\n` : '')
|
|
971
|
+
+ ' To modify the document instead: copy the original, then edit the copy with a script. '
|
|
972
|
+
+ `pptx/docx/xlsx have python-pptx/python-docx/openpyxl installed under ${managedPython()}, `
|
|
973
|
+
+ "and .fig edits go through doc2md-fig's openfig-core. "
|
|
974
|
+
+ 'After editing, reconvert the copy with `sprag doc2md <copy> --agent codex` to verify the change landed. Never edit the original directly.'
|
|
975
|
+
: '[doc2md] 이 파일은 변환 캐시입니다. 여기를 고쳐도 원본 문서에는 아무것도 반영되지 않고, '
|
|
976
|
+
+ '캐시만 오염된 채 다음 읽기부터 계속 서빙됩니다.\n'
|
|
977
|
+
+ (source ? ` 원본: ${source}\n` : '')
|
|
978
|
+
+ ' 문서 수정이 목적이라면: 원본을 복사한 뒤(cp) 복사본을 스크립트로 수정하십시오. '
|
|
979
|
+
+ `pptx·docx·xlsx 는 ${managedPython()} 에 python-pptx·python-docx·openpyxl 이 설치되어 있고, `
|
|
980
|
+
+ '.fig 는 doc2md-fig 의 openfig-core 로 편집·재인코드할 수 있습니다. '
|
|
981
|
+
+ '수정 후 복사본을 `sprag doc2md <복사본>` 으로 재변환해 의도한 변경이 들어갔는지 확인하십시오. 원본은 절대 직접 수정하지 마십시오.',
|
|
943
982
|
};
|
|
944
983
|
}
|
|
945
984
|
|
|
946
985
|
if (isTargetPath(abs)) {
|
|
947
986
|
return {
|
|
948
987
|
deny: true,
|
|
949
|
-
reason:
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
988
|
+
reason: codex
|
|
989
|
+
? `[doc2md] ${path.basename(abs)} is a binary document. Edit/Write emit text, so this call would destroy it. `
|
|
990
|
+
+ 'To modify it, copy the original, then edit the copy with a script. '
|
|
991
|
+
+ `pptx/docx/xlsx use python-pptx/python-docx/openpyxl under ${managedPython()}, and .fig uses openfig-core. `
|
|
992
|
+
+ 'After editing, reconvert with `sprag doc2md <copy> --agent codex` to verify the change landed.'
|
|
993
|
+
: `[doc2md] ${path.basename(abs)} 는 이진 문서입니다. Edit/Write 는 텍스트를 쓰므로 이 파일을 파괴합니다. `
|
|
994
|
+
+ '수정하려면 원본을 복사한 뒤(cp) 복사본을 스크립트로 고치십시오. '
|
|
995
|
+
+ `pptx·docx·xlsx 는 ${managedPython()} 의 python-pptx·python-docx·openpyxl, .fig 는 openfig-core 를 쓰고, `
|
|
996
|
+
+ '수정 후 `sprag doc2md <복사본>` 재변환으로 결과를 검증하십시오.',
|
|
953
997
|
};
|
|
954
998
|
}
|
|
955
999
|
return null;
|
package/src/handoff.js
CHANGED
|
@@ -66,11 +66,11 @@ function pickPath(cwd, now) {
|
|
|
66
66
|
return join(cwd, `${stem}-${Date.now()}.md`);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function renderTemplate({ now, cwd, git, caps }) {
|
|
69
|
+
function renderTemplate({ now, cwd, git, caps, agent, session }) {
|
|
70
70
|
const lines = [];
|
|
71
71
|
lines.push(`# Handoff — ${ymd(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`);
|
|
72
72
|
lines.push('');
|
|
73
|
-
lines.push(`Generated by \`sprag handoff\`.`);
|
|
73
|
+
lines.push(`Generated by \`sprag handoff${agent === 'codex' ? ' --agent codex' : ''}\`.`);
|
|
74
74
|
lines.push('');
|
|
75
75
|
lines.push('## Context');
|
|
76
76
|
lines.push('');
|
|
@@ -92,7 +92,27 @@ function renderTemplate({ now, cwd, git, caps }) {
|
|
|
92
92
|
|
|
93
93
|
lines.push('## Cap snapshot');
|
|
94
94
|
lines.push('');
|
|
95
|
-
if (
|
|
95
|
+
if (agent === 'codex') {
|
|
96
|
+
lines.push('- Source: recorded Codex rollout, not a live quota or billing response.');
|
|
97
|
+
if (session) {
|
|
98
|
+
lines.push(`- Session: ${JSON.stringify(session.sessionId)}`);
|
|
99
|
+
lines.push(`- Model: ${JSON.stringify(session.model)}; effort: ${JSON.stringify(session.effort)}`);
|
|
100
|
+
lines.push(`- Last activity: ${session.lastActivity ? new Date(session.lastActivity).toISOString() : 'unknown'}`);
|
|
101
|
+
lines.push(`- Recorded input context: ${session.lastContextTokens ?? 'unknown'} / ${session.contextWindow ?? 'unknown'} tokens`);
|
|
102
|
+
lines.push(`- Tokens: input ${session.totals.input + session.totals.cacheRead}, cached ${session.totals.cacheRead}, output ${session.totals.output}`);
|
|
103
|
+
lines.push(`- Limits snapshot: ${session.rateLimitsAt ? new Date(session.rateLimitsAt).toISOString() : 'not reported'}`);
|
|
104
|
+
for (const key of ['primary', 'secondary']) {
|
|
105
|
+
const win = session.rateLimits?.[key];
|
|
106
|
+
if (!Number.isFinite(win?.used_percent)) continue;
|
|
107
|
+
const reset = Number.isFinite(win.resets_at) ? win.resets_at * 1000 : null;
|
|
108
|
+
const resetDate = reset === null ? null : new Date(reset);
|
|
109
|
+
const resetText = resetDate && Number.isFinite(resetDate.getTime())
|
|
110
|
+
? reset <= now.getTime() ? 'awaiting new usage' : `reset at ${resetDate.toISOString()}` : 'reset unknown';
|
|
111
|
+
lines.push(`- ${key}: ${win.used_percent}% used (${resetText})`);
|
|
112
|
+
}
|
|
113
|
+
} else lines.push('- No matching Codex session log.');
|
|
114
|
+
lines.push('- Cost and cache TTL: unavailable.');
|
|
115
|
+
} else if (caps && Array.isArray(caps.windows) && caps.windows.length > 0) {
|
|
96
116
|
for (const win of caps.windows) {
|
|
97
117
|
const label = labelForKey(win.key).long;
|
|
98
118
|
if (!Number.isFinite(win.usedPct)) {
|
|
@@ -132,7 +152,7 @@ function renderTemplate({ now, cwd, git, caps }) {
|
|
|
132
152
|
lines.push('- _(non-obvious gotchas, half-finished refactors, failing tests)_');
|
|
133
153
|
lines.push('');
|
|
134
154
|
|
|
135
|
-
lines.push(
|
|
155
|
+
lines.push(`## Resume prompt for the next ${agent === 'codex' ? 'Codex' : 'Claude Code'} session`);
|
|
136
156
|
lines.push('');
|
|
137
157
|
lines.push('```');
|
|
138
158
|
lines.push('Read the most recent HANDOFF-*.md in this directory and continue the work.');
|
|
@@ -151,11 +171,11 @@ function renderTemplate({ now, cwd, git, caps }) {
|
|
|
151
171
|
* @param {Date} [opts.now=new Date()]
|
|
152
172
|
* @returns {{ path: string, git: { branch: string, head: string, status: string } | null }}
|
|
153
173
|
*/
|
|
154
|
-
export function writeHandoff({ cwd = process.cwd(), caps = null, now = new Date() } = {}) {
|
|
174
|
+
export function writeHandoff({ cwd = process.cwd(), caps = null, now = new Date(), agent = 'claude', session = null } = {}) {
|
|
155
175
|
const absCwd = resolve(cwd);
|
|
156
176
|
const git = gitSnapshot(absCwd);
|
|
157
177
|
const path = pickPath(absCwd, now);
|
|
158
|
-
const body = renderTemplate({ now, cwd: absCwd, git, caps });
|
|
159
|
-
writeFileSync(path, body);
|
|
178
|
+
const body = renderTemplate({ now, cwd: absCwd, git, caps, agent, session });
|
|
179
|
+
writeFileSync(path, body, { flag: 'wx' });
|
|
160
180
|
return { path, git };
|
|
161
181
|
}
|
package/src/harness-templates.js
CHANGED
|
@@ -40,6 +40,38 @@ export function modelRatchetImportLine(scope = 'project') {
|
|
|
40
40
|
return scope === 'global' ? '@~/.claude/ratchet-model.md' : '@.claude/ratchet-model.md';
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The preset ratchet: the bundled rules the user accepted globally. It is a
|
|
45
|
+
* tool-owned file like ratchet-model.md, regenerated whole from the recorded
|
|
46
|
+
* answers, so an upgrade can reword, add or drop presets without touching the
|
|
47
|
+
* rules the user wrote into ratchet.md. Presets are global-only, so only the
|
|
48
|
+
* global block imports it.
|
|
49
|
+
*/
|
|
50
|
+
export function presetRatchetImportLine() {
|
|
51
|
+
return '@~/.claude/ratchet-preset.md';
|
|
52
|
+
}
|
|
53
|
+
export const PRESET_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-preset\.md\s*$/m;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render ratchet-preset.md. `rules` is [{ id, text }] in display order. The
|
|
57
|
+
* file is loaded into every session, so the header stays short.
|
|
58
|
+
*/
|
|
59
|
+
export function renderPresetRatchet(rules, { lang = 'en', agent = 'claude' } = {}) {
|
|
60
|
+
const ko = lang === 'ko' && agent !== 'codex';
|
|
61
|
+
const suffix = agent === 'codex' ? ' --agent codex' : '';
|
|
62
|
+
const head = ko
|
|
63
|
+
? `# Preset Ratchet Rules (sprag 관리)
|
|
64
|
+
|
|
65
|
+
sprag 에 동봉된 추천 룰 가운데 전역으로 등록한 것입니다. sprag 가 이 파일을 통째로 다시 쓰므로
|
|
66
|
+
직접 고치지 마십시오. 하나를 빼려면 \`sprag seed skip <id>${suffix}\`, 직접 쓰는 룰은 ratchet.md 에 둡니다.`
|
|
67
|
+
: `# Preset Ratchet Rules (managed by sprag)
|
|
68
|
+
|
|
69
|
+
Bundled rules you registered globally. Sprag rewrites this file whole, so do not
|
|
70
|
+
edit it. Drop one with \`sprag seed skip <id>${suffix}\`; your own rules belong in ratchet.md.`;
|
|
71
|
+
const body = rules.map((r) => `- [${r.id}] ${r.text}`).join('\n');
|
|
72
|
+
return `${head}\n\n## Rules\n\n${body}${body ? '\n' : ''}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
43
75
|
// Matches either scope's import line, so completeness checks don't need to know
|
|
44
76
|
// which scope wrote the block. `(?!-)` keeps the ratchet-model import from
|
|
45
77
|
// counting as the ratchet.md one.
|
|
@@ -118,7 +150,7 @@ ${HARNESS_SECTIONS[4].heading}
|
|
|
118
150
|
📥 ratchet 룰 로드 (이 줄들을 지우면 룰이 적용되지 않습니다):
|
|
119
151
|
|
|
120
152
|
${ratchetImportLine(scope)}
|
|
121
|
-
${modelRatchetImportLine(scope)}
|
|
153
|
+
${modelRatchetImportLine(scope)}${scope === 'global' ? `\n${presetRatchetImportLine()}` : ''}
|
|
122
154
|
${HARNESS_BLOCK_END}
|
|
123
155
|
`;
|
|
124
156
|
}
|