sprag-cli 3.40.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
package/src/harness.js ADDED
@@ -0,0 +1,613 @@
1
+ /**
2
+ * Harness module — manages CLAUDE.md (single file, 5 sections), ratchet.md,
3
+ * and reports completeness for the statusline 🅷 N/5 indicator.
4
+ *
5
+ * Detection is project-scoped: we look at the current working directory's
6
+ * CLAUDE.md (or the nearest one walking up to the git root). Statusline calls
7
+ * harnessStatus() per render — keep it cheap (read + regex, no parsing).
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
11
+ import { join, resolve, dirname } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+ import { createRequire } from 'node:module';
14
+ import { fileURLToPath } from 'node:url';
15
+ import {
16
+ HARNESS_SECTIONS,
17
+ HARNESS_BLOCK_BEGIN,
18
+ HARNESS_BLOCK_END,
19
+ harnessClaudeMdBlock,
20
+ harnessRatchetMdInitial,
21
+ appendRatchetRule,
22
+ RATCHET_IMPORT_RE,
23
+ MODEL_RATCHET_IMPORT_RE,
24
+ } from './harness-templates.js';
25
+ import { routeWarningForStatusline } from './route-scan.js';
26
+ import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet } from './model-rules.js';
27
+ import { compactWindowWarningForStatusline } from './compact-window.js';
28
+ import { userLanguage } from './config.js';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ function readHarnessState() {
32
+ try {
33
+ const a = require('./harness-analyzer.cjs');
34
+ return a.readState();
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Walk up from `start` looking for a project root marker (CLAUDE.md, .git,
42
+ * or package.json). Falls back to `start` itself so harness commands always
43
+ * have *some* directory to write into, even outside a repo.
44
+ */
45
+ export function findProjectRoot(start = process.cwd()) {
46
+ let dir = resolve(start);
47
+ for (;;) {
48
+ if (
49
+ existsSync(join(dir, 'CLAUDE.md')) ||
50
+ existsSync(join(dir, '.git')) ||
51
+ existsSync(join(dir, 'package.json'))
52
+ ) {
53
+ return dir;
54
+ }
55
+ const parent = dirname(dir);
56
+ if (parent === dir) return resolve(start);
57
+ dir = parent;
58
+ }
59
+ }
60
+
61
+ function claudeMdPath(root) {
62
+ return join(root, 'CLAUDE.md');
63
+ }
64
+
65
+ function ratchetMdPath(root) {
66
+ return join(root, '.claude', 'ratchet.md');
67
+ }
68
+
69
+ function globalRatchetMdPath() {
70
+ return join(homedir(), '.claude', 'ratchet.md');
71
+ }
72
+
73
+ function resolveRatchetPath(scope, root) {
74
+ return scope === 'global' ? globalRatchetMdPath() : ratchetMdPath(root);
75
+ }
76
+
77
+ // Global harness lives in ~/.claude/CLAUDE.md — Claude Code loads this for every
78
+ // project, so a global init makes the 5 harness sections apply everywhere
79
+ // (mirrors the project/global split that ratchet.md already has).
80
+ function globalClaudeMdPath() {
81
+ return join(homedir(), '.claude', 'CLAUDE.md');
82
+ }
83
+
84
+ function resolveClaudeMdPath(scope, root) {
85
+ return scope === 'global' ? globalClaudeMdPath() : claudeMdPath(root);
86
+ }
87
+
88
+ /**
89
+ * Count how many of the 5 harness sections appear in the project's CLAUDE.md.
90
+ * Returns { configured, total, missing, hasBlock }. Cheap enough to call from
91
+ * statusline — single file read + regex.
92
+ */
93
+ // Count harness sections in a single CLAUDE.md file. Shared by both scopes.
94
+ function statusForFile(filePath) {
95
+ if (!existsSync(filePath)) {
96
+ return {
97
+ configured: 0,
98
+ total: HARNESS_SECTIONS.length,
99
+ missing: HARNESS_SECTIONS.map((s) => s.id),
100
+ hasBlock: false,
101
+ hasFile: false,
102
+ optOut: false,
103
+ custom: false,
104
+ hasRatchetImport: false,
105
+ hasModelRatchetImport: false,
106
+ file: filePath,
107
+ };
108
+ }
109
+ let content = '';
110
+ try {
111
+ content = readFileSync(filePath, 'utf8');
112
+ } catch {
113
+ // Unreadable file (permissions, etc.) — report every section missing so
114
+ // `harness check` can't print "All 5 sections present ✅" over a 0/5.
115
+ return { configured: 0, total: HARNESS_SECTIONS.length, missing: HARNESS_SECTIONS.map((s) => s.id), hasBlock: false, hasFile: true, optOut: false, custom: false, hasRatchetImport: false, hasModelRatchetImport: false, file: filePath };
116
+ }
117
+ const hasBlock = content.includes(HARNESS_BLOCK_BEGIN);
118
+ // Opt-out marker — when the user intentionally customizes the harness block
119
+ // and doesn't want the statusline to nag, they can drop this comment
120
+ // anywhere in CLAUDE.md to silence the 🅷 indicator entirely.
121
+ const optOut = /<!--\s*harness-check:\s*off\s*-->/i.test(content);
122
+ const present = [];
123
+ const missing = [];
124
+ for (const s of HARNESS_SECTIONS) {
125
+ if (content.includes(s.heading)) present.push(s.id);
126
+ else missing.push(s.id);
127
+ }
128
+ // Custom state — user has the harness block but at least one header was
129
+ // hand-edited away from the canonical text. Treat as intentional divergence
130
+ // (don't show N/5 nag) but still surface a neutral 🅷 custom marker so they
131
+ // know the auto-check no longer applies.
132
+ const custom = hasBlock && present.length < HARNESS_SECTIONS.length;
133
+ return {
134
+ configured: present.length,
135
+ total: HARNESS_SECTIONS.length,
136
+ missing,
137
+ hasBlock,
138
+ hasFile: true,
139
+ optOut,
140
+ custom,
141
+ // Whether the promoted ratchet rules actually reach the model. Blocks
142
+ // written before v3.6.3 have all 5 sections but no import, so the rules
143
+ // sat in a file nothing read — worth flagging separately from N/5.
144
+ hasRatchetImport: RATCHET_IMPORT_RE.test(content),
145
+ hasModelRatchetImport: MODEL_RATCHET_IMPORT_RE.test(content),
146
+ file: filePath,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Harness status for a project, with scope control:
152
+ * scope 'project' — count only <root>/CLAUDE.md
153
+ * scope 'global' — count only ~/.claude/CLAUDE.md
154
+ * scope 'auto' (default) — use the project file if it carries the harness
155
+ * block, otherwise fall back to the global file. This makes a project that
156
+ * relies on a globally-installed harness report 🅷 5/5 (covered by global),
157
+ * matching reality: Claude Code loads ~/.claude/CLAUDE.md for every project.
158
+ * The returned `source` ('project'|'global') tells callers which file was used.
159
+ *
160
+ * The `@` import flags are the union of both files, not just the source one:
161
+ * Claude Code loads ~/.claude/CLAUDE.md for every project *and* the project
162
+ * CLAUDE.md, so a project-scope block with the imports living in the global
163
+ * file still gets the ratchet rules. Checking only the source file made that
164
+ * layout report a false `ratchet-unloaded`. `importSource` says which file
165
+ * actually carries them ('project' | 'global' | 'both' | null).
166
+ */
167
+ export function harnessStatus(root = findProjectRoot(), { scope = 'auto' } = {}) {
168
+ const project = statusForFile(claudeMdPath(root));
169
+ const global = statusForFile(globalClaudeMdPath());
170
+ const pick = (s, source) => ({ ...s, ...unionImports(project, global), root, source });
171
+ if (scope === 'project') return pick(project, 'project');
172
+ if (scope === 'global') return pick(global, 'global');
173
+ if (project.hasBlock) return pick(project, 'project');
174
+ if (global.hasBlock) return pick(global, 'global');
175
+ return pick(project, 'project');
176
+ }
177
+
178
+ // Union the two files' import flags. Same file read twice (project root === ~)
179
+ // is harmless — OR is idempotent.
180
+ function unionImports(project, global) {
181
+ const samePath = project.file === global.file;
182
+ const g = samePath ? { hasRatchetImport: false, hasModelRatchetImport: false } : global;
183
+ const inProject = project.hasRatchetImport || project.hasModelRatchetImport;
184
+ const inGlobal = g.hasRatchetImport || g.hasModelRatchetImport;
185
+ return {
186
+ hasRatchetImport: project.hasRatchetImport || g.hasRatchetImport,
187
+ hasModelRatchetImport: project.hasModelRatchetImport || g.hasModelRatchetImport,
188
+ importSource: inProject && inGlobal ? 'both' : inProject ? 'project' : inGlobal ? 'global' : null,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * harness init — write CLAUDE.md (single file, 5 sections) + .claude/ratchet.md.
194
+ * If CLAUDE.md exists, back it up to CLAUDE.md.bak-YYYYMMDD-HHMMSS first
195
+ * (per user-confirmed design: backup, then overwrite with the harness block).
196
+ *
197
+ * Returns { wrote: [], backedUp: [], skipped: [] } so the CLI can report.
198
+ */
199
+ export function harnessInit({ root = findProjectRoot(), force = false, scope = 'project' } = {}) {
200
+ const cmPath = resolveClaudeMdPath(scope, root); // global → ~/.claude/CLAUDE.md
201
+ const rmPath = resolveRatchetPath(scope, root); // global → ~/.claude/ratchet.md
202
+ const result = { wrote: [], backedUp: [], skipped: [], root, scope };
203
+
204
+ // CLAUDE.md
205
+ const block = harnessClaudeMdBlock(scope);
206
+ if (existsSync(cmPath)) {
207
+ const existing = readFileSync(cmPath, 'utf8');
208
+ if (existing.includes(HARNESS_BLOCK_BEGIN) && !force) {
209
+ // Already has a harness block — replace it in-place, preserving the
210
+ // user's other content above/below.
211
+ const re = new RegExp(
212
+ `${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
213
+ 'm',
214
+ );
215
+ const next = existing.replace(re, block);
216
+ writeFileSync(cmPath, next);
217
+ result.wrote.push(cmPath + ' (block updated in place)');
218
+ } else {
219
+ // Backup as safety net, then APPEND the harness block to existing
220
+ // content (do not clobber). Users keep all their prior CLAUDE.md content;
221
+ // the harness block is added at the end and managed in-place on re-runs
222
+ // via the BEGIN/END markers.
223
+ const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15); // YYYYMMDDTHHMMSS
224
+ const bak = `${cmPath}.bak-${stamp}`;
225
+ writeFileSync(bak, existing);
226
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
227
+ writeFileSync(cmPath, existing + sep + block);
228
+ result.backedUp.push(bak);
229
+ result.wrote.push(cmPath + ' (harness block appended)');
230
+ }
231
+ } else {
232
+ mkdirSync(dirname(cmPath), { recursive: true }); // global: ensure ~/.claude exists
233
+ writeFileSync(cmPath, block);
234
+ result.wrote.push(cmPath);
235
+ }
236
+
237
+ // ratchet.md (only if missing — don't clobber user-grown rules)
238
+ if (!existsSync(rmPath)) {
239
+ mkdirSync(dirname(rmPath), { recursive: true });
240
+ writeFileSync(rmPath, harnessRatchetMdInitial());
241
+ result.wrote.push(rmPath);
242
+ } else {
243
+ result.skipped.push(rmPath + ' (already exists)');
244
+ }
245
+
246
+ // ratchet-model.md — tool-owned, normally written by route-scan. The block
247
+ // imports it, so seed an empty one now rather than ship a dangling import
248
+ // into every project that has not been scanned yet.
249
+ const mrPath = modelRatchetPathFor(scope, root);
250
+ if (!existsSync(mrPath)) {
251
+ try {
252
+ mkdirSync(dirname(mrPath), { recursive: true });
253
+ writeFileSync(mrPath, renderModelRatchet([]));
254
+ result.wrote.push(mrPath);
255
+ } catch { /* unwritable — route-scan will retry on its next sync */ }
256
+ } else {
257
+ result.skipped.push(mrPath + ' (already exists)');
258
+ }
259
+
260
+ return result;
261
+ }
262
+
263
+ /**
264
+ * harness uninit — remove the harness block from CLAUDE.md (preserves the
265
+ * user's other content). A safety backup is written first. ratchet.md is
266
+ * left intact (user-grown rules) unless `purgeRatchet` is true.
267
+ *
268
+ * Returns { removed: [], backedUp: [], skipped: [] }.
269
+ */
270
+ export function harnessUninit({ root = findProjectRoot(), purgeRatchet = false, scope = 'project' } = {}) {
271
+ const cmPath = resolveClaudeMdPath(scope, root);
272
+ const rmPath = resolveRatchetPath(scope, root);
273
+ const result = { removed: [], backedUp: [], skipped: [], root, scope };
274
+
275
+ if (existsSync(cmPath)) {
276
+ const existing = readFileSync(cmPath, 'utf8');
277
+ if (existing.includes(HARNESS_BLOCK_BEGIN)) {
278
+ const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
279
+ const bak = `${cmPath}.bak-${stamp}`;
280
+ writeFileSync(bak, existing);
281
+ const re = new RegExp(
282
+ `\\n*${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
283
+ 'm',
284
+ );
285
+ const next = existing.replace(re, '').replace(/\n{3,}$/, '\n\n');
286
+ writeFileSync(cmPath, next);
287
+ result.backedUp.push(bak);
288
+ result.removed.push(cmPath + ' (harness block removed)');
289
+ } else {
290
+ result.skipped.push(cmPath + ' (no harness block found)');
291
+ }
292
+ } else {
293
+ result.skipped.push(cmPath + ' (does not exist)');
294
+ }
295
+
296
+ if (purgeRatchet && existsSync(rmPath)) {
297
+ const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
298
+ const bak = `${rmPath}.bak-${stamp}`;
299
+ writeFileSync(bak, readFileSync(rmPath, 'utf8'));
300
+ result.backedUp.push(bak);
301
+ // Replace with empty initial template rather than delete (preserves dir).
302
+ writeFileSync(rmPath, harnessRatchetMdInitial());
303
+ result.removed.push(rmPath + ' (reset to initial)');
304
+ } else if (existsSync(rmPath)) {
305
+ result.skipped.push(rmPath + ' (kept; pass --purge-ratchet to reset)');
306
+ }
307
+
308
+ return result;
309
+ }
310
+
311
+ /**
312
+ * harness promote — append a one-line rule to .claude/ratchet.md.
313
+ * Creates the file from the initial template if missing.
314
+ */
315
+ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'project' } = {}) {
316
+ const rmPath = resolveRatchetPath(scope, root);
317
+ let existing = '';
318
+ if (existsSync(rmPath)) {
319
+ existing = readFileSync(rmPath, 'utf8');
320
+ } else {
321
+ mkdirSync(dirname(rmPath), { recursive: true });
322
+ existing = harnessRatchetMdInitial();
323
+ }
324
+ const next = appendRatchetRule(existing, ruleText);
325
+ writeFileSync(rmPath, next);
326
+ return { path: rmPath, root, scope };
327
+ }
328
+
329
+ /**
330
+ * harness pull — register the CURATED ratchet rules bundled with this package
331
+ * (presets/ratchet-rules.md) into the user's ratchet, global by default.
332
+ *
333
+ * Rationale: a project ratchet already inherits the global one (global is the
334
+ * upper layer of the hierarchy), so there is nothing to copy between the
335
+ * user's own scopes. What CAN'T reach the user any other way is the package
336
+ * author's field-tested rules — pull ships those, strictly opt-in:
337
+ * install/init never auto-injects anything.
338
+ *
339
+ * Deduped by rule text (ignoring the YYYY-MM-DD stamp) — idempotent.
340
+ * Returns { path, scope, added, skippedRules, presets }.
341
+ */
342
+ export function harnessPull({ root = findProjectRoot(), scope = 'global' } = {}) {
343
+ const presets = presetRules();
344
+ const rmPath = resolveRatchetPath(scope, root);
345
+ const result = { path: rmPath, scope, added: [], skippedRules: 0, presets: presets.length };
346
+ const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}:\s*/, '').trim();
347
+
348
+ let content = existsSync(rmPath)
349
+ ? readFileSync(rmPath, 'utf8')
350
+ : harnessRatchetMdInitial();
351
+ const have = new Set(
352
+ harnessListRules({ root, scope }).rules.map((r) => stripDate(r.text)),
353
+ );
354
+ for (const rule of presets) {
355
+ if (have.has(rule)) {
356
+ result.skippedRules += 1;
357
+ continue;
358
+ }
359
+ content = appendRatchetRule(content, rule);
360
+ have.add(rule);
361
+ result.added.push(rule);
362
+ }
363
+ if (result.added.length) {
364
+ mkdirSync(dirname(rmPath), { recursive: true });
365
+ writeFileSync(rmPath, content);
366
+ }
367
+ return result;
368
+ }
369
+
370
+ /**
371
+ * The bundled preset rules, both languages per rule.
372
+ *
373
+ * These strings are injected into the model's context (by `seed`) and written
374
+ * into the user's ratchet (by `pull`), so a Korean-only set would pull an
375
+ * English session's responses into Korean — hence the pair. The `ko` text is
376
+ * canonical: it is what identifies a rule, so revising an English wording never
377
+ * turns a rule the user already answered into a new one.
378
+ */
379
+ export function presetRuleEntries() {
380
+ try {
381
+ // fileURLToPath, not `.pathname`: the latter yields `/D:/repo/src` on
382
+ // Windows, where the read fails and the catch below turns a missing
383
+ // file into an empty rule set without ever saying so.
384
+ const path = join(dirname(fileURLToPath(import.meta.url)), '..', 'presets', 'ratchet-rules.json');
385
+ const data = JSON.parse(readFileSync(path, 'utf8'));
386
+ return (Array.isArray(data.rules) ? data.rules : [])
387
+ .filter((r) => r && typeof r.ko === 'string' && r.ko.trim())
388
+ .map((r) => ({ ko: r.ko.trim(), en: (r.en || r.ko).trim() }));
389
+ } catch {
390
+ return [];
391
+ }
392
+ }
393
+
394
+ /** Preset rule text in the user's configured language. */
395
+ export function presetRules(lang = userLanguage()) {
396
+ return presetRuleEntries().map((r) => (lang === 'ko' ? r.ko : r.en));
397
+ }
398
+
399
+ /**
400
+ * harness list — return numbered ratchet rules from .claude/ratchet.md.
401
+ * Numbering is 1-based and matches `harness rm <N>`.
402
+ */
403
+ export function harnessListRules({ root = findProjectRoot(), scope = 'project' } = {}) {
404
+ const rmPath = resolveRatchetPath(scope, root);
405
+ if (!existsSync(rmPath)) return { path: rmPath, rules: [] };
406
+ const lines = readFileSync(rmPath, 'utf8').split('\n');
407
+ const rules = [];
408
+ for (let i = 0; i < lines.length; i++) {
409
+ const line = lines[i];
410
+ // A "rule line" starts with "- " (markdown bullet). Header lines, blanks,
411
+ // and the "## Rules" anchor are ignored. (Model-fitting rules live in a
412
+ // separate tool-owned file, ratchet-model.md — never listed here.)
413
+ if (/^\s*-\s+/.test(line)) {
414
+ const text = line.replace(/^\s*-\s+/, '');
415
+ rules.push({ index: rules.length + 1, lineNo: i, text, ...parseRuleMeta(text) });
416
+ }
417
+ }
418
+ return { path: rmPath, rules, bytes: Buffer.byteLength(readFileSync(rmPath, 'utf8'), 'utf8') };
419
+ }
420
+
421
+ /**
422
+ * Rule metadata, parsed from the line the user already writes:
423
+ * "- 2026-05-08: [video,tts] 자막 렌더 시 ..."
424
+ * Both parts are optional — `date` is null on undated rules, `tags` empty on
425
+ * untagged ones. Tags exist to make pruning targeted (`prune --tag video`);
426
+ * they deliberately do NOT filter what gets loaded, because CLAUDE.md `@`
427
+ * imports are static — the file that is imported is the file that is read, so
428
+ * the only way to spend fewer tokens is to have fewer rules in it.
429
+ */
430
+ export function parseRuleMeta(text) {
431
+ const dateM = text.match(/^(\d{4})-(\d{2})-(\d{2})\s*:/);
432
+ // Tags must lead the rule body (right after the date, or at the very start),
433
+ // so a bracketed aside later in the sentence is not mistaken for a tag list.
434
+ const tagM = text.match(/^(?:\d{4}-\d{2}-\d{2}\s*:\s*)?\[([^\]]+)\]/);
435
+ return {
436
+ date: dateM ? dateM[0].replace(/\s*:$/, '') : null,
437
+ tags: tagM ? tagM[1].split(',').map((t) => t.trim()).filter(Boolean) : [],
438
+ };
439
+ }
440
+
441
+ // Rough token cost of the imported ratchet, charged on every single request of
442
+ // every session. 4 bytes/token is the usual mixed ko/en approximation.
443
+ export const RATCHET_TOKEN_BUDGET = 2000;
444
+
445
+ export function ratchetSizeStatus({ root = findProjectRoot(), scope = 'project' } = {}) {
446
+ const { path, rules, bytes = 0 } = harnessListRules({ root, scope });
447
+ const tokens = Math.round(bytes / 4);
448
+ return { path, count: rules.length, bytes, tokens, overBudget: tokens > RATCHET_TOKEN_BUDGET };
449
+ }
450
+
451
+ // CLAUDE.md itself is also charged on every request. The community guideline
452
+ // is "rules and file pointers, not documentation" — the harness block plus a
453
+ // modest project section fits well under this; docs pasted wholesale do not.
454
+ export const CLAUDE_MD_TOKEN_BUDGET = 4000;
455
+
456
+ /**
457
+ * Advisory size/ignore facts for `harness check`:
458
+ * - approx token weight of the project (or global) CLAUDE.md
459
+ * - whether the project has a .claudeignore (limits what Claude Code will
460
+ * read into context during searches)
461
+ * Purely informational — never affects the 🅷 N/5 score.
462
+ */
463
+ export function contextWeightStatus({ root = findProjectRoot() } = {}) {
464
+ const out = { claudeMd: null, hasClaudeIgnore: false };
465
+ try {
466
+ out.hasClaudeIgnore = existsSync(join(root, '.claudeignore'));
467
+ } catch { /* fs error → treat as absent */ }
468
+ for (const file of [join(root, 'CLAUDE.md'), join(homedir(), '.claude', 'CLAUDE.md')]) {
469
+ try {
470
+ if (!existsSync(file)) continue;
471
+ const bytes = statSync(file).size;
472
+ const tokens = Math.round(bytes / 4);
473
+ out.claudeMd = { path: file, bytes, tokens, overBudget: tokens > CLAUDE_MD_TOKEN_BUDGET };
474
+ break; // project file wins; global only as fallback
475
+ } catch { /* unreadable → skip */ }
476
+ }
477
+ return out;
478
+ }
479
+
480
+ /**
481
+ * harness prune — move rules out of ratchet.md into ratchet-archive.md next to
482
+ * it. Selection is by tag and/or age; nothing is deleted, so a pruned rule can
483
+ * be pasted back. Returns the pruned rules for the CLI to echo.
484
+ */
485
+ export function harnessPrune({ root = findProjectRoot(), scope = 'project', tag = null, olderThanMonths = null, dryRun = false } = {}) {
486
+ const { path: rmPath, rules } = harnessListRules({ root, scope });
487
+ if (!existsSync(rmPath)) return { ok: false, error: `ratchet.md not found at ${rmPath}` };
488
+ if (!tag && !olderThanMonths) return { ok: false, error: 'Nothing selected — pass --tag <t> and/or --older-than <months>' };
489
+ const cutoff = olderThanMonths ? Date.now() - olderThanMonths * 30 * 24 * 60 * 60 * 1000 : null;
490
+ const doomed = rules.filter((r) => {
491
+ if (tag && !r.tags.includes(tag)) return false;
492
+ // An undated rule has no age to judge, so age-based pruning leaves it be.
493
+ if (cutoff !== null) {
494
+ const t = r.date ? Date.parse(r.date) : NaN;
495
+ if (!Number.isFinite(t) || t >= cutoff) return false;
496
+ }
497
+ return true;
498
+ });
499
+ if (dryRun || doomed.length === 0) return { ok: true, path: rmPath, pruned: doomed, dryRun: true };
500
+ const content = readFileSync(rmPath, 'utf8');
501
+ writeFileSync(rmPath + '.bak', content);
502
+ const drop = new Set(doomed.map((r) => r.lineNo));
503
+ const kept = content.split('\n').filter((_, i) => !drop.has(i));
504
+ writeFileSync(rmPath, kept.join('\n'));
505
+ const archivePath = join(dirname(rmPath), 'ratchet-archive.md');
506
+ const header = existsSync(archivePath) ? '' : '# Ratchet Archive (pruned rules — not loaded into sessions)\n\n';
507
+ const stamp = new Date().toISOString().slice(0, 10);
508
+ const body = doomed.map((r) => `- ${r.text} <!-- pruned ${stamp} -->`).join('\n') + '\n';
509
+ writeFileSync(archivePath, (existsSync(archivePath) ? readFileSync(archivePath, 'utf8').replace(/\n*$/, '\n') : header) + body);
510
+ return { ok: true, path: rmPath, backup: rmPath + '.bak', archive: archivePath, pruned: doomed };
511
+ }
512
+
513
+ /**
514
+ * harness rm — remove a ratchet rule by its 1-based index. Writes a `.bak`
515
+ * before mutating so the user can recover. Returns the removed rule for the
516
+ * CLI to echo back.
517
+ *
518
+ * NOTE: Removal is intentionally a separate verb from `promote`. Ratchet's
519
+ * value is one-way accumulation; deleting should feel deliberate. The CLI
520
+ * surfaces a "narrow the condition instead" reminder around this call.
521
+ */
522
+ export function harnessRmRule(n, { root = findProjectRoot(), scope = 'project' } = {}) {
523
+ const { path: rmPath, rules } = harnessListRules({ root, scope });
524
+ if (!existsSync(rmPath)) {
525
+ return { ok: false, error: `ratchet.md not found at ${rmPath}` };
526
+ }
527
+ const target = rules.find((r) => r.index === n);
528
+ if (!target) {
529
+ return { ok: false, error: `No rule #${n} (have ${rules.length})`, rules };
530
+ }
531
+ const content = readFileSync(rmPath, 'utf8');
532
+ writeFileSync(rmPath + '.bak', content);
533
+ const lines = content.split('\n');
534
+ lines.splice(target.lineNo, 1);
535
+ writeFileSync(rmPath, lines.join('\n'));
536
+ return { ok: true, path: rmPath, backup: rmPath + '.bak', removed: target };
537
+ }
538
+
539
+ /**
540
+ * Statusline segment shape for the 🅷 indicator. Returns null when the user
541
+ * has explicitly disabled harness display, or when there's no CLAUDE.md and
542
+ * no .claude/ at all (silent in non-init'd projects so we don't nag).
543
+ */
544
+ export function harnessStatusForStatusline(cfg, { root } = {}) {
545
+ if (cfg && cfg.harness && cfg.harness.enabled === false) return null;
546
+ const projectRoot = root || findProjectRoot();
547
+ const status = harnessStatus(projectRoot);
548
+ // Silent when the project has neither CLAUDE.md nor a .claude/ dir — the
549
+ // user hasn't opted in, no point nagging.
550
+ if (!status.hasFile && !existsSync(join(projectRoot, '.claude'))) return null;
551
+ if (status.optOut) return null;
552
+ // Attach a warning derived from the analyzer state file (if any). Precedence:
553
+ // ratchet? > no-evidence > PEV-skip. Guards, in order:
554
+ // - freshness: the hook rewrites the state on every tool use, so anything
555
+ // older than WARNING_TTL_MS is a dead session's leftovers — a red 🅷⚠
556
+ // must never linger for days after the triggering session ended.
557
+ // - project match: state.cwd is the *session* cwd, which may be a subdir
558
+ // of the repo, while projectRoot is the walked-up root. Normalize both
559
+ // through findProjectRoot so launching Claude Code in a subdirectory
560
+ // still surfaces (and correctly scopes) the warning. A state with no
561
+ // cwd at all is unattributable — stay silent rather than leak it into
562
+ // every project.
563
+ const WARNING_TTL_MS = 30 * 60 * 1000;
564
+ const state = readHarnessState();
565
+ let warning = null;
566
+ if (state) {
567
+ const ts = state.timestamp ? Date.parse(state.timestamp) : NaN;
568
+ const fresh = Number.isFinite(ts) && Date.now() - ts <= WARNING_TTL_MS;
569
+ const matches = !!state.cwd && findProjectRoot(state.cwd) === projectRoot;
570
+ if (fresh && matches) {
571
+ if (state.ratchetCandidate && state.ratchetCandidate.count >= 2) {
572
+ const id = state.ratchetCandidate.id || 1;
573
+ warning = `ratchet? #${id}`;
574
+ }
575
+ else if (state.evidenceLow) warning = 'no-evidence';
576
+ else if (state.pevSkip) warning = 'PEV-skip';
577
+ }
578
+ }
579
+ // Config defect, above the optimization nudges: the harness block is there
580
+ // but carries no `@` import, so every promoted ratchet rule is dead weight.
581
+ // One `harness init` re-run fixes it and the warning goes away for good.
582
+ if (!warning && status.hasBlock && !(status.hasRatchetImport && status.hasModelRatchetImport)) warning = 'ratchet-unloaded';
583
+ // Same class of defect, one notch lower: the session runs on a 1M-context
584
+ // model with no `autoCompactWindow` cap, so compaction only fires past 800k
585
+ // and every request until then re-bills the whole context. 200k sessions are
586
+ // exempt — the setting cannot change anything for them.
587
+ if (!warning) {
588
+ try {
589
+ warning = compactWindowWarningForStatusline(projectRoot, cfg);
590
+ } catch { /* settings unreadable — stay silent */ }
591
+ }
592
+ // Below session-quality warnings: a promoted delegation rule whose
593
+ // category started failing (`rule-health R<N>`) — the user approved that
594
+ // rule, so its degradation outranks a mere new-candidate nudge.
595
+ if (!warning) {
596
+ try {
597
+ warning = ruleHealthWarningForStatusline(projectRoot);
598
+ } catch { /* registry unreadable — stay silent */ }
599
+ }
600
+ // Lowest precedence: route-scan delegation candidate (`route? R<N>`).
601
+ // Session-quality warnings above always win — routing is an optimization
602
+ // nudge, not a correctness signal. Cheap: one small cached-JSON read.
603
+ if (!warning) {
604
+ try {
605
+ warning = routeWarningForStatusline(projectRoot);
606
+ } catch { /* scan cache unreadable — stay silent */ }
607
+ }
608
+ return { ...status, warning };
609
+ }
610
+
611
+ function escapeRe(s) {
612
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
613
+ }