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,264 @@
1
+ /**
2
+ * Harness analyzer — scans a session transcript JSONL for warning signals
3
+ * and writes a small state file the statusline can read cheaply.
4
+ *
5
+ * Three signals (precedence: ratchet? > no-evidence > PEV-skip):
6
+ *
7
+ * 1. Ratchet candidate — same is_error tool_use_result appears 2+ times in
8
+ * the last 30 turns. Suggests the user codify a rule so it doesn't repeat.
9
+ *
10
+ * 2. Evidence rate — fraction of recent assistant messages that ship proof
11
+ * (code blocks, tool_use_result, "test"/"output"/"diff"/"screenshot"
12
+ * keywords). <30% → ⚠ no-evidence — high chance the model is reporting
13
+ * "done" without showing it.
14
+ *
15
+ * 3. PEV-skip — many *mutating* tool_use calls (Edit/Write/Bash…, 5+) in the
16
+ * last 15 assistant turns with no plan signal (no TodoWrite, no
17
+ * "plan"/"Phase"/"단계" mention). Suggests the model is racing through
18
+ * edits without a verify pass. Read-only exploration (Read/Grep/Glob)
19
+ * deliberately doesn't count — reading five files is research, not racing.
20
+ *
21
+ * CommonJS so hook.cjs can `require()` it without a bundler step.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+ const os = require('node:os');
29
+
30
+ const STATE_DIR = stateDir();
31
+ const STATE_PATH = path.join(STATE_DIR, 'harness-state.json');
32
+
33
+ const RECENT_TURNS = 15; // PEV / evidence window (assistant turns)
34
+ const RATCHET_TURNS = 30; // ratchet-candidate window
35
+ const EVIDENCE_THRESHOLD = 0.3; // <30% → ⚠ no-evidence
36
+ const PEV_TOOLUSE_THRESHOLD = 5;
37
+ // Tools that change state. Only these count toward PEV-skip — an agentic
38
+ // session trivially racks up 5+ *read* tool calls (Read/Grep/Glob) while
39
+ // researching, which is exactly the behavior we don't want to punish.
40
+ const MUTATING_TOOL_RE = /^(edit|write|multiedit|notebookedit|bash)$/i;
41
+
42
+ // Mirrors src/paths.js userDataDir() exactly (same order as doc2md.cjs).
43
+ // Duplicated because this file is CommonJS and paths.js is ESM; the
44
+ // precedence must match or state lands where the rest of the tool won't look.
45
+ function stateDir() {
46
+ if (process.env.XDG_CONFIG_HOME) {
47
+ return path.join(process.env.XDG_CONFIG_HOME, 'claude-token-saver');
48
+ }
49
+ if (process.platform === 'win32' && process.env.APPDATA) {
50
+ return path.join(process.env.APPDATA, 'claude-token-saver');
51
+ }
52
+ if (process.platform === 'darwin') {
53
+ return path.join(os.homedir(), 'Library', 'Application Support', 'claude-token-saver');
54
+ }
55
+ return path.join(os.homedir(), '.config', 'claude-token-saver');
56
+ }
57
+
58
+ function readJsonl(file) {
59
+ let raw;
60
+ try {
61
+ raw = fs.readFileSync(file, 'utf8');
62
+ } catch {
63
+ return [];
64
+ }
65
+ const out = [];
66
+ for (const line of raw.split('\n')) {
67
+ if (!line.trim()) continue;
68
+ try {
69
+ out.push(JSON.parse(line));
70
+ } catch {
71
+ // ignore corrupt line
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * Pull the text payload out of an assistant message, regardless of whether
79
+ * Claude Code stored it as a string, a content-block array, or a mix.
80
+ */
81
+ function assistantText(msg) {
82
+ if (!msg) return '';
83
+ if (typeof msg.content === 'string') return msg.content;
84
+ if (Array.isArray(msg.content)) {
85
+ return msg.content
86
+ .map((b) => {
87
+ if (typeof b === 'string') return b;
88
+ if (b && b.type === 'text') return b.text || '';
89
+ return '';
90
+ })
91
+ .join('\n');
92
+ }
93
+ return '';
94
+ }
95
+
96
+ function toolUsesIn(msg) {
97
+ if (!msg || !Array.isArray(msg.content)) return [];
98
+ return msg.content.filter((b) => b && b.type === 'tool_use');
99
+ }
100
+
101
+ function toolResultsIn(msg) {
102
+ if (!msg || !Array.isArray(msg.content)) return [];
103
+ return msg.content.filter((b) => b && b.type === 'tool_result');
104
+ }
105
+
106
+ function looksLikeEvidence(text) {
107
+ if (!text) return false;
108
+ // Code block (``` …) is the cheapest evidence signal — almost always
109
+ // present when the assistant shows actual command output or a diff.
110
+ if (/```[\s\S]*?```/.test(text)) return true;
111
+ const lower = text.toLowerCase();
112
+ // Korean + English keywords. Order doesn't matter — first hit wins.
113
+ const evidenceWords = [
114
+ 'stdout', 'output', 'diff', 'screenshot', 'passed', 'pytest', 'jest',
115
+ 'test result', 'verified', 'verifying',
116
+ '출력', '결과', '스크린샷', '통과', '검증', '확인했', '확인됨',
117
+ ];
118
+ return evidenceWords.some((w) => lower.includes(w));
119
+ }
120
+
121
+ function looksLikePlanSignal(text, toolUses) {
122
+ if (toolUses.some((t) => /^todowrite$/i.test(t.name || ''))) return true;
123
+ if (!text) return false;
124
+ const lower = text.toLowerCase();
125
+ const planWords = ['plan', 'phase', 'step 1', 'step1', 'first,', 'next,',
126
+ '단계', '계획', '먼저', '다음으로', '순서대로'];
127
+ return planWords.some((w) => lower.includes(w));
128
+ }
129
+
130
+ function errorSignature(toolResult) {
131
+ if (!toolResult || toolResult.is_error !== true) return null;
132
+ const c = toolResult.content;
133
+ let txt = '';
134
+ if (typeof c === 'string') txt = c;
135
+ else if (Array.isArray(c)) {
136
+ txt = c.map((b) => (b && typeof b.text === 'string' ? b.text : '')).join(' ');
137
+ }
138
+ txt = txt.replace(/\s+/g, ' ').trim();
139
+ if (!txt) return null;
140
+ // First 80 chars is enough to dedupe most repeated errors without overfitting
141
+ // to volatile bits like timestamps or pids.
142
+ return txt.slice(0, 80);
143
+ }
144
+
145
+ /**
146
+ * Walk the last `RATCHET_TURNS` turns and surface error signatures that
147
+ * appear 2+ times. Returns the top candidate (or null).
148
+ */
149
+ function findRatchetCandidates(entries) {
150
+ const counts = new Map();
151
+ const recent = entries.slice(-RATCHET_TURNS);
152
+ for (const e of recent) {
153
+ const msg = e && e.message;
154
+ if (!msg) continue;
155
+ for (const tr of toolResultsIn(msg)) {
156
+ const sig = errorSignature(tr);
157
+ if (!sig) continue;
158
+ const cur = counts.get(sig) || { count: 0, lastAt: e.timestamp };
159
+ cur.count += 1;
160
+ cur.lastAt = e.timestamp || cur.lastAt;
161
+ counts.set(sig, cur);
162
+ }
163
+ }
164
+ const list = [];
165
+ for (const [sig, info] of counts.entries()) {
166
+ if (info.count < 2) continue;
167
+ list.push({ pattern: sig, count: info.count, lastAt: info.lastAt });
168
+ }
169
+ // Top 5 by count, ID assigned in rank order so `harness promote 1` always
170
+ // targets the most-repeated error — stable even as new candidates appear.
171
+ list.sort((a, b) => b.count - a.count);
172
+ return list.slice(0, 5).map((c, i) => ({ id: i + 1, ...c }));
173
+ }
174
+
175
+ /**
176
+ * Evidence rate — over the last RECENT_TURNS *assistant* messages, the
177
+ * fraction that ship proof (code block / keywords). Tool_result blocks in
178
+ * the immediate next user message also count as "shown the work."
179
+ */
180
+ function computeEvidenceRate(entries) {
181
+ // Window by *assistant turns*, not raw JSONL entries — one agentic turn can
182
+ // span dozens of entries, so an entry-sliced window covered only 1-2 real
183
+ // turns and made the rate jumpy.
184
+ const idxs = [];
185
+ for (let i = 0; i < entries.length; i++) {
186
+ if (entries[i] && entries[i].type === 'assistant') idxs.push(i);
187
+ }
188
+ const recentIdxs = idxs.slice(-RECENT_TURNS);
189
+ if (recentIdxs.length === 0) return null;
190
+ let proofCount = 0;
191
+ for (const i of recentIdxs) {
192
+ const text = assistantText(entries[i].message);
193
+ let proof = looksLikeEvidence(text);
194
+ // If the *next* entry is a user message with tool_result blocks, count
195
+ // that as evidence for the assistant turn that triggered it.
196
+ const next = entries[i + 1];
197
+ if (!proof && next && next.type === 'user' && toolResultsIn(next.message).length > 0) {
198
+ proof = true;
199
+ }
200
+ if (proof) proofCount++;
201
+ }
202
+ return proofCount / recentIdxs.length;
203
+ }
204
+
205
+ function computePevSkip(entries) {
206
+ const assistants = entries.filter((e) => e && e.type === 'assistant');
207
+ const recent = assistants.slice(-RECENT_TURNS);
208
+ let mutatingCount = 0;
209
+ let planSignal = false;
210
+ for (const e of recent) {
211
+ const text = assistantText(e.message);
212
+ const tus = toolUsesIn(e.message);
213
+ mutatingCount += tus.filter((t) => MUTATING_TOOL_RE.test(t.name || '')).length;
214
+ if (looksLikePlanSignal(text, tus)) planSignal = true;
215
+ }
216
+ return mutatingCount >= PEV_TOOLUSE_THRESHOLD && !planSignal;
217
+ }
218
+
219
+ function analyzeTranscript(transcriptPath, opts) {
220
+ opts = opts || {};
221
+ const entries = readJsonl(transcriptPath);
222
+ if (entries.length === 0) return null;
223
+ const evidenceRate = computeEvidenceRate(entries);
224
+ const pevSkip = computePevSkip(entries);
225
+ const ratchetCandidates = findRatchetCandidates(entries);
226
+ return {
227
+ sessionId: opts.sessionId || null,
228
+ cwd: opts.cwd || null,
229
+ transcriptPath: transcriptPath,
230
+ timestamp: new Date().toISOString(),
231
+ evidenceRate: evidenceRate,
232
+ evidenceLow: evidenceRate !== null && evidenceRate < EVIDENCE_THRESHOLD,
233
+ pevSkip: pevSkip,
234
+ ratchetCandidate: ratchetCandidates[0] || null, // back-compat
235
+ ratchetCandidates: ratchetCandidates,
236
+ };
237
+ }
238
+
239
+ function writeState(state) {
240
+ if (!state) return;
241
+ try {
242
+ if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
243
+ fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + '\n');
244
+ } catch {
245
+ // best-effort
246
+ }
247
+ }
248
+
249
+ function readState() {
250
+ try {
251
+ if (!fs.existsSync(STATE_PATH)) return null;
252
+ return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ module.exports = {
259
+ analyzeTranscript,
260
+ writeState,
261
+ readState,
262
+ STATE_PATH,
263
+ EVIDENCE_THRESHOLD,
264
+ };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Harness templates — single-file CLAUDE.md (5 sections) + ratchet.md.
3
+ *
4
+ * Section markers (HARNESS_SECTIONS) are the source of truth for completeness
5
+ * detection: harness/check counts how many of these headers appear in the
6
+ * project's CLAUDE.md, and the statusline 🅷 N/5 indicator reports the same.
7
+ */
8
+
9
+ export const HARNESS_BLOCK_BEGIN = '<!-- claude-token-saver:harness:begin -->';
10
+ export const HARNESS_BLOCK_END = '<!-- claude-token-saver:harness:end -->';
11
+
12
+ export const HARNESS_SECTIONS = [
13
+ { id: 'ratchet', heading: '### 1. Ratchet — 같은 실수는 두 번 안 한다' },
14
+ { id: 'evidence', heading: '### 2. Evidence — "다 됐어요" 금지' },
15
+ { id: 'pev', heading: '### 3. PEV — Plan → Execute → Verify' },
16
+ { id: 'structured', heading: '### 4. Structured Task — 입력 구조화' },
17
+ { id: 'safe-path', heading: '### 5. Default Safe Path — 파괴적 명령 항상 확인' },
18
+ ];
19
+
20
+ /**
21
+ * The `@` import that actually loads ratchet.md into the session.
22
+ *
23
+ * Without this line the promoted rules live in a file nobody reads: Claude Code
24
+ * only loads CLAUDE.md (plus whatever it imports), so `harness promote` was a
25
+ * write-only operation before v3.6.3. Project scope imports the repo-local
26
+ * ratchet, global scope the user-level one — mirroring resolveRatchetPath().
27
+ */
28
+ export function ratchetImportLine(scope = 'project') {
29
+ return scope === 'global' ? '@~/.claude/ratchet.md' : '@.claude/ratchet.md';
30
+ }
31
+
32
+ /**
33
+ * The model-fitting ratchet is imported too, so the delegation rules reach the
34
+ * model through the same declared path as everything else instead of relying on
35
+ * a host that happens to pick the file up. harnessInit() seeds an empty
36
+ * ratchet-model.md and syncAllFiles() empties rather than deletes it, so this
37
+ * import never dangles.
38
+ */
39
+ export function modelRatchetImportLine(scope = 'project') {
40
+ return scope === 'global' ? '@~/.claude/ratchet-model.md' : '@.claude/ratchet-model.md';
41
+ }
42
+
43
+ // Matches either scope's import line, so completeness checks don't need to know
44
+ // which scope wrote the block. `(?!-)` keeps the ratchet-model import from
45
+ // counting as the ratchet.md one.
46
+ export const RATCHET_IMPORT_RE = /^@(?:~\/\.claude|\.claude)\/ratchet\.md\s*$/m;
47
+ export const MODEL_RATCHET_IMPORT_RE = /^@(?:~\/\.claude|\.claude)\/ratchet-model\.md\s*$/m;
48
+
49
+ export function harnessClaudeMdBlock(scope = 'project') {
50
+ const sections = HARNESS_SECTIONS.map((s) => s.heading).join('\n\n... (see full block below)');
51
+ return `${HARNESS_BLOCK_BEGIN}
52
+ ## 🅷 Harness Rules (claude-token-saver)
53
+
54
+ 이 섹션은 \`claude-token-saver harness init\`이 생성합니다. 5가지 원칙 모두를
55
+ 지키면 statusline에 \`🅷 5/5\`로 표시되고, 빠진 게 있으면 \`🅷 3/5\` 식으로
56
+ 경고합니다. 수정해도 무방하지만, 섹션 헤더(### 1. ~ ### 5.)는 검출용이므로
57
+ 지우지 마세요.
58
+
59
+ ${HARNESS_SECTIONS[0].heading}
60
+ - 같은 에러·오해·반복 작업이 한 번 더 발생하면 즉시 \`.claude/ratchet.md\`에
61
+ "조건 → 행동" 한 줄로 룰 추가.
62
+ - claude-token-saver가 후보를 감지하면 statusline에 \`🅷⚠ ratchet?\`로 알림.
63
+ \`claude-token-saver harness promote "<rule>" --project|--global\`로 승인.
64
+ - **scope는 항상 사용자에게 먼저 물어볼 것** — 프로젝트 한정이면 \`--project\`,
65
+ 도구·환경 일반 룰이면 \`--global\`(\`~/.claude/ratchet.md\`). Bash 환경은
66
+ non-TTY라 CLI의 readline 프롬프트가 안 뜨므로, 호출자(LLM)가 직접 묻고
67
+ 플래그를 명시해야 함. 묻지 않고 기본값으로 등록하지 말 것.
68
+ - 승인된 룰은 이 블록 맨 아래 \`@\` import로 매 세션 로드된다 — 그 import 라인을
69
+ 지우면 ratchet.md는 컨텍스트에 들어오지 않으니 지우지 말 것.
70
+ - **모델 피팅 랫쳇**: \`.claude/ratchet-model.md\`(프로젝트)와
71
+ \`~/.claude/ratchet-model.md\`(글로벌)에 있는 티어 위임 룰도 ratchet.md와
72
+ 동일하게 따를 것. 이 파일은 claude-token-saver가 로그 기반으로 자동
73
+ 생성·갱신하므로 직접 수정하지 말 것 (관리: \`route-scan rules\`).
74
+
75
+ ${HARNESS_SECTIONS[1].heading}
76
+ 완료 보고("다 됐어요", "테스트 통과") 시 다음 중 1개 이상을 항상 첨부:
77
+ - 테스트 실행 결과 (실제 stdout)
78
+ - 변경 파일 diff (file:line)
79
+ - UI 작업이면 스크린샷
80
+ - 명령 실행 출력
81
+
82
+ 증거 없는 완료 보고는 거짓일 확률이 매우 높음. 토큰 낭비의 주범.
83
+
84
+ ${HARNESS_SECTIONS[2].heading}
85
+ 3단계 이상 작업은 다음 사이클을 강제:
86
+ 1. **Plan** — 텍스트로 단계 명시 (TodoWrite 권장)
87
+ 2. **Execute** — 한 단계씩 실행, 결과 확인
88
+ 3. **Verify** — 테스트·실행·grep 등으로 결과 검증
89
+
90
+ Verify를 건너뛰면 statusline에 \`🅷⚠ PEV-skip\` 표시.
91
+ 0.85의 10제곱 ≈ 0.20 — 단계당 85%만 맞아도 10단계면 80% 실패.
92
+
93
+ ${HARNESS_SECTIONS[3].heading}
94
+ 새 작업 시작 시 다음 4줄을 먼저 채울 것 (입력이 구조화돼야 출력도 구조화됨):
95
+ - **목표:** 한 문장으로
96
+ - **제약:** 시간·범위·금지사항
97
+ - **검증 방법:** 어떻게 "됐다"고 판정할지
98
+ - **완료 기준:** 무엇이 통과하면 완료인지
99
+
100
+ ${HARNESS_SECTIONS[4].heading}
101
+ 다음 작업은 **항상** 사용자 확인 후 실행:
102
+ - 파괴적 명령: \`rm -rf\`, force push, drop table, kill process
103
+ - 외부 시스템: deploy, slack 발송, 댓글 작성, PR merge
104
+ - 비가역적: amend pushed commit, branch -D
105
+
106
+ 단순 read·local edit·테스트 실행은 묻지 말고 즉시 진행 (마찰 최소화).
107
+
108
+ ---
109
+
110
+ 📌 운영:
111
+ - \`claude-token-saver harness check\` — 현재 셋업 점수
112
+ - \`claude-token-saver harness promote "<룰>" --project|--global\` — ratchet에 룰 추가 (scope는 사용자에게 먼저 물어볼 것)
113
+ - \`claude-token-saver harness off\` — statusline 표시 끄기
114
+
115
+ ---
116
+
117
+ 📥 ratchet 룰 로드 (이 줄들을 지우면 룰이 적용되지 않습니다):
118
+
119
+ ${ratchetImportLine(scope)}
120
+ ${modelRatchetImportLine(scope)}
121
+ ${HARNESS_BLOCK_END}
122
+ `;
123
+ }
124
+
125
+ export function harnessRatchetMdInitial() {
126
+ return `# Ratchet Rules (auto-grown by claude-token-saver)
127
+
128
+ 같은 실수가 두 번 발생하면 여기에 한 줄 추가됩니다. 형식: "YYYY-MM-DD: <조건> → <행동>".
129
+
130
+ \`claude-token-saver harness promote "<rule>"\`로 룰을 추가하면 자동으로
131
+ 이 파일에 append 됩니다.
132
+
133
+ 이 파일은 CLAUDE.md의 \`@\` import로 매 세션 로드됩니다 — 즉 여기 있는 모든 줄이
134
+ 매 요청마다 토큰을 씁니다. 날짜 뒤에 \`[태그]\`를 붙여두면 나중에 묶어서 정리할 수
135
+ 있습니다: \`- 2026-05-08: [video,tts] ...\` →
136
+ \`claude-token-saver harness prune --tag video\` (삭제 아니라 ratchet-archive.md로 이동).
137
+
138
+ ## Rules
139
+
140
+ `;
141
+ }
142
+
143
+ /**
144
+ * Append a rule to ratchet.md content. Safe to call on the initial template
145
+ * or on a user-edited file: we just add to the end. Rules are dated.
146
+ */
147
+ export function appendRatchetRule(existing, ruleText) {
148
+ const today = new Date().toISOString().slice(0, 10);
149
+ const line = `- ${today}: ${ruleText.trim()}\n`;
150
+ // Ensure trailing newline so the new rule lands on its own line.
151
+ const base = existing.endsWith('\n') ? existing : existing + '\n';
152
+ return base + line;
153
+ }