sprag-cli 3.65.0 → 3.67.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/bin/cli.js +8 -1
- package/mod/sprag-doc2md/hooks/register.ts +34 -7
- package/mod/sprag-statusline/.claude-plugin/plugin.json +7 -0
- package/mod/sprag-statusline/hooks/hooks.json +3 -0
- package/mod/sprag-statusline/hooks/register.tsx +176 -0
- package/mod/sprag-statusline/types/index.d.ts +5 -0
- package/package.json +3 -2
- package/presets/ratchet-rules.json +7 -0
- package/src/agents.js +33 -11
- package/src/commands/cowork.js +107 -0
- package/src/commands/delegate.js +43 -0
- package/src/commands/doc2md.js +10 -1
- package/src/commands/install.js +13 -0
- package/src/commands/route-scan.js +6 -0
- package/src/commands/uninstall.js +4 -0
- package/src/cowork-sync.js +361 -0
- package/src/delegation-agents.js +133 -0
- package/src/doc2md.cjs +33 -28
- package/src/harness.js +1 -1
- package/src/implementation-delegation.js +5 -2
- package/src/installer.js +23 -1
- package/src/model-alias.js +66 -15
- package/src/model-rules.js +12 -5
- package/src/route-inject.js +65 -8
package/bin/cli.js
CHANGED
|
@@ -90,7 +90,7 @@ function tableColor() {
|
|
|
90
90
|
const KNOWN_SUBCOMMANDS = new Set([
|
|
91
91
|
'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean', 'cohesion',
|
|
92
92
|
'doc2md', 'delegate', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
|
|
93
|
-
'seed', 'litellm-budget', 'profile-map', 'feedback', 'saved',
|
|
93
|
+
'seed', 'litellm-budget', 'profile-map', 'feedback', 'saved', 'cowork',
|
|
94
94
|
]);
|
|
95
95
|
|
|
96
96
|
const USAGE = `sprag — Claude Code and Codex agent harness
|
|
@@ -116,6 +116,7 @@ Usage:
|
|
|
116
116
|
sprag uninstall --agent codex remove the global Codex integration
|
|
117
117
|
--agent claude|codex (default: claude; accepted before or after subcommands)
|
|
118
118
|
sprag route-scan detect recurring easy work → delegation candidates
|
|
119
|
+
sprag delegate agents [on|off] tool-restricted delegation agents (no MCP tools)
|
|
119
120
|
sprag profile-map show/refresh the gateway model map (LiteLLM /model/info)
|
|
120
121
|
sprag install set up everything (presets, and Codex when present) without asking
|
|
121
122
|
sprag install --manual confirm each optional step
|
|
@@ -127,6 +128,7 @@ Usage:
|
|
|
127
128
|
sprag handoff write a session handoff file
|
|
128
129
|
sprag mode [keywords] statusline preferences (icon/text, en/ko, window, ttl)
|
|
129
130
|
sprag seed register the bundled starter rules, one answer at a time
|
|
131
|
+
sprag cowork [sync|on|off|exclude <dir>|include <dir>] copy the ratchet rules into Cowork tab folders
|
|
130
132
|
sprag korean on|off|status Korean writing guidance + write-time check
|
|
131
133
|
sprag cohesion on|off|status English sentence-connection guidance
|
|
132
134
|
sprag doc2md on|off|<file> convert documents to Markdown before the model reads them
|
|
@@ -290,6 +292,11 @@ async function main() {
|
|
|
290
292
|
return (await import('../src/commands/seed.js')).run({ args, hasFlag });
|
|
291
293
|
}
|
|
292
294
|
|
|
295
|
+
// Subcommand: cowork — ratchet copies for the Claude app's Cowork tab.
|
|
296
|
+
if (args[0] === 'cowork') {
|
|
297
|
+
return (await import('../src/commands/cowork.js')).run({ args, hasFlag });
|
|
298
|
+
}
|
|
299
|
+
|
|
293
300
|
// Subcommand: saved — stopwatch reset for the lifetime "saved" counters.
|
|
294
301
|
// sprag saved | saved reset [routing|doc2md|all] | saved undo
|
|
295
302
|
if (args[0] === 'saved') {
|
|
@@ -14,6 +14,12 @@ import type { EngineInterface, Register } from 'claude-code'
|
|
|
14
14
|
* hook beneath it explains the refusal exactly as it does today. After a
|
|
15
15
|
* redirect that hook sees a .md path and stays silent, so the two never
|
|
16
16
|
* answer the same Read twice.
|
|
17
|
+
*
|
|
18
|
+
* The exception is an encrypted document (password or DRM). Claude Code
|
|
19
|
+
* refuses a pptx, xlsx or docx as a binary file before any settings hook runs,
|
|
20
|
+
* so the settings hook can never tell the model why such a file cannot be
|
|
21
|
+
* read; this mod is the only place that can, so it refuses the Read itself
|
|
22
|
+
* with the CLI's notice.
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
// Mirrors TARGET_EXTENSIONS in src/doc2md.cjs. A hooks module has no Node, so
|
|
@@ -34,13 +40,20 @@ const CONVERT_TIMEOUT_MS = 150_000
|
|
|
34
40
|
// prototype shortcut: a `--json` flag on that command should replace it.
|
|
35
41
|
const CONVERTED = /^✓ (cached|converted): (.+)$/m
|
|
36
42
|
|
|
43
|
+
// What `sprag doc2md <path>` prints on stderr for an encrypted document: the
|
|
44
|
+
// reason line, then one indented line holding the notice for the model. A CLI
|
|
45
|
+
// that predates the notice line prints the reason line alone.
|
|
46
|
+
const LOCKED = /^✗ (encrypted|drm-protected)\b.*$/m
|
|
47
|
+
|
|
37
48
|
type Converted = { cacheFile: string; isCached: boolean }
|
|
49
|
+
type Locked = { locked: string }
|
|
38
50
|
|
|
39
51
|
const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
|
|
40
52
|
|
|
41
53
|
/**
|
|
42
54
|
* Runs the conversion the settings hook would run, ledger entry included.
|
|
43
|
-
* Answers null on any failure so the caller passes the event on unchanged
|
|
55
|
+
* Answers null on any failure so the caller passes the event on unchanged,
|
|
56
|
+
* and `{ locked }` with the CLI's notice when the document is encrypted.
|
|
44
57
|
*
|
|
45
58
|
* A first conversion can take a minute, and nothing else on screen says the
|
|
46
59
|
* Read is waiting on it, so the status line under the prompt names the file
|
|
@@ -48,11 +61,17 @@ const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
|
|
|
48
61
|
* the file is cached is unknown until the CLI answers, and a log line on every
|
|
49
62
|
* Read would double the lines for the cached ones too.
|
|
50
63
|
*/
|
|
51
|
-
async function convert($: EngineInterface, path: string): Promise<Converted | null> {
|
|
64
|
+
async function convert($: EngineInterface, path: string): Promise<Converted | Locked | null> {
|
|
52
65
|
$.ui.status(`doc2md: ${baseName(path)} 변환 중…`)
|
|
53
66
|
try {
|
|
54
67
|
const run = await $.process.run([CLI, 'doc2md', path], { timeoutMs: CONVERT_TIMEOUT_MS })
|
|
55
|
-
if (run.exitCode !== 0)
|
|
68
|
+
if (run.exitCode !== 0) {
|
|
69
|
+
const reason = LOCKED.exec(run.stderr)
|
|
70
|
+
if (!reason) return null
|
|
71
|
+
const rest = run.stderr.slice(reason.index + reason[0].length)
|
|
72
|
+
const notice = rest.split('\n').map(line => line.trim()).find(line => line !== '')
|
|
73
|
+
return notice ? { locked: notice } : null
|
|
74
|
+
}
|
|
56
75
|
const match = CONVERTED.exec(run.stdout)
|
|
57
76
|
if (!match) return null
|
|
58
77
|
return { cacheFile: match[2].trim(), isCached: match[1] === 'cached' }
|
|
@@ -63,19 +82,25 @@ async function convert($: EngineInterface, path: string): Promise<Converted | nu
|
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
84
|
|
|
85
|
+
const isLocked = (doc: Converted | Locked): doc is Locked => 'locked' in doc
|
|
86
|
+
|
|
66
87
|
function logRedirect($: EngineInterface, from: string, doc: Converted) {
|
|
67
88
|
$.ui.log(`doc2md: ${baseName(from)} 대신 변환본을 읽습니다${doc.isCached ? ' (캐시)' : ''}`)
|
|
68
89
|
}
|
|
69
90
|
|
|
70
91
|
export const register: Register = on => {
|
|
71
|
-
// Both hooks fail open, which is the point of the .catch on each:
|
|
72
|
-
//
|
|
73
|
-
//
|
|
92
|
+
// Both hooks fail open, which is the point of the .catch on each: a hook that
|
|
93
|
+
// breaks hands the event on as it came and sprag's settings hook beneath
|
|
94
|
+
// answers as before. Only the encrypted-document refusal in the Read hook guards
|
|
95
|
+
// anything, and failing open there costs the notice, not safety.
|
|
74
96
|
// Where the hook had already called next, next(e) replays that call.
|
|
75
97
|
on('tool.call', { tool: 'Read' }, async ($, e, next) => {
|
|
76
98
|
if (!TARGET.test(e.file_path)) return next(e)
|
|
77
99
|
const doc = await convert($, e.file_path)
|
|
78
100
|
if (!doc) return next(e)
|
|
101
|
+
// Refuse rather than pass the original on: it cannot be opened, and the
|
|
102
|
+
// binary-file refusal it would meet says nothing of why.
|
|
103
|
+
if (isLocked(doc)) return { deny: `[doc2md] ${doc.locked}` }
|
|
79
104
|
logRedirect($, e.file_path, doc)
|
|
80
105
|
// Line and page ranges addressed the binary and mean nothing in the
|
|
81
106
|
// Markdown (`pages` applies to PDFs only), so the whole file is read.
|
|
@@ -87,7 +112,9 @@ export const register: Register = on => {
|
|
|
87
112
|
// Read hook above never sees it. Redirect the attachment the same way.
|
|
88
113
|
on('prompt.mention', { path: TARGET }, async ($, e, next) => {
|
|
89
114
|
const doc = await convert($, e.path)
|
|
90
|
-
|
|
115
|
+
// A deny here would only reach the debug log, not the model, so an
|
|
116
|
+
// encrypted mention goes on as it came; the prompt hook names it instead.
|
|
117
|
+
if (!doc || isLocked(doc)) return next(e)
|
|
91
118
|
logRedirect($, e.path, doc)
|
|
92
119
|
// A mention carries no `pages` (PromptMentionInput has mention, path,
|
|
93
120
|
// offset, limit and agentId), so only the line range is dropped here.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sprag-statusline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Draws sprag's statusline above the prompt in the Claude desktop app, which never runs the statusLine command, and keeps the snapshots the statusline leaves behind",
|
|
5
|
+
"author": { "name": "DeepPulse", "url": "https://www.youtube.com/@DeepPulseKR" },
|
|
6
|
+
"types": "./types/index.d.ts"
|
|
7
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, Register, SessionUsage } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* sprag-statusline: sprag's statusline in the Claude desktop app.
|
|
6
|
+
*
|
|
7
|
+
* The desktop app's Code tab runs claude in stream-json mode, which never runs
|
|
8
|
+
* settings.json's statusLine.command. Hooks and mods do run there, so this mod
|
|
9
|
+
* runs the same sprag command itself and draws its text in the band above the
|
|
10
|
+
* prompt. Running the real CLI also leaves what a statusline render leaves: the
|
|
11
|
+
* rate-limit snapshot and the context window size that the delegation briefing
|
|
12
|
+
* reads back.
|
|
13
|
+
*
|
|
14
|
+
* The terminal has its own statusline, so the band is drawn on the desktop
|
|
15
|
+
* surface only. Nothing here may slow or break a session: a run is skipped
|
|
16
|
+
* while another is in flight, cut off after a few seconds, and a failure keeps
|
|
17
|
+
* the last text on screen.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const lines = atom({ plugin: 'sprag-statusline', key: 'lines' } as const, [] as string[])
|
|
21
|
+
|
|
22
|
+
const REFRESH_MS = 5000
|
|
23
|
+
const RUN_TIMEOUT_MS = 3000
|
|
24
|
+
const DEFAULT_ARGV = ['sprag', '--statusline', '--no-color']
|
|
25
|
+
|
|
26
|
+
// The executable names src/installer.js's argsAfterOurExecutable accepts
|
|
27
|
+
const OUR_EXECUTABLES = ['sprag', 'claude-token-saver', 'sprag-cli']
|
|
28
|
+
const RUNNERS = /^(npx|bunx|pnpx)$/
|
|
29
|
+
const RUNNER_SUBCOMMANDS: Record<string, string[]> = {
|
|
30
|
+
pnpm: ['dlx', 'exec'],
|
|
31
|
+
yarn: ['dlx', 'run'],
|
|
32
|
+
bun: ['x', 'run'],
|
|
33
|
+
npm: ['exec'],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether a statusLine command starts with sprag's executable, as the installer judges it. */
|
|
37
|
+
function isOurCommand(tokens: string[]): boolean {
|
|
38
|
+
let i = 0
|
|
39
|
+
if (RUNNERS.test(tokens[0] ?? '')) i = 1
|
|
40
|
+
else if (RUNNER_SUBCOMMANDS[tokens[0] ?? '']?.includes(tokens[1] ?? '')) i = 2
|
|
41
|
+
const token = tokens[i]
|
|
42
|
+
if (!token) return false
|
|
43
|
+
const exe = (token.split(/[/\\]/).pop() ?? '').replace(/\.(cmd|exe|ps1)$/i, '')
|
|
44
|
+
return OUR_EXECUTABLES.includes(exe)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The command to run: the user's own sprag statusLine command with --no-color
|
|
49
|
+
* added, or the default one. This module has no shell, so the command is split
|
|
50
|
+
* on whitespace, the same way the installer reads it; a command with quoting
|
|
51
|
+
* falls back to the default rather than being run in pieces.
|
|
52
|
+
*/
|
|
53
|
+
export function statuslineArgv(settings: Readonly<Record<string, unknown>>): string[] {
|
|
54
|
+
const statusLine = settings.statusLine as { command?: unknown } | undefined
|
|
55
|
+
const command = typeof statusLine?.command === 'string' ? statusLine.command.trim() : ''
|
|
56
|
+
if (!command || /["'`$;|&<>\\]/.test(command)) return DEFAULT_ARGV
|
|
57
|
+
const tokens = command.split(/\s+/)
|
|
58
|
+
if (!isOurCommand(tokens)) return DEFAULT_ARGV
|
|
59
|
+
return tokens.includes('--no-color') ? tokens : [...tokens, '--no-color']
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The JSON Claude Code's statusline would have been fed. Fields the engine has
|
|
64
|
+
* no value for are left out, never zeroed: sprag reads an absent rate_limits as
|
|
65
|
+
* "no data" but a 0 as "0% used". transcript_path is left out because the mod
|
|
66
|
+
* cannot know it, and sprag takes it only for the effort chip.
|
|
67
|
+
*/
|
|
68
|
+
export function statuslinePayload(input: {
|
|
69
|
+
sessionId: string
|
|
70
|
+
cwd: string
|
|
71
|
+
model: string
|
|
72
|
+
usage: SessionUsage
|
|
73
|
+
}) {
|
|
74
|
+
const { usage } = input
|
|
75
|
+
const payload: Record<string, unknown> = {
|
|
76
|
+
session_id: input.sessionId,
|
|
77
|
+
cwd: input.cwd,
|
|
78
|
+
workspace: { current_dir: input.cwd, project_dir: input.cwd },
|
|
79
|
+
model: { id: input.model, display_name: input.model },
|
|
80
|
+
}
|
|
81
|
+
if (usage.context.percent !== undefined) {
|
|
82
|
+
payload.context_window = {
|
|
83
|
+
context_window_size: usage.context.window,
|
|
84
|
+
used_percentage: usage.context.percent,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (usage.rateLimits.length) {
|
|
88
|
+
const limits: Record<string, { used_percentage: number; resets_at?: number }> = {}
|
|
89
|
+
for (const limit of usage.rateLimits) {
|
|
90
|
+
const at = limit.resetsAt ? Math.floor(Date.parse(limit.resetsAt) / 1000) : NaN
|
|
91
|
+
limits[limit.kind] = {
|
|
92
|
+
used_percentage: limit.percentUsed,
|
|
93
|
+
...(Number.isFinite(at) ? { resets_at: at } : {}),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
payload.rate_limits = limits
|
|
97
|
+
}
|
|
98
|
+
if (usage.cost) payload.cost = { total_cost_usd: usage.cost.usd }
|
|
99
|
+
return payload
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Output with stray ANSI escapes removed and blank lines dropped. */
|
|
103
|
+
export function cleanOutput(text: string): string[] {
|
|
104
|
+
return text
|
|
105
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
106
|
+
.replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, '')
|
|
107
|
+
.split(/\r?\n/)
|
|
108
|
+
.map(line => line.trimEnd())
|
|
109
|
+
.filter(line => line.trim() !== '')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let isRunning = false
|
|
113
|
+
let timer: { cancel: () => void } | undefined
|
|
114
|
+
|
|
115
|
+
/** Runs the statusline once and stores its text; any failure leaves the last text as it was. */
|
|
116
|
+
async function refresh($: EngineInterface): Promise<void> {
|
|
117
|
+
if (isRunning) return
|
|
118
|
+
isRunning = true
|
|
119
|
+
try {
|
|
120
|
+
if (!(await $.session.surfaces()).includes('desktop')) return
|
|
121
|
+
const [sessionId, cwd, model, usage, settings] = await Promise.all([
|
|
122
|
+
$.session.id(),
|
|
123
|
+
$.session.cwd(),
|
|
124
|
+
$.session.model(),
|
|
125
|
+
$.session.usage(),
|
|
126
|
+
$.settings.read(),
|
|
127
|
+
])
|
|
128
|
+
const run = await $.process.run(statuslineArgv(settings), {
|
|
129
|
+
stdin: JSON.stringify(statuslinePayload({ sessionId, cwd, model, usage })),
|
|
130
|
+
timeoutMs: RUN_TIMEOUT_MS,
|
|
131
|
+
})
|
|
132
|
+
if (run.exitCode !== 0) return
|
|
133
|
+
const next = cleanOutput(run.stdout)
|
|
134
|
+
if (!next.length) return
|
|
135
|
+
// An unchanged text is not written, so the band is not redrawn every tick
|
|
136
|
+
const same = JSON.stringify(await read($, lines)) === JSON.stringify(next)
|
|
137
|
+
if (!same) await update($, lines, () => next)
|
|
138
|
+
} catch {
|
|
139
|
+
// The statusline is a convenience; a refused or timed-out run is not worth an error
|
|
140
|
+
} finally {
|
|
141
|
+
isRunning = false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const register: Register = on => {
|
|
146
|
+
on('session.start', ($, e, next) => {
|
|
147
|
+
timer?.cancel()
|
|
148
|
+
timer = $.clock.every(REFRESH_MS, () => void refresh($))
|
|
149
|
+
void refresh($)
|
|
150
|
+
return next(e)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Not awaited: a slow statusline must never hold a turn open
|
|
154
|
+
on('turn.complete', ($, e, next) => {
|
|
155
|
+
void refresh($)
|
|
156
|
+
return next(e)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
160
|
+
if (e.surface !== 'desktop' || e.props.hasSurvey) return next(e)
|
|
161
|
+
const shown = (await read($, lines)).slice(0, Math.max(0, e.props.maxRows))
|
|
162
|
+
if (!shown.length) return next(e)
|
|
163
|
+
|
|
164
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
165
|
+
// One statusline line per row; a Box lays its children out in a row unless told otherwise
|
|
166
|
+
return (
|
|
167
|
+
<Box flexDirection="column">
|
|
168
|
+
{shown.map((line, i) => (
|
|
169
|
+
<Text key={`line-${i}`} dimColor>
|
|
170
|
+
{line}
|
|
171
|
+
</Text>
|
|
172
|
+
))}
|
|
173
|
+
</Box>
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.67.0",
|
|
4
4
|
"description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"release:notes": "node scripts/release-notes.mjs",
|
|
16
16
|
"site:docs": "node scripts/build-site-docs.mjs",
|
|
17
17
|
"test": "node --test",
|
|
18
|
-
"test:mods": "claude plugin test mod/sprag-doc2md && claude plugin test mod/sprag-delegation-meter",
|
|
18
|
+
"test:mods": "claude plugin test mod/sprag-doc2md && claude plugin test mod/sprag-delegation-meter && claude plugin test mod/sprag-statusline",
|
|
19
19
|
"version": "node scripts/sync-site-version.mjs"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"src/",
|
|
24
24
|
"mod/*/.claude-plugin/plugin.json",
|
|
25
25
|
"mod/*/hooks/**",
|
|
26
|
+
"mod/*/types/**",
|
|
26
27
|
"presets/",
|
|
27
28
|
"examples/",
|
|
28
29
|
"README.md",
|
|
@@ -40,6 +40,13 @@
|
|
|
40
40
|
{
|
|
41
41
|
"ko": "지침이나 룰이 지켜지지 않아 같은 품질 문제가 재발하면 문구를 다시 쓰지 말 것 → 그 조항이 기계로 판정 가능한지부터 따지고(문자열 비교나 정규식으로 결정되면 판정 가능), 가능하면 쓰기 시점 훅이나 테스트 같은 차단 지점을 추가한다. 판단이 필요한 잔여 부분만 지침에 남기고 그 경계를 지침에 적는다. 실제 코퍼스에 돌려 오탐 건수를 센 뒤 좁히고, 회귀 테스트를 같은 커밋에 넣는다",
|
|
42
42
|
"en": "When a guideline keeps being violated and the same quality defect recurs, do NOT reword the instruction → first ask whether the clause is machine-decidable (a string comparison or regex settles it), and if so add an enforcement point such as a write-time hook or a test. Leave only the judgement remainder in the prose and state that boundary there. Measure the false-positive count on a real corpus, narrow until it is acceptable, and land a regression test in the same commit"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"ko": "여러 파일에 걸친 구현을 맡으면 → 설계와 스펙 확정은 메인 모델이 하고 코드 작성은 서브에이전트에 위임한다(Claude는 `model: sonnet`, Codex는 명시적 매핑을 우선해 해석한 T1을 쓰며 haiku나 T2에는 구현을 맡기지 않는다). 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산, 권장 모델과 그 이유를 적어 다른 문맥 없이 실행할 수 있게 한다. 이미 읽은 파일의 약 50줄 이하 변경이나 의존 단계가 하나뿐인 짧은 구현, 그리고 위임 도구나 하위 모델을 쓸 수 없는 경우는 메인 모델이 직접 처리한다. 위임한 구현은 끝날 때까지 기다리고 같은 구현을 중복 실행하지 않으며, 돌아온 diff와 테스트 결과를 메인 모델이 직접 검증한 뒤에 완료로 보고한다",
|
|
46
|
+
"en": "When an implementation spans several files → the main model settles the design and specification, then delegates the code writing to a subagent (Claude: `model: sonnet`; Codex: its resolved T1, explicit mapping first; never haiku or T2 for implementation). Write the delegation prompt so it runs without other context: design, files to change, constraints, completion criteria, test commands, budget, and the recommended model with a one-line reason. Keep changes of roughly 50 lines or less in files already read, a single short dependency chain, or cases where the subagent tools or the lower model are unavailable on the main model. Wait for the delegated work instead of duplicating it, and verify the returned diff and the actual test results before reporting completion",
|
|
47
|
+
"agents": [
|
|
48
|
+
"claude"
|
|
49
|
+
]
|
|
43
50
|
}
|
|
44
51
|
]
|
|
45
52
|
}
|
package/src/agents.js
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
* part that actually saves tokens and it is universal, so `model: haiku` is the
|
|
9
9
|
* default phrasing — the agent name is only added when the file is really
|
|
10
10
|
* there, which keeps the tool-restricted preset in play for users who have it.
|
|
11
|
+
*
|
|
12
|
+
* A rule that names a tier alone (`sonnet`, `haiku`) resolves to that tier's
|
|
13
|
+
* delegation-only agent (`sonnet-worker`, `haiku-explore`, installed by
|
|
14
|
+
* delegation-agents.js) when its file exists. A phrase that names an agent
|
|
15
|
+
* always carries a general-purpose fallback, because Cowork reads no agents
|
|
16
|
+
* directory and a copied rule would otherwise name an agent that is not there.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
import { existsSync } from 'node:fs';
|
|
@@ -20,9 +26,9 @@ const AGENT_MODEL = {
|
|
|
20
26
|
'haiku-runner': 'haiku',
|
|
21
27
|
'haiku-translate': 'haiku',
|
|
22
28
|
'sonnet-worker': 'sonnet',
|
|
23
|
-
// A T1 rule names its target by tier alone (`agent: 'sonnet'`)
|
|
24
|
-
//
|
|
25
|
-
//
|
|
29
|
+
// A T1 rule names its target by tier alone (`agent: 'sonnet'`). Without these
|
|
30
|
+
// the phrase fell through to the haiku default, and a sonnet rule told the
|
|
31
|
+
// session to delegate to haiku. resolveAgentName maps them to an agent file.
|
|
26
32
|
sonnet: 'sonnet',
|
|
27
33
|
haiku: 'haiku',
|
|
28
34
|
};
|
|
@@ -43,9 +49,23 @@ export function agentExists(name, root = process.cwd()) {
|
|
|
43
49
|
existsSync(join(claudeUserDir(), 'agents', `${name}.md`));
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
/** Agent a bare tier name stands for when that agent's file exists. */
|
|
53
|
+
const TIER_AGENT = { sonnet: 'sonnet-worker', haiku: 'haiku-explore' };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The agent a rule target really names on this machine, or null when none is
|
|
57
|
+
* installed. A name wins when its own file exists; a bare tier name falls back
|
|
58
|
+
* to its delegation-only agent.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveAgentName(name, root = process.cwd()) {
|
|
61
|
+
if (agentExists(name, root)) return name;
|
|
62
|
+
const alt = TIER_AGENT[name];
|
|
63
|
+
return alt && agentExists(alt, root) ? alt : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
/**
|
|
47
67
|
* Korean phrase for a delegation target, e.g.
|
|
48
|
-
* agent present → `haiku-explore(model 인자
|
|
68
|
+
* agent present → `haiku-explore(model 인자 생략. …general-purpose에 model: haiku)`
|
|
49
69
|
* agent absent → `model: haiku`
|
|
50
70
|
*
|
|
51
71
|
* A named agent carries its model in its own definition, and the Agent tool's
|
|
@@ -56,18 +76,20 @@ export function agentExists(name, root = process.cwd()) {
|
|
|
56
76
|
* now says to leave the argument out.
|
|
57
77
|
*/
|
|
58
78
|
export function agentPhrase(name, { model, root } = {}) {
|
|
59
|
-
const
|
|
60
|
-
|
|
79
|
+
const resolved = resolveAgentName(name, root);
|
|
80
|
+
if (!resolved) return `model: ${model || AGENT_MODEL[name] || 'haiku'}`;
|
|
81
|
+
const tier = AGENT_MODEL[resolved] || model || 'haiku';
|
|
82
|
+
return `${resolved}(model 인자 생략. 이 에이전트가 없거나 MCP 도구가 필요하면 general-purpose에 model: ${tier})`;
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
/**
|
|
64
86
|
* English phrase, including the article, e.g.
|
|
65
|
-
* agent present → `the haiku-explore subagent (omit the model argument)`
|
|
87
|
+
* agent present → `the haiku-explore subagent (omit the model argument; …)`
|
|
66
88
|
* agent absent → `a model: haiku subagent`
|
|
67
89
|
*/
|
|
68
90
|
export function agentPhraseEn(name, { model, root } = {}) {
|
|
69
|
-
const
|
|
70
|
-
return
|
|
71
|
-
|
|
72
|
-
|
|
91
|
+
const resolved = resolveAgentName(name, root);
|
|
92
|
+
if (!resolved) return `a model: ${model || AGENT_MODEL[name] || 'haiku'} subagent`;
|
|
93
|
+
const tier = AGENT_MODEL[resolved] || model || 'haiku';
|
|
94
|
+
return `the ${resolved} subagent (omit the model argument; if it is unavailable or MCP tools are needed, use general-purpose with model: ${tier})`;
|
|
73
95
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: cowork : keep the Claude app's Cowork tab on the same ratchet rules.
|
|
3
|
+
*
|
|
4
|
+
* sprag cowork status
|
|
5
|
+
* sprag cowork sync discover connected folders and write the copies now
|
|
6
|
+
* sprag cowork on | off turn the sync on (and sync) or off (and remove the copies)
|
|
7
|
+
* sprag cowork exclude <dir> never write into <dir> (removes its copy)
|
|
8
|
+
* sprag cowork include <dir> undo exclude (and sync)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import { realpathSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
const REASONS = {
|
|
15
|
+
ko: {
|
|
16
|
+
'not-absolute': '절대 경로가 아님', missing: '폴더가 없음', 'not-a-directory': '디렉터리가 아님',
|
|
17
|
+
'home-or-ancestor': '홈 디렉터리 또는 그 상위 경로', 'inside-claude-dir': '~/.claude 안',
|
|
18
|
+
'inside-library': '~/Library 안', 'inside-sprag-data': 'sprag 데이터 폴더 안', excluded: '제외 목록에 있음',
|
|
19
|
+
'not-owned': '같은 이름의 사용자 파일이 있어 덮어쓰지 않음', 'no-source': '복사할 원본 래칫 파일이 없음',
|
|
20
|
+
},
|
|
21
|
+
en: {
|
|
22
|
+
'not-absolute': 'not an absolute path', missing: 'folder does not exist', 'not-a-directory': 'not a directory',
|
|
23
|
+
'home-or-ancestor': 'the home directory or one of its parents', 'inside-claude-dir': 'inside ~/.claude',
|
|
24
|
+
'inside-library': 'inside ~/Library', 'inside-sprag-data': 'inside the sprag data folder', excluded: 'on the exclude list',
|
|
25
|
+
'not-owned': 'a user file of the same name exists, not overwritten', 'no-source': 'no source ratchet file to copy',
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const norm = (p) => {
|
|
30
|
+
const abs = resolve(p);
|
|
31
|
+
try { return realpathSync(abs); } catch { return abs; }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function run({ args }) {
|
|
35
|
+
const cw = await import('../cowork-sync.js');
|
|
36
|
+
const { userLanguage } = await import('../config.js');
|
|
37
|
+
const ko = userLanguage() === 'ko';
|
|
38
|
+
const why = (r) => (REASONS[ko ? 'ko' : 'en'][r] || r);
|
|
39
|
+
const sub = args[1];
|
|
40
|
+
|
|
41
|
+
const summarize = (r) => {
|
|
42
|
+
console.log(ko
|
|
43
|
+
? `Cowork 래칫 동기화: 새로 씀 ${r.written.length}, 변경 없음 ${r.unchanged.length}, 건너뜀 ${r.skipped.length}, 제거 ${r.removed.length}`
|
|
44
|
+
: `Cowork ratchet sync: written ${r.written.length}, unchanged ${r.unchanged.length}, skipped ${r.skipped.length}, removed ${r.removed.length}`);
|
|
45
|
+
for (const d of r.written) console.log(` + ${d}`);
|
|
46
|
+
for (const d of r.removed) console.log(` - ${d}`);
|
|
47
|
+
for (const s of r.skipped) console.log(` ! ${s.dir} (${why(s.reason)})`);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (sub === 'sync') return summarize(cw.syncCoworkRatchets({ discover: true }));
|
|
51
|
+
|
|
52
|
+
if (sub === 'off') {
|
|
53
|
+
cw.saveCoworkConfig({ sync: false });
|
|
54
|
+
console.log(ko ? 'Cowork 래칫 동기화를 껐습니다. 만들어 둔 사본을 지웁니다.' : 'Cowork ratchet sync is off. Removing the copies it made.');
|
|
55
|
+
return summarize(cw.removeCoworkRatchets());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (sub === 'on') {
|
|
59
|
+
cw.saveCoworkConfig({ sync: true });
|
|
60
|
+
console.log(ko ? 'Cowork 래칫 동기화를 켰습니다.' : 'Cowork ratchet sync is on.');
|
|
61
|
+
return summarize(cw.syncCoworkRatchets({ discover: true }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (sub === 'exclude' || sub === 'include') {
|
|
65
|
+
const target = args.slice(2).find((a) => !a.startsWith('-'));
|
|
66
|
+
if (!target) {
|
|
67
|
+
console.error(ko ? '폴더 경로를 지정하십시오.' : 'Pass a folder path.');
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const dir = norm(target);
|
|
72
|
+
const list = cw.coworkConfig().exclude;
|
|
73
|
+
if (sub === 'exclude') {
|
|
74
|
+
if (!list.includes(dir)) cw.saveCoworkConfig({ exclude: [...list, dir] });
|
|
75
|
+
const removed = cw.removeCoworkRatchetIn(dir);
|
|
76
|
+
console.log(ko
|
|
77
|
+
? `제외 목록에 추가했습니다: ${dir}${removed ? ' (사본을 지웠습니다)' : ''}`
|
|
78
|
+
: `Added to the exclude list: ${dir}${removed ? ' (copy removed)' : ''}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
cw.saveCoworkConfig({ exclude: list.filter((x) => x !== dir && norm(x) !== dir) });
|
|
82
|
+
console.log(ko ? `제외 목록에서 뺐습니다: ${dir}` : `Removed from the exclude list: ${dir}`);
|
|
83
|
+
return summarize(cw.syncCoworkRatchets({ discover: true }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (sub !== undefined) {
|
|
87
|
+
console.error(ko ? `알 수 없는 하위 명령입니다: ${sub}` : `Unknown subcommand: ${sub}`);
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const st = cw.coworkStatus();
|
|
93
|
+
console.log(ko ? `Cowork 래칫 동기화: ${st.enabled ? '켜짐' : '꺼짐'}` : `Cowork ratchet sync: ${st.enabled ? 'on' : 'off'}`);
|
|
94
|
+
console.log(ko ? `마지막 동기화: ${st.lastSync || '없음'}` : `Last sync: ${st.lastSync || 'never'}`);
|
|
95
|
+
if (st.folders.length === 0) {
|
|
96
|
+
console.log(ko ? '발견한 Cowork 연결 폴더가 없습니다.' : 'No Cowork folders discovered.');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
console.log(ko ? '연결 폴더:' : 'Connected folders:');
|
|
100
|
+
for (const f of st.folders) {
|
|
101
|
+
const state = f.hasCopy ? (ko ? '사본 있음' : 'copy present')
|
|
102
|
+
: f.foreign ? why('not-owned')
|
|
103
|
+
: f.eligible ? (ko ? '사본 없음' : 'no copy yet')
|
|
104
|
+
: (ko ? `제외: ${why(f.reason)}` : `skipped: ${why(f.reason)}`);
|
|
105
|
+
console.log(` ${f.dir} [${state}]`);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/commands/delegate.js
CHANGED
|
@@ -59,6 +59,13 @@ export async function run({ args, hasFlag }) {
|
|
|
59
59
|
const { userLanguage } = await import('../config.js');
|
|
60
60
|
const lang = userLanguage();
|
|
61
61
|
|
|
62
|
+
// `delegate agents` manages the tool-restricted subagent files. It is split
|
|
63
|
+
// off by the second word so it never mixes with the on/off PreToolUse guard.
|
|
64
|
+
if (sub === 'agents') {
|
|
65
|
+
await agentsCommand(args[2], lang);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
if (sub === 'on') {
|
|
63
70
|
// Install first, and only record the feature as on once that worked.
|
|
64
71
|
// Saving the flag first left `delegate: on, hook not registered` behind
|
|
@@ -131,3 +138,39 @@ export async function run({ args, hasFlag }) {
|
|
|
131
138
|
console.log('Enable with: sprag delegate on');
|
|
132
139
|
}
|
|
133
140
|
}
|
|
141
|
+
|
|
142
|
+
async function agentsCommand(action, lang) {
|
|
143
|
+
const ko = lang === 'ko';
|
|
144
|
+
const da = await import('../delegation-agents.js');
|
|
145
|
+
const report = (r) => {
|
|
146
|
+
for (const n of r.written) console.log(ko ? `✓ ${n} 에이전트를 설치했습니다.` : `✓ installed agent ${n}`);
|
|
147
|
+
for (const n of r.unchanged) console.log(ko ? ` ${n} 에이전트는 이미 최신입니다.` : ` agent ${n} is already current`);
|
|
148
|
+
for (const k of r.skipped) {
|
|
149
|
+
console.log(ko
|
|
150
|
+
? `✗ ${k.name} 에이전트는 건너뛰었습니다 (${k.reason === 'not-owned' ? '직접 만든 같은 이름의 파일이 있습니다' : k.reason}).`
|
|
151
|
+
: `✗ skipped agent ${k.name} (${k.reason})`);
|
|
152
|
+
}
|
|
153
|
+
for (const n of r.removed) console.log(ko ? `✓ ${n} 에이전트를 제거했습니다.` : `✓ removed agent ${n}`);
|
|
154
|
+
};
|
|
155
|
+
const rerender = async () => {
|
|
156
|
+
try { (await import('../model-rules.js')).syncAllFiles(); } catch { /* the next session start re-renders */ }
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (action === 'on' || action === 'off') {
|
|
160
|
+
const { loadConfig, saveConfig } = await import('../config.js');
|
|
161
|
+
saveConfig({ ...loadConfig(), delegationAgents: action === 'on' });
|
|
162
|
+
const r = action === 'on' ? da.syncDelegationAgents() : da.removeDelegationAgents();
|
|
163
|
+
await rerender();
|
|
164
|
+
report(r);
|
|
165
|
+
console.log(action === 'on'
|
|
166
|
+
? (ko ? '위임 전용 에이전트를 켰습니다. 래칫 문구가 에이전트 이름으로 다시 만들어졌습니다.' : 'Delegation agents are on. The ratchet wording was re-rendered with the agent names.')
|
|
167
|
+
: (ko ? '위임 전용 에이전트를 껐습니다. 래칫 문구가 model 지정 방식으로 다시 만들어졌습니다.' : 'Delegation agents are off. The ratchet wording was re-rendered with plain model tiers.'));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const st = da.delegationAgentStatus();
|
|
172
|
+
console.log(ko ? `delegate agents: ${st.enabled ? 'on' : 'off'}` : `delegate agents: ${st.enabled ? 'on' : 'off'}`);
|
|
173
|
+
const label = { 'installed-by-sprag': ko ? 'sprag 가 설치함' : 'installed by sprag', 'user-file': ko ? '직접 만든 파일' : 'user file', absent: ko ? '없음' : 'absent' };
|
|
174
|
+
for (const a of st.agents) console.log(` ${a.name}: ${a.state} (${label[a.state]})`);
|
|
175
|
+
console.log(ko ? '켜기 또는 끄기: sprag delegate agents on|off' : 'Toggle with: sprag delegate agents on|off');
|
|
176
|
+
}
|
package/src/commands/doc2md.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
13
|
import { readdirSync, rmSync, existsSync, statSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
14
|
+
import { basename, join } from 'node:path';
|
|
15
15
|
|
|
16
16
|
const require = createRequire(import.meta.url);
|
|
17
17
|
|
|
@@ -149,6 +149,15 @@ export async function run({ args, hasFlag }) {
|
|
|
149
149
|
}
|
|
150
150
|
console.error(`✗ ${result.reason}${result.detail ? `: ${result.detail}` : ''}`);
|
|
151
151
|
if (result.reason === 'no-markitdown') console.error(` ${doc2md.INSTALL_HINT}`);
|
|
152
|
+
// The line after the reason is the same sentence the hooks give the model;
|
|
153
|
+
// the mod reads it back from here, so keep the first line's format as is.
|
|
154
|
+
let lang = 'en';
|
|
155
|
+
try {
|
|
156
|
+
const { userLanguage } = await import('../config.js');
|
|
157
|
+
lang = userLanguage();
|
|
158
|
+
} catch { /* the diagnostic must not depend on the config module */ }
|
|
159
|
+
const notice = doc2md.lockedNotice(basename(sub), result, lang);
|
|
160
|
+
if (notice) console.error(` ${notice}`);
|
|
152
161
|
process.exitCode = 1;
|
|
153
162
|
return;
|
|
154
163
|
}
|