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
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Subcommand: harness β€” manage the project's CLAUDE.md harness rules.
3
+ * claude-token-saver harness init # write CLAUDE.md (5 sections) + ratchet.md
4
+ * claude-token-saver harness uninit # remove harness block from CLAUDE.md (backup kept)
5
+ * claude-token-saver harness check # show πŸ…· N/5 + which sections are missing
6
+ * claude-token-saver harness promote "<rule>" # append a rule to ratchet.md
7
+ * claude-token-saver harness pull [--global|--project] # register the package's curated preset rules (default global)
8
+ * claude-token-saver harness off | on # toggle the statusline πŸ…· segment
9
+ */
10
+
11
+ import { debug } from '../debug.js';
12
+
13
+ export async function run({ args, hasFlag }) {
14
+ const sub = args[1];
15
+ // Scope flags for init/uninit/check (same convention as promote/list/rm):
16
+ // --global | --project | --scope=global|project | --scope global|project
17
+ const parseHarnessScope = (argv, dflt) => {
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const a = argv[i];
20
+ if (a === '--global') return 'global';
21
+ if (a === '--project') return 'project';
22
+ if (a === '--scope' && (argv[i + 1] === 'global' || argv[i + 1] === 'project')) return argv[i + 1];
23
+ if (a.startsWith('--scope=')) {
24
+ const v = a.slice('--scope='.length);
25
+ if (v === 'global' || v === 'project') return v;
26
+ }
27
+ }
28
+ return dflt;
29
+ };
30
+ const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessPull, harnessListRules, harnessRmRule, harnessPrune, ratchetSizeStatus, RATCHET_TOKEN_BUDGET, contextWeightStatus, CLAUDE_MD_TOKEN_BUDGET, findProjectRoot } =
31
+ await import('../harness.js');
32
+ const { HARNESS_SECTIONS } = await import('../harness-templates.js');
33
+ const { loadConfig, saveConfig, userLanguage } = await import('../config.js');
34
+ const lang = userLanguage();
35
+
36
+ if (!sub || sub === 'check') {
37
+ const scope = parseHarnessScope(args.slice(2), 'auto'); // auto = project, else global fallback
38
+ const root = findProjectRoot();
39
+ const s = harnessStatus(root, { scope });
40
+ // Only call out "covered by global" when we *fell back* to it (auto), not
41
+ // when the user explicitly asked for the global scope.
42
+ const via = (scope === 'auto' && s.source === 'global') ? ' (covered by global ~/.claude/CLAUDE.md)' : '';
43
+ console.log(`πŸ…· ${s.configured}/${s.total} β€” ${s.file}${via}`);
44
+ console.log(`CLAUDE.md: ${s.hasFile ? 'present' : 'missing'}` +
45
+ (s.hasFile ? `, harness block: ${s.hasBlock ? 'yes' : 'no'}` : '') + ` [${s.source}]`);
46
+ if (s.missing.length) {
47
+ console.log('Missing sections:');
48
+ for (const id of s.missing) {
49
+ const sec = HARNESS_SECTIONS.find((x) => x.id === id);
50
+ console.log(` - ${id}: ${sec ? sec.heading.replace(/^#+\s*/, '') : ''}`);
51
+ }
52
+ console.log('\nRun: claude-token-saver harness init (this project)');
53
+ console.log(' or: claude-token-saver harness init --global (all projects, ~/.claude/CLAUDE.md)');
54
+ } else {
55
+ console.log('All 5 harness sections present. βœ…');
56
+ }
57
+ // Sections can all be present while ratchet.md still never reaches the
58
+ // model β€” blocks written before v3.6.3 have no `@` import line.
59
+ if (s.hasBlock && !(s.hasRatchetImport && s.hasModelRatchetImport)) {
60
+ const dead = [!s.hasRatchetImport && 'ratchet.md', !s.hasModelRatchetImport && 'ratchet-model.md'].filter(Boolean).join(' + ');
61
+ console.log(`\n⚠ ${dead} is NOT loaded into sessions β€” the harness block has no \`@\` import line for it.`);
62
+ console.log(' Those rules are being written to a file nothing reads.');
63
+ console.log(` Fix: claude-token-saver harness init${s.source === 'global' ? ' --global' : ''} (updates the block in place)`);
64
+ }
65
+ // Imported ratchets are charged on every request, so their size matters.
66
+ // Static `@` imports cannot be filtered at load time β€” the only lever is
67
+ // fewer rules, hence the prune pointer rather than a "filter" suggestion.
68
+ for (const sc of ['project', 'global']) {
69
+ const size = ratchetSizeStatus({ scope: sc });
70
+ if (!size.count) continue;
71
+ const line = `ratchet.md [${sc}]: ${size.count} rules, ~${size.tokens} tok/request`;
72
+ if (size.overBudget) {
73
+ console.log(`\n⚠ ${line} β€” over the ~${RATCHET_TOKEN_BUDGET} token budget.`);
74
+ console.log(` Trim: claude-token-saver harness prune${sc === 'global' ? ' --global' : ''} --older-than 6 --dry-run`);
75
+ console.log(' (project-specific rules belong in --project scope, not global.)');
76
+ } else {
77
+ console.log(`${line}`);
78
+ }
79
+ }
80
+ // Advisory context-weight facts (never counted in πŸ…· N/5): CLAUDE.md is
81
+ // charged on every request, and without a .claudeignore Claude Code can
82
+ // pull build artifacts and vendored code into context during searches.
83
+ try {
84
+ const w = contextWeightStatus({ root });
85
+ if (w.claudeMd) {
86
+ const line = `CLAUDE.md size: ~${w.claudeMd.tokens} tok/request (${w.claudeMd.path})`;
87
+ if (w.claudeMd.overBudget) {
88
+ console.log(`\n⚠ ${line} β€” over the ~${CLAUDE_MD_TOKEN_BUDGET} token guideline.`);
89
+ console.log(' Keep rules and file pointers here; move documentation into files it points to.');
90
+ } else {
91
+ console.log(line);
92
+ }
93
+ }
94
+ console.log(`.claudeignore: ${w.hasClaudeIgnore ? 'present' : 'absent β€” consider adding one so searches skip build output, vendored code, and large data files'}`);
95
+ } catch (e) {
96
+ debug('harness:context-weight', e);
97
+ }
98
+ return;
99
+ }
100
+
101
+ if (sub === 'init') {
102
+ const scope = parseHarnessScope(args.slice(2), 'project'); // default project (back-compat)
103
+ const force = hasFlag('--force');
104
+ const r = harnessInit({ force, scope });
105
+ console.log(`Scope: ${scope}${scope === 'global' ? ' (~/.claude/CLAUDE.md β€” applies to all projects)' : ` (${r.root})`}`);
106
+ for (const p of r.backedUp) console.log(`Backed up: ${p}`);
107
+ for (const p of r.wrote) console.log(`Wrote: ${p}`);
108
+ for (const p of r.skipped) console.log(`Skipped: ${p}`);
109
+ console.log('\nπŸ…· Harness initialized. Statusline will show πŸ…· 5/5 on next refresh.');
110
+ return;
111
+ }
112
+
113
+ if (sub === 'promote') {
114
+ // Parse scope flags before stripping. Accepts: --global, --project,
115
+ // --scope=global|project, --scope global|project
116
+ const promoteArgs = args.slice(2);
117
+ let scope = null;
118
+ const scopeFlags = new Set();
119
+ for (let i = 0; i < promoteArgs.length; i++) {
120
+ const a = promoteArgs[i];
121
+ if (a === '--global') { scope = 'global'; scopeFlags.add(i); }
122
+ else if (a === '--project') { scope = 'project'; scopeFlags.add(i); }
123
+ else if (a === '--scope' && promoteArgs[i + 1]) {
124
+ const v = promoteArgs[i + 1];
125
+ if (v !== 'global' && v !== 'project') {
126
+ console.error(`Invalid --scope value: ${v} (expected "global" or "project")`);
127
+ process.exit(1);
128
+ }
129
+ scope = v; scopeFlags.add(i); scopeFlags.add(i + 1); i++;
130
+ } else if (a.startsWith('--scope=')) {
131
+ const v = a.slice('--scope='.length);
132
+ if (v !== 'global' && v !== 'project') {
133
+ console.error(`Invalid --scope value: ${v} (expected "global" or "project")`);
134
+ process.exit(1);
135
+ }
136
+ scope = v; scopeFlags.add(i);
137
+ }
138
+ }
139
+ const raw = promoteArgs.filter((_, i) => !scopeFlags.has(i)).join(' ').trim();
140
+ if (!raw) {
141
+ console.error('Usage: claude-token-saver harness promote [--global|--project] <N> # from statusline πŸ…·βš  ratchet? #N');
142
+ console.error(' or: claude-token-saver harness promote [--global|--project] "<rule text>"');
143
+ process.exit(1);
144
+ }
145
+ let rule = raw;
146
+ // Numeric arg β†’ look up candidate #N from analyzer state and turn its
147
+ // detected error pattern into a starter ratchet rule. Saves the user
148
+ // from retyping the error; they can edit ratchet.md afterward.
149
+ if (/^\d+$/.test(raw)) {
150
+ const n = parseInt(raw, 10);
151
+ const analyzer = await import('../harness-analyzer.cjs');
152
+ const a = analyzer.default || analyzer;
153
+ const state = a.readState();
154
+ const list = (state && state.ratchetCandidates) || [];
155
+ const cand = list.find((c) => c.id === n);
156
+ if (!cand) {
157
+ console.error(`No ratchet candidate #${n} in state. Run \`harness analyze\` or wait for the hook to populate it.`);
158
+ if (list.length) {
159
+ console.error('Available candidates:');
160
+ for (const c of list) console.error(` #${c.id} (Γ—${c.count}): ${c.pattern}`);
161
+ }
162
+ process.exit(1);
163
+ }
164
+ rule = `반볡 감지 Γ—${cand.count}: ${cand.pattern} β€” TODO: μ›μΈΒ·μ˜ˆλ°©μ±… ν•œ μ€„λ‘œ`;
165
+ }
166
+ // R-prefixed arg β†’ route-scan delegation candidate (statusline `route? R<N>`).
167
+ // The rule text is pre-generated by the scan; promoting also resolves the
168
+ // candidate so the chip stops and rescans don't resurface it.
169
+ let routeCandidateId = null;
170
+ let routeCandidate = null;
171
+ if (/^[Rr]\d+$/.test(raw)) {
172
+ const n = parseInt(raw.slice(1), 10);
173
+ const rs = await import('../route-scan.js');
174
+ const cand = (rs.openCandidates(rs.readRouteScan()) || []).find((c) => c.id === n);
175
+ if (!cand) {
176
+ console.error(`No open route candidate R${n}. Run: claude-token-saver route-scan`);
177
+ process.exit(1);
178
+ }
179
+ rule = lang === 'ko' ? cand.rule : (cand.ruleEn || cand.rule);
180
+ routeCandidateId = n;
181
+ routeCandidate = cand;
182
+ if (!scope) {
183
+ console.error(`Route candidate R${n} requires an explicit scope (suggested: --${cand.suggestedScope}).`);
184
+ console.error('Ask the user, then pass --project or --global.');
185
+ process.exit(1);
186
+ }
187
+ }
188
+ // Scope resolution: explicit flag wins. Otherwise prompt interactively
189
+ // when running on a TTY; in non-TTY (CI/scripts) require an explicit
190
+ // flag so the choice is never silently made for the caller.
191
+ if (!scope) {
192
+ if (process.stdin.isTTY && process.stdout.isTTY) {
193
+ const readline = await import('node:readline');
194
+ const { homedir: hd } = await import('node:os');
195
+ const { findProjectRoot: fpr } = await import('../harness.js');
196
+ const projPath = `${fpr()}/.claude/ratchet.md`;
197
+ const globPath = `${hd()}/.claude/ratchet.md`;
198
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
199
+ const ask = (q) => new Promise((res) => rl.question(q, res));
200
+ console.log('Where should this rule live?');
201
+ console.log(` [1] project (${projPath})`);
202
+ console.log(` [2] global (${globPath})`);
203
+ const ans = (await ask('Choose [1/2] (default 1): ')).trim();
204
+ rl.close();
205
+ scope = (ans === '2' || ans.toLowerCase() === 'global' || ans.toLowerCase() === 'g')
206
+ ? 'global' : 'project';
207
+ } else {
208
+ console.error('Scope required in non-interactive mode.');
209
+ console.error('Pass --project or --global (or --scope=project|global).');
210
+ process.exit(1);
211
+ }
212
+ }
213
+ // Route candidates become MODEL-FITTING rules: they live in a
214
+ // tool-managed block (separate from user-authored ratchet rules) and
215
+ // keep updating from subsequent logs β€” recurrence counts, error rates,
216
+ // rule-health β€” on every rescan.
217
+ if (routeCandidate) {
218
+ const rs = await import('../route-scan.js');
219
+ const mr = await import('../model-rules.js');
220
+ // A --project rule must land in THE project the pattern was detected
221
+ // in, not the cwd's. Old caches without projectPath: verify cwd match.
222
+ let targetRoot = null;
223
+ if (scope === 'project') {
224
+ if (routeCandidate.projectPath) {
225
+ targetRoot = findProjectRoot(routeCandidate.projectPath);
226
+ } else if (rs.mungeProjectPath(findProjectRoot()) === routeCandidate.project) {
227
+ targetRoot = findProjectRoot();
228
+ } else {
229
+ console.error(`Route candidate R${routeCandidateId} was detected in another project (${routeCandidate.project}),`);
230
+ console.error('but this cached scan predates project-path tracking.');
231
+ console.error('Re-scan to capture it, then promote again:');
232
+ console.error(' claude-token-saver route-scan --refresh');
233
+ process.exit(1);
234
+ }
235
+ }
236
+ const entry = mr.addModelRule({
237
+ signature: routeCandidate.signature,
238
+ tier: routeCandidate.tier || 'T2',
239
+ category: routeCandidate.category,
240
+ label: routeCandidate.label,
241
+ labelEn: routeCandidate.labelEn,
242
+ agent: routeCandidate.agent,
243
+ scope,
244
+ targetRoot,
245
+ project: routeCandidate.project,
246
+ rule: lang === 'ko' ? routeCandidate.rule : (routeCandidate.ruleEn || routeCandidate.rule),
247
+ example: routeCandidate.example,
248
+ count: routeCandidate.count,
249
+ // Calibrated budget snapshot β€” ratchet-model.md restates it when it
250
+ // merges a category's T2 and T1 rules into one conditional rule.
251
+ budget: routeCandidate.budget || null,
252
+ promotedAt: new Date().toISOString().slice(0, 10),
253
+ lastSeen: new Date().toISOString().slice(0, 10),
254
+ });
255
+ const written = mr.syncAllFiles();
256
+ rs.resolveCandidate(routeCandidateId);
257
+ console.log(`Model-fitting rule registered [${scope}${targetRoot ? ` β†’ ${targetRoot}` : ''}] (tier ${entry.tier}):`);
258
+ console.log(` - ${entry.rule}`);
259
+ for (const p of written) console.log(` ratchet-model.md updated: ${p}`);
260
+ console.log(lang === 'ko'
261
+ ? `(route candidate R${routeCandidateId} resolved β€” λ‹€μŒ μ„Έμ…˜λΆ€ν„° μžλ™ μœ„μž„, 이후 μŠ€μΊ”λ§ˆλ‹€ 둜그 기반 κ°±μ‹ λ©λ‹ˆλ‹€)`
262
+ : `(route candidate R${routeCandidateId} resolved β€” delegation applies from the next session, refreshed from logs on every rescan)`);
263
+ console.log(lang === 'ko'
264
+ ? 'λ£° λͺ©λ‘/제거: claude-token-saver route-scan rules [rm <N>]'
265
+ : 'List / remove: claude-token-saver route-scan rules [rm <N>]');
266
+ // Event-triggered refresh: establish the new rule's stat baseline
267
+ // right away instead of waiting for the next data-gated rescan.
268
+ try {
269
+ const { spawn } = await import('node:child_process');
270
+ spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
271
+ { detached: true, stdio: 'ignore', windowsHide: true }).unref();
272
+ } catch (e) { debug('promote:spawn-refresh', e); /* baseline arrives on the next gated rescan */ }
273
+ return;
274
+ }
275
+ const r = harnessPromote(rule, { scope });
276
+ console.log(`Appended to ${r.path} [${r.scope}]:`);
277
+ console.log(` - ${rule}`);
278
+ if (/^\d+$/.test(raw)) {
279
+ console.log(lang === 'ko'
280
+ ? '\nπŸ‘‰ ratchet.mdλ₯Ό μ—΄μ–΄ TODO 뢀뢄을 μ‹€μ œ 룰둜 λ‹€λ“¬μ–΄μ£Όμ„Έμš”.'
281
+ : '\nπŸ‘‰ Open ratchet.md and turn the TODO into the actual rule.');
282
+ }
283
+ return;
284
+ }
285
+
286
+ if (sub === 'analyze') {
287
+ // Run the analyzer once against the most recent session JSONL under
288
+ // ~/.claude/projects/ and dump the resulting state. Useful for users
289
+ // who don't have the hook installed but want to see warnings.
290
+ const analyzer = await import('../harness-analyzer.cjs');
291
+ const { analyzeTranscript, writeState } = analyzer.default || analyzer;
292
+ const { readdirSync, statSync } = await import('node:fs');
293
+ const { join: pj } = await import('node:path');
294
+ const { homedir } = await import('node:os');
295
+ const dir = pj(homedir(), '.claude', 'projects');
296
+ let latest = null;
297
+ let latestMtime = 0;
298
+ try {
299
+ for (const subdir of readdirSync(dir)) {
300
+ const full = pj(dir, subdir);
301
+ if (!statSync(full).isDirectory()) continue;
302
+ for (const f of readdirSync(full)) {
303
+ if (!f.endsWith('.jsonl')) continue;
304
+ const fp = pj(full, f);
305
+ const m = statSync(fp).mtimeMs;
306
+ if (m > latestMtime) { latestMtime = m; latest = fp; }
307
+ }
308
+ }
309
+ } catch (e) { debug('harness:analyze-scan', e); }
310
+ if (!latest) {
311
+ console.error('No session transcripts found under ~/.claude/projects/');
312
+ process.exit(1);
313
+ }
314
+ const state = analyzeTranscript(latest, { cwd: process.cwd() });
315
+ if (state) writeState(state);
316
+ console.log(JSON.stringify(state, null, 2));
317
+ return;
318
+ }
319
+
320
+ if (sub === 'uninit' || sub === 'remove') {
321
+ const scope = parseHarnessScope(args.slice(2), 'project');
322
+ const purgeRatchet = args.includes('--purge-ratchet');
323
+ const r = harnessUninit({ purgeRatchet, scope });
324
+ console.log(`Scope: ${scope}${scope === 'global' ? ' (~/.claude/CLAUDE.md)' : ` (${r.root})`}`);
325
+ r.removed.forEach((f) => console.log(` removed: ${f}`));
326
+ r.backedUp.forEach((f) => console.log(` backup: ${f}`));
327
+ r.skipped.forEach((f) => console.log(` skip: ${f}`));
328
+ if (r.removed.length === 0) console.log('Nothing to remove.');
329
+ return;
330
+ }
331
+
332
+ if (sub === 'pull') {
333
+ // Register the package's curated preset rules (presets/ratchet-rules.md)
334
+ // into the user's ratchet β€” global by default (they're tool/environment
335
+ // rules, and a project ratchet inherits global anyway). Strictly opt-in:
336
+ // install/init never auto-injects rules.
337
+ const scope = parseHarnessScope(args.slice(2), 'global');
338
+ const r = harnessPull({ scope });
339
+ console.log(`Curated preset rules β†’ ${r.path} [${r.scope}]`);
340
+ if (r.added.length) {
341
+ console.log(`βœ… ${r.added.length}/${r.presets} rule(s) registered:`);
342
+ for (const t of r.added) console.log(` - ${t}`);
343
+ } else {
344
+ console.log(`No new rules β€” all ${r.presets} presets already registered.`);
345
+ }
346
+ if (r.skippedRules && r.added.length) console.log(` (${r.skippedRules} already present β€” skipped)`);
347
+ console.log(lang === 'ko'
348
+ ? '\nν•„μš” μ—†λŠ” 룰은 μ–Έμ œλ“ : claude-token-saver harness list / rm <N>'
349
+ : '\nDrop any rule you do not want: claude-token-saver harness list / rm <N>');
350
+ return;
351
+ }
352
+
353
+ if (sub === 'list' || sub === 'ls') {
354
+ const wantGlobal = hasFlag('--global');
355
+ const wantProject = hasFlag('--project') || !wantGlobal;
356
+ const print = (scope) => {
357
+ const { path, rules } = harnessListRules({ scope });
358
+ if (!rules.length) {
359
+ console.log(`No ratchet rules in ${path} [${scope}]`);
360
+ return;
361
+ }
362
+ console.log(`πŸ“‹ Ratchet rules [${scope}] β€” ${path}\n`);
363
+ for (const r of rules) console.log(` #${r.index} ${r.text}`);
364
+ console.log('');
365
+ };
366
+ if (wantProject) print('project');
367
+ if (wantGlobal) print('global');
368
+ console.log('Remove with: claude-token-saver harness rm [--global|--project] <N>');
369
+ console.log('Archive in bulk: claude-token-saver harness prune [--global] [--tag <t>] [--older-than <months>] [--dry-run]');
370
+ return;
371
+ }
372
+
373
+ if (sub === 'prune') {
374
+ const pruneScope = parseHarnessScope(args.slice(2), 'project');
375
+ const argv = args.slice(2);
376
+ const valueOf = (flag) => {
377
+ const i = argv.indexOf(flag);
378
+ if (i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--')) return argv[i + 1];
379
+ const eq = argv.find((a) => a.startsWith(flag + '='));
380
+ return eq ? eq.slice(flag.length + 1) : null;
381
+ };
382
+ const months = valueOf('--older-than');
383
+ if (months !== null && !/^\d+$/.test(months)) {
384
+ console.error(`Invalid --older-than value: ${months} (expected a number of months)`);
385
+ process.exit(1);
386
+ }
387
+ const r = harnessPrune({
388
+ scope: pruneScope,
389
+ tag: valueOf('--tag'),
390
+ olderThanMonths: months ? parseInt(months, 10) : null,
391
+ dryRun: hasFlag('--dry-run'),
392
+ });
393
+ if (!r.ok) { console.error(r.error); process.exit(1); }
394
+ if (!r.pruned.length) { console.log(`Nothing matched β€” ${r.path} unchanged.`); return; }
395
+ console.log(`${r.dryRun ? 'Would prune' : 'Pruned'} ${r.pruned.length} rule(s) from ${r.path}:`);
396
+ for (const p of r.pruned) console.log(` #${p.index} ${p.text.slice(0, 100)}`);
397
+ if (!r.dryRun) {
398
+ console.log(`\nArchived to: ${r.archive} (backup: ${r.backup})`);
399
+ console.log('Archived rules are NOT loaded into sessions β€” paste one back into ratchet.md to restore it.');
400
+ }
401
+ return;
402
+ }
403
+
404
+ if (sub === 'rm') {
405
+ const rmScope = hasFlag('--global') ? 'global' : 'project';
406
+ const rmArgs = args.slice(2).filter((a) => a !== '--global' && a !== '--project');
407
+ const raw = (rmArgs[0] || '').trim();
408
+ if (!/^\d+$/.test(raw)) {
409
+ console.error('Usage: claude-token-saver harness rm [--global|--project] <N> # N from `harness list`');
410
+ process.exit(1);
411
+ }
412
+ const n = parseInt(raw, 10);
413
+ // ⚠️ Heads-up before deletion. Ratchet's value is one-way accumulation β€”
414
+ // dropping a rule is sometimes right, but more often the rule is just
415
+ // too narrow. Surface the alternative loudly here.
416
+ if (lang === 'ko') {
417
+ console.log('⚠️ 주의: ratchet λ£° μ‚­μ œλŠ” μ‹ μ€‘ν•˜κ²Œ.');
418
+ console.log(' 같은 μ‹€μˆ˜κ°€ 또 λ°œμƒν•  κ°€λŠ₯성이 ν½λ‹ˆλ‹€. 보톡은 "쑰건이 λ„ˆλ¬΄ μ’μ•„μ„œ"');
419
+ console.log(' λ¬Έμ œκ°€ λ˜λŠ” κ²½μš°κ°€ λ§Žμ•„μš”. μ§€μš°κΈ° 전에 ν•œ 번 더 κ²€ν† ν•˜μ„Έμš”:');
420
+ console.log(' - 룰이 λ„ˆλ¬΄ κ΄‘λ²”μœ„ν•΄μ„œ 정상 μΌ€μ΄μŠ€λ„ λ§‰λ‚˜? β†’ 쑰건을 μ’ν˜€μ„œ 닀듬기');
421
+ console.log(' - 룰이 λ„ˆλ¬΄ μ’μ•„μ„œ 거의 λ°œλ™ μ•ˆ λ˜λ‚˜? β†’ κ·Έλƒ₯ 두기 (λΉ„μš© 0)');
422
+ console.log(' - 정말 잘λͺ»λœ 룰이라 ν™•μ‹ ? β†’ κ·Έλ•Œλ§Œ μ‚­μ œ');
423
+ } else {
424
+ console.log('⚠️ Careful: removing a ratchet rule is rarely the fix.');
425
+ console.log(' The mistake it guards against tends to come back. Usually the');
426
+ console.log(' problem is that the rule is worded too narrowly. Check first:');
427
+ console.log(' - Too broad, blocking legitimate cases? β†’ tighten the condition');
428
+ console.log(' - Too narrow, almost never fires? β†’ leave it (it costs nothing)');
429
+ console.log(' - Genuinely wrong? β†’ only then delete it');
430
+ }
431
+ console.log('');
432
+ const r = harnessRmRule(n, { scope: rmScope });
433
+ if (!r.ok) {
434
+ console.error(`❌ ${r.error}`);
435
+ if (r.rules) {
436
+ console.error('Available:');
437
+ for (const x of r.rules) console.error(` #${x.index} ${x.text}`);
438
+ }
439
+ process.exit(1);
440
+ }
441
+ console.log(`βœ… Removed #${n}: ${r.removed.text}`);
442
+ console.log(` Backup: ${r.backup}`);
443
+ console.log(` 볡ꡬ: cp "${r.backup}" "${r.path}"`);
444
+ return;
445
+ }
446
+
447
+ if (sub === 'off' || sub === 'on') {
448
+ const cfg = loadConfig();
449
+ cfg.harness = cfg.harness || {};
450
+ cfg.harness.enabled = sub === 'on';
451
+ saveConfig(cfg);
452
+ console.log(`Statusline πŸ…· segment: ${sub}`);
453
+ return;
454
+ }
455
+
456
+ console.error(`Unknown harness subcommand: ${sub}`);
457
+ console.error('Usage: claude-token-saver harness [check|init|uninit [--purge-ratchet]|promote "<rule>"|pull [--global|--project]|list|rm <N>|prune [--tag <t>] [--older-than <months>] [--dry-run]|off|on]');
458
+ process.exit(1);
459
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Subcommand: history β€” print recent warning transitions captured by the
3
+ * statusline. One markdown file per day, persisted under the platform-
4
+ * specific user-data dir.
5
+ * claude-token-saver history # last 7 days
6
+ * claude-token-saver history --days 30 # custom window
7
+ * claude-token-saver history --list # just list available dates
8
+ */
9
+
10
+
11
+ export async function run({ hasFlag, numArg }) {
12
+ const { readRecent, listDates, historyDir, formatHistoryForLanguage } =
13
+ await import('../history.js');
14
+ const { userLanguage } = await import('../config.js');
15
+ const lang = userLanguage();
16
+ if (hasFlag('--list')) {
17
+ const dates = listDates();
18
+ if (dates.length === 0) {
19
+ console.log(lang === 'ko'
20
+ ? `νžˆμŠ€ν† λ¦¬κ°€ 아직 μ—†μŠ΅λ‹ˆλ‹€. νŒŒμΌμ€ λ‹€μŒ μœ„μΉ˜μ— μƒμ„±λ©λ‹ˆλ‹€: ${historyDir()}`
21
+ : `No history yet. Files will appear under: ${historyDir()}`);
22
+ return;
23
+ }
24
+ console.log(lang === 'ko' ? `νžˆμŠ€ν† λ¦¬ (${historyDir()}):` : `History (${historyDir()}):`);
25
+ for (const d of dates) console.log(` ${d}`);
26
+ return;
27
+ }
28
+ const days = numArg('--days', { dflt: 7, min: 0 });
29
+ const recent = readRecent(days);
30
+ if (recent.length === 0) {
31
+ if (lang === 'ko') {
32
+ console.log(`졜근 ${days}일 λ‚΄ κ²½κ³  νžˆμŠ€ν† λ¦¬κ°€ μ—†μŠ΅λ‹ˆλ‹€.`);
33
+ console.log(`(파일이 생성될 μœ„μΉ˜: ${historyDir()})`);
34
+ } else {
35
+ console.log(`No warning history in the last ${days} day${days === 1 ? '' : 's'}.`);
36
+ console.log(`(Files would be written to: ${historyDir()})`);
37
+ }
38
+ return;
39
+ }
40
+ for (const { content } of recent) {
41
+ const filtered = formatHistoryForLanguage(content, lang);
42
+ console.log(filtered.replace(/\n+$/, ''));
43
+ console.log('');
44
+ }
45
+ return;
46
+ }