sprag-cli 3.53.0 → 3.54.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 +46 -3
- package/README.md +50 -4
- package/bin/cli.js +26 -3
- package/docs/CODEX.ko.md +172 -0
- package/docs/CODEX.md +485 -0
- package/package.json +11 -3
- 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 +131 -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-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 +557 -0
- package/src/commands/seed.js +11 -10
- package/src/doc2md.cjs +72 -28
- package/src/handoff.js +27 -7
- package/src/harness.js +26 -17
- package/src/seed-rules.js +58 -34
- package/src/update-check.js +37 -2
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.js
CHANGED
|
@@ -26,6 +26,7 @@ import { routeWarningForStatusline } from './route-scan.js';
|
|
|
26
26
|
import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet } from './model-rules.js';
|
|
27
27
|
import { compactWindowWarningForStatusline } from './compact-window.js';
|
|
28
28
|
import { userLanguage } from './config.js';
|
|
29
|
+
import { codexUserDir } from './agent.js';
|
|
29
30
|
|
|
30
31
|
const require = createRequire(import.meta.url);
|
|
31
32
|
function readHarnessState() {
|
|
@@ -42,11 +43,13 @@ function readHarnessState() {
|
|
|
42
43
|
* or package.json). Falls back to `start` itself so harness commands always
|
|
43
44
|
* have *some* directory to write into, even outside a repo.
|
|
44
45
|
*/
|
|
45
|
-
export function findProjectRoot(start = process.cwd()) {
|
|
46
|
+
export function findProjectRoot(start = process.cwd(), { agent = 'claude' } = {}) {
|
|
46
47
|
let dir = resolve(start);
|
|
47
48
|
for (;;) {
|
|
48
49
|
if (
|
|
49
|
-
|
|
50
|
+
(agent === 'codex'
|
|
51
|
+
? existsSync(join(dir, 'AGENTS.md')) || existsSync(join(dir, 'AGENTS.override.md'))
|
|
52
|
+
: existsSync(join(dir, 'CLAUDE.md'))) ||
|
|
50
53
|
existsSync(join(dir, '.git')) ||
|
|
51
54
|
existsSync(join(dir, 'package.json'))
|
|
52
55
|
) {
|
|
@@ -70,10 +73,15 @@ function globalRatchetMdPath() {
|
|
|
70
73
|
return join(homedir(), '.claude', 'ratchet.md');
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
function resolveRatchetPath(scope, root) {
|
|
76
|
+
function resolveRatchetPath(scope, root, agent = 'claude') {
|
|
77
|
+
if (agent === 'codex') return scope === 'global' ? join(codexUserDir(), 'ratchet.md') : join(root, '.codex', 'ratchet.md');
|
|
74
78
|
return scope === 'global' ? globalRatchetMdPath() : ratchetMdPath(root);
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
function initialRatchet(agent) {
|
|
82
|
+
return agent === 'codex' ? '# Codex Ratchet Rules\n\n## Rules\n\n' : harnessRatchetMdInitial();
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
// Global harness lives in ~/.claude/CLAUDE.md — Claude Code loads this for every
|
|
78
86
|
// project, so a global init makes the 5 harness sections apply everywhere
|
|
79
87
|
// (mirrors the project/global split that ratchet.md already has).
|
|
@@ -312,14 +320,14 @@ export function harnessUninit({ root = findProjectRoot(), purgeRatchet = false,
|
|
|
312
320
|
* harness promote — append a one-line rule to .claude/ratchet.md.
|
|
313
321
|
* Creates the file from the initial template if missing.
|
|
314
322
|
*/
|
|
315
|
-
export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'project' } = {}) {
|
|
316
|
-
const rmPath = resolveRatchetPath(scope, root);
|
|
323
|
+
export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'project', agent = 'claude' } = {}) {
|
|
324
|
+
const rmPath = resolveRatchetPath(scope, root, agent);
|
|
317
325
|
let existing = '';
|
|
318
326
|
if (existsSync(rmPath)) {
|
|
319
327
|
existing = readFileSync(rmPath, 'utf8');
|
|
320
328
|
} else {
|
|
321
329
|
mkdirSync(dirname(rmPath), { recursive: true });
|
|
322
|
-
existing =
|
|
330
|
+
existing = initialRatchet(agent);
|
|
323
331
|
}
|
|
324
332
|
const next = appendRatchetRule(existing, ruleText);
|
|
325
333
|
writeFileSync(rmPath, next);
|
|
@@ -339,17 +347,17 @@ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'pr
|
|
|
339
347
|
* Deduped by rule text (ignoring the YYYY-MM-DD stamp) — idempotent.
|
|
340
348
|
* Returns { path, scope, added, skippedRules, presets }.
|
|
341
349
|
*/
|
|
342
|
-
export function harnessPull({ root = findProjectRoot(), scope = 'global' } = {}) {
|
|
350
|
+
export function harnessPull({ root = findProjectRoot(), scope = 'global', agent = 'claude' } = {}) {
|
|
343
351
|
const presets = presetRules();
|
|
344
|
-
const rmPath = resolveRatchetPath(scope, root);
|
|
352
|
+
const rmPath = resolveRatchetPath(scope, root, agent);
|
|
345
353
|
const result = { path: rmPath, scope, added: [], skippedRules: 0, presets: presets.length };
|
|
346
354
|
const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}:\s*/, '').trim();
|
|
347
355
|
|
|
348
356
|
let content = existsSync(rmPath)
|
|
349
357
|
? readFileSync(rmPath, 'utf8')
|
|
350
|
-
:
|
|
358
|
+
: initialRatchet(agent);
|
|
351
359
|
const have = new Set(
|
|
352
|
-
harnessListRules({ root, scope }).rules.map((r) => stripDate(r.text)),
|
|
360
|
+
harnessListRules({ root, scope, agent }).rules.map((r) => stripDate(r.text)),
|
|
353
361
|
);
|
|
354
362
|
for (const rule of presets) {
|
|
355
363
|
if (have.has(rule)) {
|
|
@@ -385,7 +393,8 @@ export function presetRuleEntries() {
|
|
|
385
393
|
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
386
394
|
return (Array.isArray(data.rules) ? data.rules : [])
|
|
387
395
|
.filter((r) => r && typeof r.ko === 'string' && r.ko.trim())
|
|
388
|
-
.map((r) => ({ ko: r.ko.trim(), en: (r.en || r.ko).trim()
|
|
396
|
+
.map((r) => ({ ko: r.ko.trim(), en: (r.en || r.ko).trim(),
|
|
397
|
+
...(Array.isArray(r.agents) ? { agents: r.agents.filter((a) => typeof a === 'string') } : {}) }));
|
|
389
398
|
} catch {
|
|
390
399
|
return [];
|
|
391
400
|
}
|
|
@@ -400,8 +409,8 @@ export function presetRules(lang = userLanguage()) {
|
|
|
400
409
|
* harness list — return numbered ratchet rules from .claude/ratchet.md.
|
|
401
410
|
* Numbering is 1-based and matches `harness rm <N>`.
|
|
402
411
|
*/
|
|
403
|
-
export function harnessListRules({ root = findProjectRoot(), scope = 'project' } = {}) {
|
|
404
|
-
const rmPath = resolveRatchetPath(scope, root);
|
|
412
|
+
export function harnessListRules({ root = findProjectRoot(), scope = 'project', agent = 'claude' } = {}) {
|
|
413
|
+
const rmPath = resolveRatchetPath(scope, root, agent);
|
|
405
414
|
if (!existsSync(rmPath)) return { path: rmPath, rules: [] };
|
|
406
415
|
const lines = readFileSync(rmPath, 'utf8').split('\n');
|
|
407
416
|
const rules = [];
|
|
@@ -482,8 +491,8 @@ export function contextWeightStatus({ root = findProjectRoot() } = {}) {
|
|
|
482
491
|
* it. Selection is by tag and/or age; nothing is deleted, so a pruned rule can
|
|
483
492
|
* be pasted back. Returns the pruned rules for the CLI to echo.
|
|
484
493
|
*/
|
|
485
|
-
export function harnessPrune({ root = findProjectRoot(), scope = 'project', tag = null, olderThanMonths = null, dryRun = false } = {}) {
|
|
486
|
-
const { path: rmPath, rules } = harnessListRules({ root, scope });
|
|
494
|
+
export function harnessPrune({ root = findProjectRoot(), scope = 'project', agent = 'claude', tag = null, olderThanMonths = null, dryRun = false } = {}) {
|
|
495
|
+
const { path: rmPath, rules } = harnessListRules({ root, scope, agent });
|
|
487
496
|
if (!existsSync(rmPath)) return { ok: false, error: `ratchet.md not found at ${rmPath}` };
|
|
488
497
|
if (!tag && !olderThanMonths) return { ok: false, error: 'Nothing selected — pass --tag <t> and/or --older-than <months>' };
|
|
489
498
|
const cutoff = olderThanMonths ? Date.now() - olderThanMonths * 30 * 24 * 60 * 60 * 1000 : null;
|
|
@@ -519,8 +528,8 @@ export function harnessPrune({ root = findProjectRoot(), scope = 'project', tag
|
|
|
519
528
|
* value is one-way accumulation; deleting should feel deliberate. The CLI
|
|
520
529
|
* surfaces a "narrow the condition instead" reminder around this call.
|
|
521
530
|
*/
|
|
522
|
-
export function harnessRmRule(n, { root = findProjectRoot(), scope = 'project' } = {}) {
|
|
523
|
-
const { path: rmPath, rules } = harnessListRules({ root, scope });
|
|
531
|
+
export function harnessRmRule(n, { root = findProjectRoot(), scope = 'project', agent = 'claude' } = {}) {
|
|
532
|
+
const { path: rmPath, rules } = harnessListRules({ root, scope, agent });
|
|
524
533
|
if (!existsSync(rmPath)) {
|
|
525
534
|
return { ok: false, error: `ratchet.md not found at ${rmPath}` };
|
|
526
535
|
}
|
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,15 @@ 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
|
-
const res = harnessPromote(seed.ruleText, { root, scope });
|
|
171
|
-
recordDecision(id, 'accepted', { scope });
|
|
180
|
+
const res = harnessPromote(seed.ruleText, { root, scope, agent });
|
|
181
|
+
recordDecision(id, 'accepted', { scope }, { agent });
|
|
172
182
|
return { id, kind: 'ratchet', scope, path: res.path, rule: seed.ruleText };
|
|
173
183
|
}
|
|
174
184
|
|
|
@@ -216,15 +226,15 @@ export async function acceptSeed(id, { scope = 'global', root = findProjectRoot(
|
|
|
216
226
|
export function skipSeed(id, opts = {}) {
|
|
217
227
|
const seed = findSeed(id, opts);
|
|
218
228
|
if (!seed) return null;
|
|
219
|
-
recordDecision(id, 'skipped');
|
|
229
|
+
recordDecision(id, 'skipped', {}, { agent: opts.agent });
|
|
220
230
|
return { id, kind: seed.kind, rule: seed.ruleText };
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
/** Forget every recorded answer — the full set becomes pending again. */
|
|
224
|
-
export function resetSeeds() {
|
|
225
|
-
const state = loadSeedState();
|
|
234
|
+
export function resetSeeds({ agent = 'claude' } = {}) {
|
|
235
|
+
const state = loadSeedState({ agent });
|
|
226
236
|
const count = Object.keys(state.decided).length;
|
|
227
|
-
saveSeedState({ decided: {} });
|
|
237
|
+
saveSeedState({ decided: {} }, { agent });
|
|
228
238
|
return count;
|
|
229
239
|
}
|
|
230
240
|
|
|
@@ -236,9 +246,23 @@ export function resetSeeds() {
|
|
|
236
246
|
* asked for, and because a single "register all of these?" question invites a
|
|
237
247
|
* yes to rules the user never read.
|
|
238
248
|
*/
|
|
239
|
-
export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot() } = {}) {
|
|
240
|
-
const pending = pendingSeeds({ lang, root });
|
|
249
|
+
export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot(), agent = 'claude' } = {}) {
|
|
250
|
+
const pending = pendingSeeds({ lang, root, agent });
|
|
241
251
|
if (pending.length === 0) return null;
|
|
252
|
+
if (agent === 'codex') {
|
|
253
|
+
// Codex hook text stays English; the rule text itself follows the user's language.
|
|
254
|
+
const cmd = (s) => `${CLI_NAME} seed ${s} --agent codex`;
|
|
255
|
+
return [
|
|
256
|
+
`[Sprag seed] ${pending.length} recommended Codex ratchet rule(s) from the bundled presets are not registered yet.`,
|
|
257
|
+
'Ask in your first reply, after answering any request the user opened with. Offer these choices in this order:',
|
|
258
|
+
` 1. Register all, globally: ${cmd('accept all --global')}`,
|
|
259
|
+
` 2. Register all, this project only: ${cmd('accept all --project')}`,
|
|
260
|
+
' 3. Decide one at a time: ask about each rule below and run its command as soon as they answer',
|
|
261
|
+
` 4. Register none: ${cmd('skip all')} (never offered again)`,
|
|
262
|
+
'Always confirm the scope with the user before running an accept command.',
|
|
263
|
+
...pending.map((s) => ` [${s.id}] ${s.ruleText}`),
|
|
264
|
+
].join('\n');
|
|
265
|
+
}
|
|
242
266
|
const models = pending.filter((s) => s.kind === 'model');
|
|
243
267
|
const fixes = pending.filter((s) => s.kind === 'ratchet');
|
|
244
268
|
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
|
});
|