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 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) return null
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: this mod
72
- // saves a round trip and guards nothing, so a hook that breaks hands the
73
- // event on as it came and sprag's settings hook beneath answers as before.
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
- if (!doc) return next(e)
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,3 @@
1
+ {
2
+ "modules": ["./register.tsx"]
3
+ }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ declare module 'claude-code' {
2
+ interface PluginState {
3
+ 'sprag-statusline': { lines: string[] }
4
+ }
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.65.0",
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'`), with no
24
- // preset agent behind it. Without these the phrase fell through to the
25
- // haiku default, and a sonnet rule told the session to delegate to haiku.
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 tier = model || AGENT_MODEL[name] || 'haiku';
60
- return agentExists(name, root) ? `${name}(model 인자 생략)` : `model: ${tier}`;
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 tier = model || AGENT_MODEL[name] || 'haiku';
70
- return agentExists(name, root)
71
- ? `the ${name} subagent (omit the model argument)`
72
- : `a model: ${tier} subagent`;
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
+ }
@@ -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
+ }
@@ -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
  }