sprag-cli 3.66.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/package.json +1 -1
- 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/install.js +11 -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/harness.js +1 -1
- package/src/implementation-delegation.js +5 -2
- package/src/model-rules.js +12 -5
- package/src/route-inject.js +8 -3
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') {
|
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": {
|
|
@@ -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/install.js
CHANGED
|
@@ -368,6 +368,17 @@ export async function run({ hasFlag }) {
|
|
|
368
368
|
// they have not read. All the install does is say what is waiting.
|
|
369
369
|
// Presets now live in ratchet-preset.md. Moving the ones older versions
|
|
370
370
|
// appended to ratchet.md comes first, so they are not offered again below.
|
|
371
|
+
try {
|
|
372
|
+
const d = (await import('../delegation-agents.js')).syncDelegationAgents();
|
|
373
|
+
if (d.written.length) {
|
|
374
|
+
console.log(lang === 'ko'
|
|
375
|
+
? ` agents: 위임 전용 에이전트 ${d.written.length}개를 ~/.claude/agents 에 설치했습니다 (MCP 도구 없음).`
|
|
376
|
+
: ` agents: installed ${d.written.length} delegation-only agent(s) in ~/.claude/agents (no MCP tools).`);
|
|
377
|
+
}
|
|
378
|
+
} catch (e) {
|
|
379
|
+
debug('install:delegation-agents', e);
|
|
380
|
+
}
|
|
381
|
+
|
|
371
382
|
try {
|
|
372
383
|
const { preparePresetRatchet } = await import('../preset-ratchet.js');
|
|
373
384
|
const p = preparePresetRatchet();
|
|
@@ -296,11 +296,15 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
296
296
|
// would keep reading the old wording indefinitely. The call writes only
|
|
297
297
|
// when the rendering differs, so the common case is a read and a string
|
|
298
298
|
// compare.
|
|
299
|
+
// Agent files come first so the ratchet-model.md rendered next names them.
|
|
300
|
+
try { (await import('../delegation-agents.js')).syncDelegationAgents(); } catch (e) { debug('route-scan:delegation-agents', e); }
|
|
299
301
|
try { mrHook.syncAllFiles(); } catch (e) { debug('route-scan:sync-ratchet', e); }
|
|
300
302
|
// Same reason for the preset ratchet: an upgrade that rewords or drops a
|
|
301
303
|
// bundled rule reaches ratchet-preset.md here, without a reinstall. It
|
|
302
304
|
// also moves presets older versions appended to ratchet.md.
|
|
303
305
|
try { (await import('../preset-ratchet.js')).preparePresetRatchet(); } catch (e) { debug('route-scan:sync-preset', e); }
|
|
306
|
+
// Cowork cannot follow the ~/.claude imports, so it gets a pasted copy.
|
|
307
|
+
try { (await import('../cowork-sync.js')).syncCoworkRatchets({ discover: true }); } catch (e) { debug('route-scan:cowork-sync', e); }
|
|
304
308
|
// Registered rules whose delegated-category error rate crossed the
|
|
305
309
|
// health threshold since promotion — the user approved these, so a
|
|
306
310
|
// status change must be briefed, not just written into the md file.
|
|
@@ -466,6 +470,8 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
466
470
|
let cache = rs.readRouteScan();
|
|
467
471
|
if (hasFlag('--refresh') || (cache && cache.days !== days) || await rs.shouldRescan(cache, { days })) {
|
|
468
472
|
cache = await rs.runRouteScan({ days });
|
|
473
|
+
// model-rules were rewritten by the scan; carry them to the Cowork copies.
|
|
474
|
+
try { (await import('../cowork-sync.js')).syncCoworkRatchets({ discover: true }); } catch (e) { debug('route-scan:cowork-sync', e); }
|
|
469
475
|
}
|
|
470
476
|
if (hasFlag('--quiet')) return;
|
|
471
477
|
if (hasFlag('--json')) {
|
|
@@ -26,6 +26,10 @@ export async function run({ hasFlag, args = [] }) {
|
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// The Cowork copies and the settings.json exclude entry are ours too.
|
|
30
|
+
try { (await import('../cowork-sync.js')).removeCoworkRatchets(); } catch { /* uninstall goes on */ }
|
|
31
|
+
try { (await import('../delegation-agents.js')).removeDelegationAgents(); } catch { /* uninstall goes on */ }
|
|
32
|
+
|
|
29
33
|
if (r.removed.length === 0) {
|
|
30
34
|
console.log(lang === 'ko' ? '설치된 항목이 없습니다.' : 'Nothing was installed.');
|
|
31
35
|
} else {
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cowork-sync: copy the global ratchet files into the folders connected to the
|
|
3
|
+
* Claude desktop app's Cowork tab.
|
|
4
|
+
*
|
|
5
|
+
* Cowork does not read ~/.claude (no hooks, no `@` imports), but it does read
|
|
6
|
+
* `<folder>/.claude/rules/*.md`. So the three global ratchet files are pasted
|
|
7
|
+
* into one tool-owned rules file per connected folder. Claude Code reads the
|
|
8
|
+
* same rules directory, so ~/.claude/settings.json gets a `claudeMdExcludes`
|
|
9
|
+
* entry that keeps Claude Code from loading the copy a second time (Cowork does
|
|
10
|
+
* not read that settings file).
|
|
11
|
+
*
|
|
12
|
+
* Where Cowork keeps its session metadata (macOS verified):
|
|
13
|
+
* ~/Library/Application Support/Claude-3p/local-agent-mode-sessions/<org>/<user>/local_<id>.json
|
|
14
|
+
* The 1p app is assumed to use ".../Claude/local-agent-mode-sessions" (not
|
|
15
|
+
* verified). Windows is assumed to use %APPDATA%\Claude and %APPDATA%\Claude-3p
|
|
16
|
+
* (not verified). Linux is out of scope.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here throws to the caller: a folder that cannot be written is
|
|
19
|
+
* reported in `skipped`, and the SessionStart hook ignores the whole call on
|
|
20
|
+
* failure.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
existsSync, readFileSync, readdirSync, statSync, realpathSync, mkdirSync, writeFileSync, rmSync,
|
|
25
|
+
appendFileSync,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { join, resolve, sep, isAbsolute } from 'node:path';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { execFileSync } from 'node:child_process';
|
|
30
|
+
import { userDataDir, claudeUserDir } from './paths.js';
|
|
31
|
+
import { loadConfig, saveConfig, userLanguage } from './config.js';
|
|
32
|
+
import { writeStateFile } from './state-file.js';
|
|
33
|
+
import { readSettings, writeSettings } from './installer.js';
|
|
34
|
+
import { modelRatchetPathFor } from './model-rules.js';
|
|
35
|
+
import { presetRatchetPath } from './preset-ratchet.js';
|
|
36
|
+
import { globalRatchetMdPath } from './harness.js';
|
|
37
|
+
import { debug } from './debug.js';
|
|
38
|
+
|
|
39
|
+
export const COWORK_MARKER = '<!-- sprag:cowork-ratchet -->';
|
|
40
|
+
export const COWORK_EXCLUDE_PATTERN = '**/.claude/rules/sprag-cowork-ratchet.md';
|
|
41
|
+
const COPY_REL = join('.claude', 'rules', 'sprag-cowork-ratchet.md');
|
|
42
|
+
const MAX_PARSE_PER_CALL = 200;
|
|
43
|
+
|
|
44
|
+
export const coworkStatePath = () => join(userDataDir(), 'cowork-sync.json');
|
|
45
|
+
|
|
46
|
+
// ---------- config ----------
|
|
47
|
+
|
|
48
|
+
export function coworkConfig() {
|
|
49
|
+
const c = loadConfig().cowork;
|
|
50
|
+
return {
|
|
51
|
+
sync: !(c && c.sync === false),
|
|
52
|
+
exclude: Array.isArray(c?.exclude) ? c.exclude.filter((x) => typeof x === 'string') : [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function saveCoworkConfig(patch) {
|
|
57
|
+
const cfg = loadConfig();
|
|
58
|
+
saveConfig({ ...cfg, cowork: { ...coworkConfig(), ...patch } });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------- state ----------
|
|
62
|
+
|
|
63
|
+
export function loadCoworkState() {
|
|
64
|
+
try {
|
|
65
|
+
const s = JSON.parse(readFileSync(coworkStatePath(), 'utf8'));
|
|
66
|
+
if (s && typeof s === 'object' && !Array.isArray(s)) {
|
|
67
|
+
return {
|
|
68
|
+
version: 1,
|
|
69
|
+
scanned: s.scanned && typeof s.scanned === 'object' ? s.scanned : {},
|
|
70
|
+
folders: s.folders && typeof s.folders === 'object' ? s.folders : {},
|
|
71
|
+
written: s.written && typeof s.written === 'object' ? s.written : {},
|
|
72
|
+
...(typeof s.lastSync === 'string' ? { lastSync: s.lastSync } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
} catch { /* first run or unreadable: start empty */ }
|
|
76
|
+
return { version: 1, scanned: {}, folders: {}, written: {} };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function saveCoworkState(state) {
|
|
80
|
+
try { writeStateFile(coworkStatePath(), JSON.stringify(state, null, 2) + '\n'); } catch (e) { debug('cowork-sync:save-state', e); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------- discovery ----------
|
|
84
|
+
|
|
85
|
+
export function coworkSessionRoots() {
|
|
86
|
+
const bases = [];
|
|
87
|
+
if (process.platform === 'darwin') {
|
|
88
|
+
const support = join(homedir(), 'Library', 'Application Support');
|
|
89
|
+
bases.push(join(support, 'Claude'), join(support, 'Claude-3p'));
|
|
90
|
+
} else if (process.platform === 'win32' && process.env.APPDATA) {
|
|
91
|
+
bases.push(join(process.env.APPDATA, 'Claude'), join(process.env.APPDATA, 'Claude-3p'));
|
|
92
|
+
}
|
|
93
|
+
return bases.map((b) => join(b, 'local-agent-mode-sessions')).filter((p) => existsSync(p));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function subdirs(dir) {
|
|
97
|
+
try {
|
|
98
|
+
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
|
|
99
|
+
} catch { return []; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** local_*.json exactly three levels under a root: <org>/<user>/local_*.json */
|
|
103
|
+
function sessionMetaFiles(root) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const org of subdirs(root)) {
|
|
106
|
+
for (const user of subdirs(org)) {
|
|
107
|
+
let names;
|
|
108
|
+
try { names = readdirSync(user); } catch { continue; }
|
|
109
|
+
for (const n of names) {
|
|
110
|
+
if (n.startsWith('local_') && n.endsWith('.json')) out.push(join(user, n));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse only the metadata files whose mtime changed since the last call (at
|
|
119
|
+
* most 200 per call, newest first; the rest wait for the next call) and add
|
|
120
|
+
* the folders they name to `state.folders`. Folders are never forgotten.
|
|
121
|
+
*/
|
|
122
|
+
export function discoverCoworkFolders({ state = loadCoworkState() } = {}) {
|
|
123
|
+
const now = new Date().toISOString();
|
|
124
|
+
const seen = {};
|
|
125
|
+
const changed = [];
|
|
126
|
+
for (const root of coworkSessionRoots()) {
|
|
127
|
+
for (const file of sessionMetaFiles(root)) {
|
|
128
|
+
let mtime;
|
|
129
|
+
try { mtime = statSync(file).mtimeMs; } catch { continue; }
|
|
130
|
+
seen[file] = mtime;
|
|
131
|
+
if (state.scanned[file] !== mtime) changed.push({ file, mtime });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
changed.sort((a, b) => b.mtime - a.mtime);
|
|
135
|
+
const nextScanned = {};
|
|
136
|
+
// Unchanged files keep their mark; changed ones are marked only once handled,
|
|
137
|
+
// so the ones beyond the cap are picked up by the next call.
|
|
138
|
+
for (const [file, mtime] of Object.entries(seen)) {
|
|
139
|
+
if (state.scanned[file] === mtime) nextScanned[file] = mtime;
|
|
140
|
+
}
|
|
141
|
+
for (const { file, mtime } of changed.slice(0, MAX_PARSE_PER_CALL)) {
|
|
142
|
+
nextScanned[file] = mtime;
|
|
143
|
+
let meta;
|
|
144
|
+
try { meta = JSON.parse(readFileSync(file, 'utf8')); } catch { continue; }
|
|
145
|
+
const list = Array.isArray(meta?.userSelectedFolders) ? meta.userSelectedFolders : [];
|
|
146
|
+
for (const f of list) {
|
|
147
|
+
if (typeof f !== 'string' || !f) continue;
|
|
148
|
+
const rec = state.folders[f];
|
|
149
|
+
state.folders[f] = { firstSeen: rec?.firstSeen || now, lastSeen: now };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
state.scanned = nextScanned;
|
|
153
|
+
return Object.keys(state.folders);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------- eligibility ----------
|
|
157
|
+
|
|
158
|
+
function real(p) {
|
|
159
|
+
try { return realpathSync(p); } catch { return resolve(p); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const isInside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
163
|
+
|
|
164
|
+
/** @returns {{ ok: boolean, reason?: string, dir?: string }} `dir` is the real path when ok */
|
|
165
|
+
export function eligibleFolder(dir) {
|
|
166
|
+
if (typeof dir !== 'string' || !isAbsolute(dir)) return { ok: false, reason: 'not-absolute' };
|
|
167
|
+
let st;
|
|
168
|
+
try { st = statSync(dir); } catch { return { ok: false, reason: 'missing' }; }
|
|
169
|
+
if (!st.isDirectory()) return { ok: false, reason: 'not-a-directory' };
|
|
170
|
+
const rd = real(dir);
|
|
171
|
+
const home = real(homedir());
|
|
172
|
+
if (rd === home || isInside(home, rd)) return { ok: false, reason: 'home-or-ancestor' };
|
|
173
|
+
if (isInside(rd, real(claudeUserDir()))) return { ok: false, reason: 'inside-claude-dir' };
|
|
174
|
+
if (process.platform === 'darwin' && isInside(rd, real(join(homedir(), 'Library')))) return { ok: false, reason: 'inside-library' };
|
|
175
|
+
if (isInside(rd, real(userDataDir()))) return { ok: false, reason: 'inside-sprag-data' };
|
|
176
|
+
for (const ex of coworkConfig().exclude) {
|
|
177
|
+
if (isInside(rd, real(ex))) return { ok: false, reason: 'excluded' };
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, dir: rd };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const coworkCopyPath = (dir) => join(dir, COPY_REL);
|
|
183
|
+
|
|
184
|
+
// ---------- content ----------
|
|
185
|
+
|
|
186
|
+
/** @returns {string|null} null when none of the three source files exists */
|
|
187
|
+
export function renderCoworkRatchet(lang = userLanguage()) {
|
|
188
|
+
const ko = lang === 'ko';
|
|
189
|
+
const parts = [];
|
|
190
|
+
for (const file of [modelRatchetPathFor('global'), presetRatchetPath(), globalRatchetMdPath()]) {
|
|
191
|
+
let body;
|
|
192
|
+
try { body = readFileSync(file, 'utf8'); } catch { continue; }
|
|
193
|
+
const name = file.split(/[\\/]/).pop();
|
|
194
|
+
parts.push(`## ${ko ? '원본' : 'Source'}: ~/.claude/${name}\n\n${body.trim()}\n`);
|
|
195
|
+
}
|
|
196
|
+
if (parts.length === 0) return null;
|
|
197
|
+
const intro = ko
|
|
198
|
+
? '이 파일은 sprag가 Claude 앱 Cowork 탭용으로 생성한 래칫 사본입니다. Cowork는 ~/.claude 의 설정과 import를 읽지 않으므로, 원본 래칫 세 파일의 본문을 여기에 옮겨 둡니다. sprag가 원본이 바뀔 때마다 이 파일을 통째로 다시 쓰므로 직접 고치지 마십시오. Claude Code(Code 탭)는 settings.json 의 claudeMdExcludes 로 이 파일을 읽지 않습니다.'
|
|
199
|
+
: 'This file is a copy of the ratchet rules that sprag generates for the Cowork tab of the Claude app. Cowork does not read the settings or imports under ~/.claude, so the bodies of the three source ratchet files are copied here. sprag rewrites this whole file whenever a source changes, so do not edit it by hand. Claude Code (the Code tab) skips this file through claudeMdExcludes in settings.json.';
|
|
200
|
+
return `${COWORK_MARKER}\n${intro}\n\n${parts.join('\n')}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function firstLine(file) {
|
|
204
|
+
const raw = readFileSync(file, 'utf8');
|
|
205
|
+
const i = raw.indexOf('\n');
|
|
206
|
+
return (i === -1 ? raw : raw.slice(0, i)).replace(/\r$/, '').trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const isOwned = (file) => { try { return firstLine(file) === COWORK_MARKER; } catch { return false; } };
|
|
210
|
+
|
|
211
|
+
// ---------- git exclude ----------
|
|
212
|
+
|
|
213
|
+
const git = (dir, args) => execFileSync('git', ['-C', dir, ...args], {
|
|
214
|
+
timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8',
|
|
215
|
+
}).trim();
|
|
216
|
+
|
|
217
|
+
function addGitExclude(dir) {
|
|
218
|
+
try {
|
|
219
|
+
if (git(dir, ['rev-parse', '--is-inside-work-tree']) !== 'true') return;
|
|
220
|
+
const rel = COPY_REL.split(sep).join('/');
|
|
221
|
+
try { git(dir, ['check-ignore', '-q', rel]); return; } catch { /* exit 1: not ignored yet */ }
|
|
222
|
+
const excludeFile = resolve(dir, git(dir, ['rev-parse', '--git-path', 'info/exclude']));
|
|
223
|
+
const prefix = git(dir, ['rev-parse', '--show-prefix']);
|
|
224
|
+
const line = `/${prefix}${rel}`;
|
|
225
|
+
let existing = '';
|
|
226
|
+
try { existing = readFileSync(excludeFile, 'utf8'); } catch { /* new file */ }
|
|
227
|
+
if (existing.split(/\r?\n/).includes(line)) return;
|
|
228
|
+
mkdirSync(resolve(excludeFile, '..'), { recursive: true });
|
|
229
|
+
appendFileSync(excludeFile, `${existing && !existing.endsWith('\n') ? '\n' : ''}${line}\n`);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
debug('cowork-sync:git-exclude', e);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------- settings.json (Code tab de-dup) ----------
|
|
236
|
+
|
|
237
|
+
const settingsFile = () => join(claudeUserDir(), 'settings.json');
|
|
238
|
+
|
|
239
|
+
function ensureClaudeMdExclude() {
|
|
240
|
+
const file = settingsFile();
|
|
241
|
+
const read = readSettings(file);
|
|
242
|
+
if (read.state !== 'ok' && read.state !== 'absent') return;
|
|
243
|
+
const cur = read.settings.claudeMdExcludes;
|
|
244
|
+
if (cur !== undefined && !Array.isArray(cur)) return; // not ours to reshape
|
|
245
|
+
if (cur && cur.includes(COWORK_EXCLUDE_PATTERN)) return;
|
|
246
|
+
read.settings.claudeMdExcludes = [...(cur || []), COWORK_EXCLUDE_PATTERN];
|
|
247
|
+
const problem = writeSettings(file, read.settings);
|
|
248
|
+
if (problem) debug('cowork-sync:settings-write', problem);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function dropClaudeMdExclude() {
|
|
252
|
+
const file = settingsFile();
|
|
253
|
+
const read = readSettings(file);
|
|
254
|
+
if (read.state !== 'ok') return;
|
|
255
|
+
const cur = read.settings.claudeMdExcludes;
|
|
256
|
+
if (!Array.isArray(cur) || !cur.includes(COWORK_EXCLUDE_PATTERN)) return;
|
|
257
|
+
const next = cur.filter((x) => x !== COWORK_EXCLUDE_PATTERN);
|
|
258
|
+
if (next.length) read.settings.claudeMdExcludes = next;
|
|
259
|
+
else delete read.settings.claudeMdExcludes;
|
|
260
|
+
const problem = writeSettings(file, read.settings);
|
|
261
|
+
if (problem) debug('cowork-sync:settings-write', problem);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------- sync / remove ----------
|
|
265
|
+
|
|
266
|
+
export function syncCoworkRatchets({ discover = true, agent = 'claude' } = {}) {
|
|
267
|
+
const result = { written: [], unchanged: [], skipped: [], removed: [] };
|
|
268
|
+
if (agent === 'codex') return result;
|
|
269
|
+
try {
|
|
270
|
+
if (!coworkConfig().sync) return removeCoworkRatchets();
|
|
271
|
+
const state = loadCoworkState();
|
|
272
|
+
if (discover) {
|
|
273
|
+
try { discoverCoworkFolders({ state }); } catch (e) { debug('cowork-sync:discover', e); }
|
|
274
|
+
}
|
|
275
|
+
const content = renderCoworkRatchet();
|
|
276
|
+
for (const folder of Object.keys(state.folders)) {
|
|
277
|
+
try {
|
|
278
|
+
const el = eligibleFolder(folder);
|
|
279
|
+
if (!el.ok) { result.skipped.push({ dir: folder, reason: el.reason }); continue; }
|
|
280
|
+
if (content === null) { result.skipped.push({ dir: folder, reason: 'no-source' }); continue; }
|
|
281
|
+
const target = coworkCopyPath(el.dir);
|
|
282
|
+
if (existsSync(target)) {
|
|
283
|
+
if (!isOwned(target)) { result.skipped.push({ dir: folder, reason: 'not-owned' }); continue; }
|
|
284
|
+
if (readFileSync(target, 'utf8') === content) {
|
|
285
|
+
result.unchanged.push(folder);
|
|
286
|
+
state.written[target] = state.written[target] || { at: new Date().toISOString() };
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
mkdirSync(join(el.dir, '.claude', 'rules'), { recursive: true });
|
|
291
|
+
writeFileSync(target, content);
|
|
292
|
+
state.written[target] = { at: new Date().toISOString() };
|
|
293
|
+
result.written.push(folder);
|
|
294
|
+
addGitExclude(el.dir);
|
|
295
|
+
} catch (e) {
|
|
296
|
+
result.skipped.push({ dir: folder, reason: `error: ${e?.message || e}` });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (result.written.length || result.unchanged.length) {
|
|
300
|
+
try { ensureClaudeMdExclude(); } catch (e) { debug('cowork-sync:settings', e); }
|
|
301
|
+
}
|
|
302
|
+
state.lastSync = new Date().toISOString();
|
|
303
|
+
saveCoworkState(state);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
debug('cowork-sync:sync', e);
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Remove the copy in one folder (only when it carries our marker). */
|
|
311
|
+
export function removeCoworkRatchetIn(dir) {
|
|
312
|
+
const candidates = new Set([coworkCopyPath(dir), coworkCopyPath(real(dir))]);
|
|
313
|
+
let removed = false;
|
|
314
|
+
const state = loadCoworkState();
|
|
315
|
+
for (const target of candidates) {
|
|
316
|
+
try {
|
|
317
|
+
if (existsSync(target) && isOwned(target)) { rmSync(target, { force: true }); removed = true; }
|
|
318
|
+
} catch (e) { debug('cowork-sync:remove-one', e); }
|
|
319
|
+
delete state.written[target];
|
|
320
|
+
}
|
|
321
|
+
saveCoworkState(state);
|
|
322
|
+
return removed;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function removeCoworkRatchets() {
|
|
326
|
+
const result = { written: [], unchanged: [], skipped: [], removed: [] };
|
|
327
|
+
try {
|
|
328
|
+
const state = loadCoworkState();
|
|
329
|
+
const targets = new Set(Object.keys(state.written));
|
|
330
|
+
for (const folder of Object.keys(state.folders)) targets.add(coworkCopyPath(folder));
|
|
331
|
+
for (const target of targets) {
|
|
332
|
+
try {
|
|
333
|
+
if (existsSync(target) && isOwned(target)) {
|
|
334
|
+
rmSync(target, { force: true });
|
|
335
|
+
result.removed.push(resolve(target, '..', '..', '..'));
|
|
336
|
+
}
|
|
337
|
+
} catch (e) {
|
|
338
|
+
result.skipped.push({ dir: target, reason: `error: ${e?.message || e}` });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
state.written = {};
|
|
342
|
+
saveCoworkState(state);
|
|
343
|
+
try { dropClaudeMdExclude(); } catch (e) { debug('cowork-sync:settings', e); }
|
|
344
|
+
} catch (e) {
|
|
345
|
+
debug('cowork-sync:remove', e);
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Per-folder view for `sprag cowork`. */
|
|
351
|
+
export function coworkStatus() {
|
|
352
|
+
const state = loadCoworkState();
|
|
353
|
+
const cfg = coworkConfig();
|
|
354
|
+
const folders = Object.keys(state.folders).map((dir) => {
|
|
355
|
+
const el = eligibleFolder(dir);
|
|
356
|
+
const copy = coworkCopyPath(el.ok ? el.dir : dir);
|
|
357
|
+
const there = existsSync(copy);
|
|
358
|
+
return { dir, eligible: el.ok, reason: el.reason, hasCopy: there && isOwned(copy), foreign: there && !isOwned(copy) };
|
|
359
|
+
});
|
|
360
|
+
return { enabled: cfg.sync, exclude: cfg.exclude, lastSync: state.lastSync || null, folders };
|
|
361
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation-only subagents: Claude Code agents with a `tools` allowlist.
|
|
3
|
+
*
|
|
4
|
+
* Behind a gateway (ANTHROPIC_BASE_URL) Claude Code turns MCP tool search off,
|
|
5
|
+
* so every MCP tool definition rides along on each subagent request. A
|
|
6
|
+
* `general-purpose` haiku measured 46,618 tokens on its first request; a haiku
|
|
7
|
+
* agent limited to Read/Grep/Glob/Bash measured 20,141. A `tools` allowlist
|
|
8
|
+
* excludes MCP tools by definition, which is the whole saving.
|
|
9
|
+
*
|
|
10
|
+
* The files live in ~/.claude/agents and carry an ownership marker inside the
|
|
11
|
+
* frontmatter (a YAML comment, so it never reaches the prompt). Only files with
|
|
12
|
+
* that marker are ever overwritten or deleted; a same-named user file is left.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { claudeUserDir } from './paths.js';
|
|
18
|
+
import { loadConfig } from './config.js';
|
|
19
|
+
|
|
20
|
+
export const OWNER_MARK = 'sprag:delegation-agent';
|
|
21
|
+
const MARK_LINE = `# managed by sprag (${OWNER_MARK}); edits are overwritten`;
|
|
22
|
+
|
|
23
|
+
// Models are aliases on purpose: behind a gateway they resolve through
|
|
24
|
+
// ANTHROPIC_DEFAULT_*_MODEL, and a full model id would bypass that mapping.
|
|
25
|
+
export const DEFINITIONS = [
|
|
26
|
+
{
|
|
27
|
+
name: 'haiku-explore',
|
|
28
|
+
model: 'haiku',
|
|
29
|
+
tools: 'Read, Grep, Glob, Bash',
|
|
30
|
+
role: 'Your role: read-only exploration, lookups, status checks, and questions about pasted logs or text.',
|
|
31
|
+
description: 'Cheap read-only exploration, lookup, status checks and summaries for delegated tasks. It has no MCP tools.',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'haiku-runner',
|
|
35
|
+
model: 'haiku',
|
|
36
|
+
tools: 'Bash, Read, Grep, Glob',
|
|
37
|
+
role: 'Your role: run the build, test, or git commands you are given and report the result.',
|
|
38
|
+
description: 'Cheap runner for build, test and git commands in delegated tasks. It has no MCP tools.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'haiku-translate',
|
|
42
|
+
model: 'haiku',
|
|
43
|
+
tools: 'Read, Grep, Glob, Edit, Write',
|
|
44
|
+
role: 'Your role: batch translation and mechanical text conversion of the files or text you are given.',
|
|
45
|
+
description: 'Cheap batch translation and structured text conversion for delegated tasks. It has no MCP tools.',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'sonnet-worker',
|
|
49
|
+
model: 'sonnet',
|
|
50
|
+
tools: 'Read, Grep, Glob, Bash, Edit, Write, NotebookEdit',
|
|
51
|
+
role: 'Your role: multi-step work and the implementation half of a design the caller already fixed.',
|
|
52
|
+
description: 'Mid-tier worker for multi-step tasks and implementation handed over with a fixed design. It has no MCP tools.',
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function render(def) {
|
|
57
|
+
return [
|
|
58
|
+
'---',
|
|
59
|
+
`name: ${def.name}`,
|
|
60
|
+
`description: ${def.description}`,
|
|
61
|
+
`tools: ${def.tools}`,
|
|
62
|
+
`model: ${def.model}`,
|
|
63
|
+
MARK_LINE,
|
|
64
|
+
'---',
|
|
65
|
+
def.role,
|
|
66
|
+
'Do only the delegated task, and stay within the caps (tool calls, output tokens) the caller wrote in the prompt.',
|
|
67
|
+
"Report concisely, in the language of the caller's prompt.",
|
|
68
|
+
'If you need an MCP tool or any tool not on your list, stop at once and report one line: "needs general-purpose: <tools needed>". The parent will delegate again.',
|
|
69
|
+
'Do not guess at anything you could not verify; say that you could not verify it.',
|
|
70
|
+
'',
|
|
71
|
+
].join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const agentsDir = () => join(claudeUserDir(), 'agents');
|
|
75
|
+
const fileOf = (name) => join(agentsDir(), `${name}.md`);
|
|
76
|
+
|
|
77
|
+
/** True when the marker sits inside the leading frontmatter block. */
|
|
78
|
+
function isOwned(text) {
|
|
79
|
+
if (!text.startsWith('---')) return false;
|
|
80
|
+
const end = text.indexOf('\n---', 3);
|
|
81
|
+
return (end === -1 ? text : text.slice(0, end)).includes(OWNER_MARK);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readOwned(path) {
|
|
85
|
+
try { return { text: readFileSync(path, 'utf8') }; } catch { return null; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function removeDelegationAgents() {
|
|
89
|
+
const removed = [];
|
|
90
|
+
for (const def of DEFINITIONS) {
|
|
91
|
+
const p = fileOf(def.name);
|
|
92
|
+
try {
|
|
93
|
+
const f = readOwned(p);
|
|
94
|
+
if (f && isOwned(f.text)) { unlinkSync(p); removed.push(def.name); }
|
|
95
|
+
} catch { /* one file failing does not stop the rest */ }
|
|
96
|
+
}
|
|
97
|
+
return { written: [], unchanged: [], skipped: [], removed };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function syncDelegationAgents({ agent = 'claude' } = {}) {
|
|
101
|
+
const res = { written: [], unchanged: [], skipped: [], removed: [] };
|
|
102
|
+
if (agent === 'codex') return res;
|
|
103
|
+
try {
|
|
104
|
+
if (loadConfig().delegationAgents === false) return removeDelegationAgents();
|
|
105
|
+
} catch { /* an unreadable config means the default, on */ }
|
|
106
|
+
for (const def of DEFINITIONS) {
|
|
107
|
+
try {
|
|
108
|
+
const p = fileOf(def.name);
|
|
109
|
+
const want = render(def);
|
|
110
|
+
const cur = existsSync(p) ? readOwned(p) : null;
|
|
111
|
+
if (existsSync(p) && !cur) { res.skipped.push({ name: def.name, reason: 'unreadable' }); continue; }
|
|
112
|
+
if (cur && !isOwned(cur.text)) { res.skipped.push({ name: def.name, reason: 'not-owned' }); continue; }
|
|
113
|
+
if (cur && cur.text === want) { res.unchanged.push(def.name); continue; }
|
|
114
|
+
mkdirSync(agentsDir(), { recursive: true });
|
|
115
|
+
writeFileSync(p, want);
|
|
116
|
+
res.written.push(def.name);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
res.skipped.push({ name: def.name, reason: `error: ${e.message}` });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return res;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function delegationAgentStatus() {
|
|
125
|
+
let enabled = true;
|
|
126
|
+
try { enabled = loadConfig().delegationAgents !== false; } catch { /* default on */ }
|
|
127
|
+
const agents = DEFINITIONS.map((def) => {
|
|
128
|
+
const f = existsSync(fileOf(def.name)) ? readOwned(fileOf(def.name)) : null;
|
|
129
|
+
const state = !f ? 'absent' : isOwned(f.text) ? 'installed-by-sprag' : 'user-file';
|
|
130
|
+
return { name: def.name, state };
|
|
131
|
+
});
|
|
132
|
+
return { enabled, agents };
|
|
133
|
+
}
|
package/src/harness.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
|
|
2
|
+
|
|
1
3
|
/** Shared policy, rendered with each client's native model selector. */
|
|
2
4
|
export function implementationDelegationRule(agent = 'claude', lang = 'en') {
|
|
3
5
|
const target = agent === 'codex'
|
|
4
6
|
? 'the resolved T1 model for the current parent model and provider (explicit mapping first, automatic resolution otherwise)'
|
|
5
|
-
: 'a model: sonnet subagent';
|
|
7
|
+
: (resolveAgentName('sonnet', null) ? agentPhraseEn('sonnet', { root: null }) : 'a model: sonnet subagent');
|
|
8
|
+
const targetKo = resolveAgentName('sonnet', null) ? agentPhrase('sonnet', { root: null }) : 'model: sonnet 서브에이전트';
|
|
6
9
|
if (lang === 'ko') return [
|
|
7
10
|
'- **구현 분업**: 여러 파일을 고칠 때 설계와 스펙 확정은 메인 모델이 맡습니다.',
|
|
8
|
-
` 구현은 ${agent === 'codex' ? '현재 부모 모델과 프로바이더의 T1 모델' :
|
|
11
|
+
` 구현은 ${agent === 'codex' ? '현재 부모 모델과 프로바이더의 T1 모델' : targetKo}에 위임합니다.`,
|
|
9
12
|
' Claude는 sonnet을 쓰고, Codex는 명시적 T1 매핑을 우선하며 없으면 자동 해석한 T1을 씁니다.',
|
|
10
13
|
' 두 모델의 성능이 같다고 가정하지 않으며 haiku나 T2에 구현을 맡기지 않습니다.',
|
|
11
14
|
' 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산과',
|
package/src/model-rules.js
CHANGED
|
@@ -33,7 +33,7 @@ import { join, dirname } from 'node:path';
|
|
|
33
33
|
import { homedir } from 'node:os';
|
|
34
34
|
import { userDataDir } from './paths.js';
|
|
35
35
|
import { userLanguage } from './config.js';
|
|
36
|
-
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
36
|
+
import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
|
|
37
37
|
import { writeStateFile } from './state-file.js';
|
|
38
38
|
import { signedUsd } from './money.js';
|
|
39
39
|
import { implementationDelegationRule } from './implementation-delegation.js';
|
|
@@ -132,16 +132,23 @@ function shortExample(text) {
|
|
|
132
132
|
return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// T1 is named through its delegation agent only when that file exists; without
|
|
136
|
+
// it the literal `model: sonnet` stays so output on such machines is unchanged.
|
|
137
|
+
function t1Target(root, ko) {
|
|
138
|
+
if (resolveAgentName('sonnet', root) === null) return null;
|
|
139
|
+
return (ko ? agentPhrase : agentPhraseEn)('sonnet', { root });
|
|
140
|
+
}
|
|
141
|
+
|
|
135
142
|
export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
|
|
136
143
|
if (lang === 'ko') {
|
|
137
144
|
return g.tier === 'T2'
|
|
138
145
|
? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
|
|
139
|
-
: `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 model: sonnet에 위임`;
|
|
146
|
+
: `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
|
|
140
147
|
}
|
|
141
148
|
const example = g.exampleEn || g.example;
|
|
142
149
|
return g.tier === 'T2'
|
|
143
150
|
? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
|
|
144
|
-
: `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to a model: sonnet subagent`;
|
|
151
|
+
: `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
// See the note in route-scan.js — paths.js is the only place that resolves
|
|
@@ -401,10 +408,10 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
|
|
|
401
408
|
// match at a glance.
|
|
402
409
|
const rule = ko
|
|
403
410
|
? `"${t2.label}": 기본 ${agentPhrase(t2.agent, { root })}(예: "${ex(t2)}"), ` +
|
|
404
|
-
`여러 단계·여러 파일이 얽히면 model: sonnet(예: "${ex(t1)}"). ` +
|
|
411
|
+
`여러 단계·여러 파일이 얽히면 ${t1Target(root, true) ?? 'model: sonnet'}(예: "${ex(t1)}"). ` +
|
|
405
412
|
mergedBudget(t2, t1)
|
|
406
413
|
: `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent, { root })} by default (e.g. "${ex(t2)}"), ` +
|
|
407
|
-
|
|
414
|
+
`${t1Target(root, false) ?? 'model: sonnet'} when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
|
|
408
415
|
mergedBudget(t2, t1);
|
|
409
416
|
lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)}${extraHealth} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)}${extraStats} -->`);
|
|
410
417
|
} else if (t2 || t1) {
|
package/src/route-inject.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
import { categorizeScored, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
|
|
35
35
|
import { loadModelRules, budgetCapPhrase, ruleBudget } from './model-rules.js';
|
|
36
36
|
import { sharedProjectRoots, realProjectRoot, scopeRank } from './shared-model-rules.js';
|
|
37
|
-
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
37
|
+
import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
|
|
38
38
|
import { userLanguage } from './config.js';
|
|
39
39
|
import { aliasForRole, resolveModelAlias } from './model-alias.js';
|
|
40
40
|
import { isRecognizedModelId, modelRank } from './cost.js';
|
|
@@ -259,11 +259,16 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
|
|
|
259
259
|
const raw = ko ? agentPhrase : agentPhraseEn;
|
|
260
260
|
const phrase = (name) => raw(name, root === undefined ? undefined : { root });
|
|
261
261
|
|
|
262
|
+
// T1 is named through its delegation agent only when that file exists here;
|
|
263
|
+
// otherwise the literal tier string stays, so output without agents is unchanged.
|
|
264
|
+
const t1Root = root === undefined ? undefined : root;
|
|
265
|
+
const t1Named = resolveAgentName('sonnet', t1Root) !== null;
|
|
266
|
+
const t1Phrase = t1Named ? phrase('sonnet') : 'model: sonnet';
|
|
262
267
|
let target;
|
|
263
268
|
if (t2 && t1) {
|
|
264
269
|
target = ko
|
|
265
|
-
? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면
|
|
266
|
-
: `${phrase(t2.agent)} by default,
|
|
270
|
+
? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 ${t1Phrase}`
|
|
271
|
+
: `${phrase(t2.agent)} by default, ${t1Phrase} when it spans multiple steps or file edits`;
|
|
267
272
|
} else {
|
|
268
273
|
const only = t2 || t1;
|
|
269
274
|
target = ko ? phrase(only.agent) : phrase(only.agent);
|