sprag-cli 3.52.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.
Files changed (42) hide show
  1. package/README.ko.md +46 -3
  2. package/README.md +50 -4
  3. package/bin/cli.js +35 -4
  4. package/docs/CODEX.ko.md +172 -0
  5. package/docs/CODEX.md +485 -0
  6. package/package.json +11 -3
  7. package/presets/delegation/bounds.md +29 -0
  8. package/presets/ratchet-rules.json +4 -1
  9. package/src/agent.js +33 -0
  10. package/src/codex-brief.js +89 -0
  11. package/src/codex-budget.js +73 -0
  12. package/src/codex-cache-policy.js +203 -0
  13. package/src/codex-cache.js +46 -0
  14. package/src/codex-delegation.js +154 -0
  15. package/src/codex-doc2md-ledger.js +45 -0
  16. package/src/codex-doc2md.js +99 -0
  17. package/src/codex-doctor.js +53 -0
  18. package/src/codex-harness.js +200 -0
  19. package/src/codex-hooks.js +131 -0
  20. package/src/codex-installer.js +79 -0
  21. package/src/codex-ledger.js +143 -0
  22. package/src/codex-panel-launcher.js +48 -0
  23. package/src/codex-panel-runner.js +74 -0
  24. package/src/codex-panel-session.js +35 -0
  25. package/src/codex-panel-shell.js +51 -0
  26. package/src/codex-panel-state.js +40 -0
  27. package/src/codex-panel.js +448 -0
  28. package/src/codex-parser.js +397 -0
  29. package/src/codex-route-scan.js +171 -0
  30. package/src/commands/brief.js +8 -1
  31. package/src/commands/codex-delegate.js +59 -0
  32. package/src/commands/codex.js +557 -0
  33. package/src/commands/delegate.js +133 -0
  34. package/src/commands/seed.js +11 -10
  35. package/src/delegation-guard.js +278 -0
  36. package/src/doc2md.cjs +72 -28
  37. package/src/handoff.js +27 -7
  38. package/src/harness.js +26 -17
  39. package/src/installer.js +168 -0
  40. package/src/seed-rules.js +58 -34
  41. package/src/session-paths.js +242 -0
  42. 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
- const re = new RegExp(`[@'"\`]?((?:[A-Za-z]:)?[~./\\\\][^\\s'"\`]*\\.(?:${exts}))`, 'gi');
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 m of text.matchAll(re)) {
776
- const raw = m[1];
777
- const abs = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(raw);
778
- if (!found.includes(abs)) found.push(abs);
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 = convert(p, opts);
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 && result.meta.clipped) {
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(lang === 'ko'
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: '[doc2md] 이 파일은 변환 캐시입니다. 여기를 고쳐도 원본 문서에는 아무것도 반영되지 않고, '
937
- + '캐시만 오염된 채 다음 읽기부터 계속 서빙됩니다.\n'
938
- + (source ? ` 원본: ${source}\n` : '')
939
- + ' 문서 수정이 목적이라면: 원본을 복사한 뒤(cp) 복사본을 스크립트로 수정하십시오. '
940
- + `pptx·docx·xlsx 는 ${managedPython()} 에 python-pptx·python-docx·openpyxl 이 설치되어 있고, `
941
- + '.fig 는 doc2md-fig 의 openfig-core 로 편집·재인코드할 수 있습니다. '
942
- + '수정 후 복사본을 `sprag doc2md <복사본>` 으로 재변환해 의도한 변경이 들어갔는지 확인하십시오. 원본은 절대 직접 수정하지 마십시오.',
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: `[doc2md] ${path.basename(abs)} 는 이진 문서입니다. Edit/Write 는 텍스트를 쓰므로 이 파일을 파괴합니다. `
950
- + '수정하려면 원본을 복사한 뒤(cp) 복사본을 스크립트로 고치십시오. '
951
- + `pptx·docx·xlsx 는 ${managedPython()} 의 python-pptx·python-docx·openpyxl, .fig 는 openfig-core 를 쓰고, `
952
- + '수정 후 `sprag doc2md <복사본>` 재변환으로 결과를 검증하십시오.',
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 (caps && Array.isArray(caps.windows) && caps.windows.length > 0) {
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('## Resume prompt for the next Claude Code session');
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
- existsSync(join(dir, 'CLAUDE.md')) ||
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 = harnessRatchetMdInitial();
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
- : harnessRatchetMdInitial();
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/installer.js CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync } from 'node:fs';
17
+ import { loadConfig, saveConfig } from './config.js';
17
18
  import { CLI_NAME, LEGACY_CLI_NAME } from './cli-name.js';
18
19
  import { join } from 'node:path';
19
20
  import { claudeUserDir, userDataDir } from './paths.js';
@@ -602,6 +603,154 @@ export function removeDoc2mdHook() {
602
603
  return { path: file, action: 'removed' };
603
604
  }
604
605
 
606
+ // Registers the PreToolUse hook that rewrites Task/Agent delegation prompts
607
+ // (see delegation-guard.js for what it appends and why). Matches on both tool
608
+ // names because Claude Code's own docs and its runtime disagree on which one
609
+ // fires — cheaper to catch both than to find out which build is running.
610
+ //
611
+ // timeout: 10 — this hook reads a transcript TAIL (4 MiB; see TAIL_BYTES in
612
+ // session-paths.js, measured at 11.5ms on a real 4.65MB session) and two small
613
+ // preset files, nowhere near doc2md's markitdown cold-import cost, so a short
614
+ // timeout is safe and catches a hung read fast. The size lives in that one
615
+ // constant; quoting a number here is only ever a stale copy of it.
616
+ //
617
+ // Installed by `delegate on`, removed by `delegate off`. Idempotent. Not part
618
+ // of `installAll()` — this feature rewrites tool input on every delegation,
619
+ // which is exactly the kind of silent-by-default behavior install.js's other
620
+ // opt-in features (doc2md, korean, cohesion) avoid shipping without being asked.
621
+ const DELEGATION_GUARD_HOOK_COMMAND = `${CLI} delegate --hook`;
622
+ // One pattern, shared by install, remove and the status readout. Three
623
+ // hand-written variants drifted apart once already in this file: a substring
624
+ // test confused `--hook` with `--hook-delegated`. `(?![\\w-])` rejects both a
625
+ // hyphen and a word character, so `--hookfoo` is not mistaken for this hook.
626
+ // Not exported. Every consumer in this file goes through the wrapper below, and
627
+ // handing the bare pattern out would let a caller outside repeat the judgement
628
+ // this file just narrowed: the flag alone, with isOurCommand() skipped.
629
+ const DELEGATION_GUARD_HOOK_PATTERN = /delegate --hook(?![\w-])/;
630
+
631
+ /**
632
+ * Whether a settings.json hook command is THIS hook: both our executable in
633
+ * the command position and our flag.
634
+ *
635
+ * The flag alone is not enough. It says nothing about which program runs, so
636
+ * a hook the user registered themselves — `other-tool delegate --hook`, or a
637
+ * script of their own named `delegate` — matched, and `delegate off` deleted
638
+ * it. This repository has already shipped that bug once: CHANGELOG v3.46.1
639
+ * records `uninstall` removing `node ~/.claude/probe/sprag-probe.mjs` and
640
+ * `my-sprag-wrapper` because the check looked at the wrong part of the
641
+ * command. isOurCommand() is the fix that landed then, and it belongs here
642
+ * too. Install, remove and the status readout all go through this.
643
+ */
644
+ export function isDelegationGuardHookCommand(command) {
645
+ return typeof command === 'string'
646
+ && isOurCommand(command)
647
+ && DELEGATION_GUARD_HOOK_PATTERN.test(command);
648
+ }
649
+
650
+ // Named for the feature, not the flag: `installDelegationHook()` above is
651
+ // already taken by the route-scan PostToolUse hook, and it rewrites its own
652
+ // matcher as the same 'Task|Agent' string. A one-word difference between the
653
+ // two names (Delegate vs Delegation) once caused a call to the wrong hook to
654
+ // go unnoticed, so this pair spells out which concern it belongs to
655
+ // (delegation-guard.js's prompt rewrite) rather than staying terse.
656
+ /**
657
+ * Whether this hook is registered, and when it is not, why — for the status
658
+ * readout, which otherwise reported "not registered" identically for a
659
+ * settings.json that has no hook and one this tool refused to touch. Only the
660
+ * second of those is something the user has to fix, and only this carries the
661
+ * reason out to them. Install and remove read and write the same file, so they
662
+ * keep their own traversal; what is shared is the predicate.
663
+ */
664
+ export function delegationGuardHookState() {
665
+ const file = join(claudeUserDir(), 'settings.json');
666
+ if (!existsSync(file)) return { registered: false, path: file };
667
+ let settings;
668
+ try {
669
+ settings = JSON.parse(readFileSync(file, 'utf8'));
670
+ } catch (e) {
671
+ return { registered: false, path: file, reason: `unreadable JSON (${e.message})` };
672
+ }
673
+ const list = settings?.hooks?.PreToolUse;
674
+ if (list === undefined) return { registered: false, path: file };
675
+ if (!Array.isArray(list)) {
676
+ return { registered: false, path: file, reason: 'hooks.PreToolUse is not an array — fix settings.json manually' };
677
+ }
678
+ return {
679
+ registered: list.some((m) => Array.isArray(m?.hooks) && m.hooks.some((h) => isDelegationGuardHookCommand(h?.command))),
680
+ path: file,
681
+ };
682
+ }
683
+
684
+ export function installDelegationGuardHook() {
685
+ const dir = claudeUserDir();
686
+ const file = join(dir, 'settings.json');
687
+ mkdirSync(dir, { recursive: true });
688
+ // Remembered before the write: appending an entry to a settings file that
689
+ // was already there is an update, and reporting 'created' would tell the
690
+ // user their file is new when it is not.
691
+ const existed = existsSync(file);
692
+
693
+ let settings = {};
694
+ if (existed) {
695
+ try {
696
+ settings = JSON.parse(readFileSync(file, 'utf8'));
697
+ } catch (e) {
698
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
699
+ }
700
+ }
701
+
702
+ settings.hooks = settings.hooks || {};
703
+ if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
704
+ return { path: file, action: 'skipped', reason: 'hooks.PreToolUse is not an array — fix settings.json manually' };
705
+ }
706
+ const list = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
707
+ const already = list.some((m) =>
708
+ Array.isArray(m?.hooks) && m.hooks.some((h) => isDelegationGuardHookCommand(h?.command)),
709
+ );
710
+ if (already) return { path: file, action: 'exists' };
711
+
712
+ list.push({
713
+ matcher: 'Task|Agent',
714
+ hooks: [{ type: 'command', command: DELEGATION_GUARD_HOOK_COMMAND, timeout: 10 }],
715
+ });
716
+ settings.hooks.PreToolUse = list;
717
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
718
+ return { path: file, action: existed ? 'updated' : 'created' };
719
+ }
720
+
721
+ export function removeDelegationGuardHook() {
722
+ const file = join(claudeUserDir(), 'settings.json');
723
+ if (!existsSync(file)) return { path: file, action: 'absent' };
724
+ let settings;
725
+ try {
726
+ settings = JSON.parse(readFileSync(file, 'utf8'));
727
+ } catch (e) {
728
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
729
+ }
730
+ const list = settings?.hooks?.PreToolUse;
731
+ if (!Array.isArray(list)) return { path: file, action: 'absent' };
732
+ // Per hook, not per matcher group — the same correction uninstallAll() above
733
+ // carries, for the same reason. Claude Code merges entries that share a
734
+ // matcher into one group, so filtering whole groups took the other tool's
735
+ // hook with ours whenever we were installed second. v3.46.1 and v3.51.0 both
736
+ // record that failure; a group survives here as long as something of
737
+ // somebody else's is left in it.
738
+ let touched = false;
739
+ const kept = [];
740
+ for (const m of list) {
741
+ if (!Array.isArray(m?.hooks)) { kept.push(m); continue; }
742
+ const hooks = m.hooks.filter((h) => !isDelegationGuardHookCommand(h?.command));
743
+ if (hooks.length === m.hooks.length) { kept.push(m); continue; }
744
+ touched = true;
745
+ if (hooks.length) kept.push({ ...m, hooks });
746
+ }
747
+ if (!touched) return { path: file, action: 'absent' };
748
+ if (kept.length === 0) delete settings.hooks.PreToolUse;
749
+ else settings.hooks.PreToolUse = kept;
750
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
751
+ return { path: file, action: 'removed' };
752
+ }
753
+
605
754
  /**
606
755
  * Undo what `install` did: every hook this tool registered, the statusline
607
756
  * entry when it is still ours, and the skill file.
@@ -689,7 +838,26 @@ export function uninstallAll({ purge = false } = {}) {
689
838
  result.removed.push('state directory');
690
839
  }
691
840
  } else {
841
+ // The state directory stays so a reinstall keeps ledgers and preferences,
842
+ // with one exception carved out just below.
692
843
  result.kept.push('state directory (savings ledgers, config, cache) — remove with --purge');
844
+ // delegate.enabled is not a preference, it is an assertion that a hook is
845
+ // registered — and the hooks were just removed above. `delegate on` goes
846
+ // out of its way to hold the invariant "flag on implies hook registered",
847
+ // installing first and leaving the flag alone when that fails; leaving the
848
+ // flag set here breaks the same invariant from the other side and makes
849
+ // status report `delegate: on, hook not registered` after a clean
850
+ // uninstall. Config it does not own is left untouched.
851
+ try {
852
+ const cfg = loadConfig();
853
+ if (cfg?.delegate?.enabled) {
854
+ cfg.delegate = { ...cfg.delegate, enabled: false };
855
+ saveConfig(cfg);
856
+ result.removed.push('delegate flag');
857
+ }
858
+ } catch {
859
+ // A config this cannot read is not worth failing an uninstall over.
860
+ }
693
861
  }
694
862
 
695
863
  return { ...result, action: 'removed' };