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.
- package/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- 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
|
+
}
|