Repository navigation
Expand file tree
/
Copy pathdialects.mjs
More file actions
279 lines (251 loc) · 10.2 KB
/
Copy pathdialects.mjs
File metadata and controls
279 lines (251 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
// One neutral source, two harnesses. The body is shared; only the frontmatter
// differs, and it is composed here rather than maintained twice.
//
// 🔴 The source is in **neither** dialect on purpose. If it were written in one
// harness's format, the other would be a second-class citizen translated as an
// afterthought — and the one that drifts is always the one nobody looks at.
const FENCE = /^---\n([\s\S]*?)\n---\n?/;
export class SourceError extends Error {}
// A deliberately small frontmatter reader: `key: value`, plus `- item` lists
// under a key. Enough for what these files declare, and it fails loudly on
// anything else rather than guessing.
export function parseSource(text, file = 'source') {
const m = FENCE.exec(String(text));
if (!m) throw new SourceError(`${file}: no frontmatter`);
const front = {};
let listKey = null;
let lastKey = null;
// A key with nothing after the colon cannot be classified yet: the next line
// decides. `- item` makes it a list; indented text makes it a wrapped value.
let pendingKey = null;
for (const raw of m[1].split('\n')) {
if (!raw.trim()) continue;
const item = /^\s*-\s+(.*)$/.exec(raw);
if (item) {
if (pendingKey) {
front[pendingKey] = [];
listKey = pendingKey;
pendingKey = null;
}
if (!listKey) throw new SourceError(`${file}: list item with no key`);
front[listKey].push(item[1].trim());
lastKey = null;
continue;
}
if (pendingKey && /^\s+\S/.test(raw)) {
front[pendingKey] = raw.trim();
lastKey = pendingKey;
listKey = null;
pendingKey = null;
continue;
}
const pair = /^([a-z][a-z0-9-]*):\s*(.*)$/i.exec(raw);
if (!pair) {
// ⚠️ An indented line after a key is that key's value continuing. This
// is not a nicety: Prettier reflows long frontmatter values onto a second
// line, so the repo's own formatter produced files this parser refused.
// The reader has to survive what the writer does to its own sources.
if (lastKey && /^\s+\S/.test(raw)) {
front[lastKey] = `${front[lastKey]} ${raw.trim()}`.trim();
continue;
}
throw new SourceError(`${file}: cannot read "${raw.trim()}"`);
}
const [, key, value] = pair;
if (pendingKey) {
// Nothing followed it: an empty key is an empty list.
front[pendingKey] = [];
pendingKey = null;
}
if (value === '') {
pendingKey = key;
listKey = null;
lastKey = null;
} else {
listKey = null;
lastKey = key;
front[key] = value.trim();
}
}
if (pendingKey) front[pendingKey] = [];
return { front, body: String(text).slice(m[0].length).trimStart() };
}
export function requireField(front, key, file) {
const value = front[key];
if (!value) throw new SourceError(`${file}: missing "${key}"`);
return value;
}
const list = (v) => (Array.isArray(v) ? v : []);
const tools = (front) =>
String(front.tools ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const WRITES = ['edit', 'write'];
const canWrite = (t) => t.some((x) => WRITES.includes(x));
// ── Model tier ───────────────────────────────────────────────────────────────
//
// The source names a tier, never a model: a model id is one provider's word.
// `fast` is the only tier, for work a small model was measured doing, and only
// Claude Code gets one (ADR-038).
const TIERS = ['fast'];
const CLAUDE_MODEL = { fast: 'haiku' };
function tierOf(front, file) {
const tier = front.model;
if (tier === undefined) return null;
if (!TIERS.includes(tier)) {
throw new SourceError(
`${file}: model "${tier}" is not a tier (${TIERS.join(', ')})`,
);
}
return tier;
}
// Proof that a deployed file is ours, so the installer may delete it later. In
// the body and not the frontmatter: opencode forwards unknown frontmatter keys
// to the model provider.
export const MARK = '<!-- generated-by: harness -->';
function frontmatter(lines, body) {
return `---\n${lines.join('\n')}\n---\n\n${MARK}\n\n${body}`;
}
// ── Claude Code ──────────────────────────────────────────────────────────────
const CLAUDE_TOOL = {
bash: 'Bash',
glob: 'Glob',
grep: 'Grep',
read: 'Read',
edit: 'Edit',
write: 'Write',
};
export function forClaude(front, body, file = 'source') {
const name = requireField(front, 'name', file);
const kind = front.kind ?? 'agent';
const description = requireField(front, 'description', file);
// 🔴 Agents and commands are not the same shape here. An agent declares
// `name` and `tools`; a command takes its name from its filename and spells
// the field `allowed-tools`. Writing the agent shape into a command file was
// caught by comparing against the copies the previous tool wrote — no test
// would have known which spelling the tool expects.
const head =
kind === 'agent'
? [`name: ${name}`, `description: ${description}`]
: [`description: ${description}`];
const tier = tierOf(front, file);
if (tier) head.push(`model: ${CLAUDE_MODEL[tier]}`);
const allow = list(front['bash-allow']);
const mapped = [
...new Set(
tools(front)
.map((t) => CLAUDE_TOOL[t])
.filter(Boolean),
),
];
// A command's `allowed-tools` takes patterns, so its bash is scoped
// the way opencode scopes it. A subagent's `tools` does not.
const scoped =
kind === 'agent' || !allow.length
? mapped
: mapped.flatMap((t) => {
if (t !== 'Bash') return [t];
// `)` or `,` would end a pattern early and widen what it allows.
const bad = allow.find((p) => /[(),]/.test(p));
if (bad)
throw new SourceError(`${file}: bash-allow pattern "${bad}"`);
return allow.map((p) => `Bash(${p})`);
});
if (scoped.length) {
head.push(
`${kind === 'agent' ? 'tools' : 'allowed-tools'}: ${scoped.join(', ')}`,
);
}
const warnings = [];
// 🔴 Said out loud rather than dropped quietly. For a subagent the allow-list
// is real protection in the other harness and does not exist here, so a
// reader of this file must not assume the restriction travelled with it.
if (kind === 'agent' && allow.length) {
warnings.push(
`${name}: the bash allow-list does not travel to Claude Code — ` +
'it declares tool names only, so bash is unrestricted there.',
);
}
return { content: frontmatter(head, body), warnings };
}
// ── opencode ─────────────────────────────────────────────────────────────────
export function forOpencode(front, body, file = 'source') {
const name = requireField(front, 'name', file);
const kind = front.kind ?? 'agent';
const t = tools(front);
const head = [
`name: ${name}`,
`description: ${requireField(front, 'description', file)}`,
];
const warnings = [];
// ⚠️ Not written: in opencode the command runs on the session's model, by
// the owner's choice (ADR-038). Said, so nobody expects the tier to travel.
if (tierOf(front, file)) {
warnings.push(
`${name}: \`model: fast\` is not applied in opencode — it runs on the ` +
"session's model.",
);
}
if (kind === 'agent') {
head.push('mode: subagent');
if (front.temperature) head.push(`temperature: ${front.temperature}`);
head.push('permission:', ` edit: ${canWrite(t) ? 'allow' : 'deny'}`);
// ⚠️ Written for V1, which applies it. V2 moves it under `request.body`
// and does not send `request` yet, so there it is carried and ignored —
// said, so nobody tunes a value that changes nothing.
if (front.temperature) {
warnings.push(
`${name}: \`temperature: ${front.temperature}\` is not applied in ` +
'opencode V2 — it moves under `request.body`, which V2 does not ' +
'send yet. V1 applies it.',
);
}
const allow = list(front['bash-allow']);
// 🔴 Omitting the block when there is no bash left it **allowed** — the
// opposite of what the source declares. Absence of a permission is not a
// denial, so the denial is written.
if (!t.includes('bash')) {
head.push(' bash:', " '*': deny");
} else if (allow.length) {
head.push(' bash:', " '*': deny");
for (const pattern of allow) head.push(` '${pattern}': allow`);
}
} else {
head.push('agent: build');
// A command that runs as a subtask says so, or it runs in the main
// session and spends its context. Declared in the source and easy to
// drop silently on the way out.
if (String(front.subtask) === 'true') head.push('subtask: true');
// opencode command frontmatter does not support `tools:` (only agents do),
// so the source's tool list is intentionally not emitted here. The command
// inherits the permissions of the named `agent`.
}
return { content: frontmatter(head, body), warnings };
}
export const DIALECTS = { claude: forClaude, opencode: forOpencode };
// ── Which harnesses to write ─────────────────────────────────────────────────
//
// Claude Code is always there: installing this harness assumes it. opencode is
// optional, so it is written only where its own directory exists, or when asked
// for with `--opencode`. Writing it everywhere left stale configuration on
// machines that never had it (measured 2026-09-06).
export const OPENCODE_HOME = '.config/opencode';
export function chooseHarnesses({ opencode = false, forced = false } = {}) {
if (forced) {
return {
targets: ['claude', 'opencode'],
why: 'claude, and opencode as asked for',
};
}
if (opencode) {
return {
targets: ['claude', 'opencode'],
why: 'claude, and opencode found here',
};
}
return {
targets: ['claude'],
why: 'claude only: no opencode found here (--opencode writes it anyway)',
};
}