@vaubaan/harness 0.0.0-stage → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +307 -2
- package/dist/agents/coding.agent.md +182 -0
- package/dist/agents/merge.agent.md +139 -0
- package/dist/agents/planning.agent.md +194 -0
- package/dist/agents/review.agent.md +176 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +2924 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/commands/clean-code.cmd.md +26 -0
- package/dist/commands/decision.cmd.md +19 -0
- package/dist/commands/doc-audit.cmd.md +24 -0
- package/dist/commands/drift.cmd.md +18 -0
- package/dist/commands/handbook.cmd.md +21 -0
- package/dist/commands/readme.cmd.md +19 -0
- package/dist/commands/spec.cmd.md +20 -0
- package/dist/commands/test-plan.cmd.md +22 -0
- package/dist/commands/workflow.cmd.md +19 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +1706 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +42 -0
- package/dist/init.js +1088 -0
- package/dist/init.js.map +1 -0
- package/dist/linters/index.d.ts +43 -0
- package/dist/linters/index.js +727 -0
- package/dist/linters/index.js.map +1 -0
- package/dist/parser/index.d.ts +247 -0
- package/dist/parser/index.js +114 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/render-B9_kNtQc.d.ts +50 -0
- package/dist/render.d.ts +3 -0
- package/dist/render.js +572 -0
- package/dist/render.js.map +1 -0
- package/dist/skills/clean-code-guardian.skill.md +391 -0
- package/dist/skills/decision-record-writer.skill.md +166 -0
- package/dist/skills/doc-audit.skill.md +257 -0
- package/dist/skills/drift-detector.skill.md +173 -0
- package/dist/skills/handbook-writer.skill.md +185 -0
- package/dist/skills/readme-writer.skill.md +131 -0
- package/dist/skills/spec-writer.skill.md +165 -0
- package/dist/skills/test-plan-writer.skill.md +179 -0
- package/dist/skills/workflow-writer.skill.md +168 -0
- package/dist/templates/code-quality.md.tmpl +57 -0
- package/dist/templates/coding-workflow.md.tmpl +88 -0
- package/dist/templates/decision.md.tmpl +60 -0
- package/dist/templates/e2e-tests.md.tmpl +62 -0
- package/dist/templates/handbook.md.tmpl +58 -0
- package/dist/templates/integration-tests.md.tmpl +50 -0
- package/dist/templates/readme.md.tmpl +51 -0
- package/dist/templates/spec.md.tmpl +64 -0
- package/package.json +82 -4
|
@@ -0,0 +1,2924 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { dirname as dirname8, resolve as resolve8 } from "path";
|
|
5
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
|
|
8
|
+
// src/linters/util.ts
|
|
9
|
+
import { execFileSync } from "child_process";
|
|
10
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
11
|
+
import { join, relative } from "path";
|
|
12
|
+
import fg from "fast-glob";
|
|
13
|
+
import matter from "gray-matter";
|
|
14
|
+
function fileExists(repoRoot, relativePath) {
|
|
15
|
+
return existsSync(join(repoRoot, relativePath));
|
|
16
|
+
}
|
|
17
|
+
function readText(repoRoot, relativePath) {
|
|
18
|
+
return readFileSync(join(repoRoot, relativePath), "utf8");
|
|
19
|
+
}
|
|
20
|
+
function listMarkdown(repoRoot, dir) {
|
|
21
|
+
const cwd = join(repoRoot, dir);
|
|
22
|
+
if (!existsSync(cwd)) return [];
|
|
23
|
+
return fg.sync("**/*.md", { cwd, absolute: false, ignore: ["**/node_modules/**", "**/dist/**"] }).filter((f) => !f.endsWith("README.md")).map((f) => join(dir, f));
|
|
24
|
+
}
|
|
25
|
+
function dirExists(repoRoot, relativePath) {
|
|
26
|
+
const full = join(repoRoot, relativePath);
|
|
27
|
+
if (!existsSync(full)) return false;
|
|
28
|
+
return statSync(full).isDirectory();
|
|
29
|
+
}
|
|
30
|
+
function parseFrontmatter(text) {
|
|
31
|
+
const m = matter(text);
|
|
32
|
+
return { data: m.data, body: m.content };
|
|
33
|
+
}
|
|
34
|
+
function gitLastCommitForPath(repoRoot, relativePath) {
|
|
35
|
+
try {
|
|
36
|
+
const out = execFileSync("git", ["log", "-1", "--format=%H:%ct", "--", relativePath], {
|
|
37
|
+
cwd: repoRoot,
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
40
|
+
}).trim();
|
|
41
|
+
if (!out) return void 0;
|
|
42
|
+
const idx = out.indexOf(":");
|
|
43
|
+
if (idx < 0) return void 0;
|
|
44
|
+
const hash2 = out.slice(0, idx);
|
|
45
|
+
const ts = Number.parseInt(out.slice(idx + 1), 10);
|
|
46
|
+
return { hash: hash2, timestamp: ts };
|
|
47
|
+
} catch {
|
|
48
|
+
return void 0;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function gitCommitsSince(repoRoot, relativePath, sinceCommit) {
|
|
52
|
+
try {
|
|
53
|
+
const out = execFileSync(
|
|
54
|
+
"git",
|
|
55
|
+
["log", `${sinceCommit}..HEAD`, "--format=%H", "--", relativePath],
|
|
56
|
+
{
|
|
57
|
+
cwd: repoRoot,
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
60
|
+
}
|
|
61
|
+
).trim();
|
|
62
|
+
if (!out) return 0;
|
|
63
|
+
return out.split("\n").filter(Boolean).length;
|
|
64
|
+
} catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function findLineNumber(text, needle) {
|
|
69
|
+
const lines = text.split("\n");
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i] ?? "";
|
|
72
|
+
if (typeof needle === "string" ? line.includes(needle) : needle.test(line)) {
|
|
73
|
+
return i + 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return void 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/linters/adr-no-tracks.ts
|
|
80
|
+
var ADR_DIR = "docs/decisions";
|
|
81
|
+
var adrNoTracks = {
|
|
82
|
+
id: "lint:adr-no-tracks",
|
|
83
|
+
pillars: ["decisions"],
|
|
84
|
+
defaultSeverity: "error",
|
|
85
|
+
suggestedSkills: ["/decision"],
|
|
86
|
+
run(repoRoot) {
|
|
87
|
+
const issues = [];
|
|
88
|
+
const files = listMarkdown(repoRoot, ADR_DIR);
|
|
89
|
+
for (const f of files) {
|
|
90
|
+
const text = readText(repoRoot, f);
|
|
91
|
+
const { data } = parseFrontmatter(text);
|
|
92
|
+
if (!("tracks" in data)) continue;
|
|
93
|
+
const value = data.tracks;
|
|
94
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
95
|
+
issues.push({
|
|
96
|
+
linter: this.id,
|
|
97
|
+
severity: "error",
|
|
98
|
+
file: f,
|
|
99
|
+
message: "ADRs must not declare `tracks:` (or must set it to []). ADRs document a decision frozen in time, not the current state of code \u2014 use status: superseded to mark them obsolete instead."
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return issues;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// src/linters/cardinal-files.ts
|
|
107
|
+
var REQUIRED_FILES = [
|
|
108
|
+
"docs/tests/integration-tests.md",
|
|
109
|
+
"docs/tests/e2e-tests.md",
|
|
110
|
+
"docs/workflow/coding-workflow.md"
|
|
111
|
+
];
|
|
112
|
+
var cardinalFiles = {
|
|
113
|
+
id: "lint:cardinal-files",
|
|
114
|
+
pillars: ["tests", "workflow"],
|
|
115
|
+
defaultSeverity: "error",
|
|
116
|
+
suggestedSkills: ["/test-plan", "/workflow"],
|
|
117
|
+
run(repoRoot) {
|
|
118
|
+
const issues = [];
|
|
119
|
+
for (const f of REQUIRED_FILES) {
|
|
120
|
+
if (!fileExists(repoRoot, f)) {
|
|
121
|
+
issues.push({
|
|
122
|
+
linter: this.id,
|
|
123
|
+
severity: "error",
|
|
124
|
+
file: f,
|
|
125
|
+
message: `missing required cardinal file: ${f}`
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return issues;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// src/linters/config.ts
|
|
134
|
+
import { cosmiconfigSync } from "cosmiconfig";
|
|
135
|
+
var DEFAULT_CONFIG = { ignore: [] };
|
|
136
|
+
function loadLintConfig(repoRoot) {
|
|
137
|
+
const explorer = cosmiconfigSync("harness", {
|
|
138
|
+
searchPlaces: [
|
|
139
|
+
"package.json",
|
|
140
|
+
".harnessrc",
|
|
141
|
+
".harnessrc.json",
|
|
142
|
+
".harnessrc.yaml",
|
|
143
|
+
".harnessrc.yml",
|
|
144
|
+
"harness.config.js",
|
|
145
|
+
"harness.config.cjs"
|
|
146
|
+
]
|
|
147
|
+
});
|
|
148
|
+
const result = explorer.search(repoRoot);
|
|
149
|
+
if (!result || result.isEmpty) return DEFAULT_CONFIG;
|
|
150
|
+
const raw = result.config;
|
|
151
|
+
if (!raw) return DEFAULT_CONFIG;
|
|
152
|
+
const lint = raw.lint;
|
|
153
|
+
if (!lint) return DEFAULT_CONFIG;
|
|
154
|
+
const ignore = lint.ignore;
|
|
155
|
+
if (!Array.isArray(ignore)) return DEFAULT_CONFIG;
|
|
156
|
+
return { ignore: ignore.filter((x) => typeof x === "string") };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/linters/cross-links.ts
|
|
160
|
+
import { existsSync as existsSync2 } from "fs";
|
|
161
|
+
import { dirname, isAbsolute, join as join2, resolve } from "path";
|
|
162
|
+
import fg2 from "fast-glob";
|
|
163
|
+
var LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
164
|
+
function maskCodeSpans(text) {
|
|
165
|
+
return text.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, " ")).replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length));
|
|
166
|
+
}
|
|
167
|
+
function isExternalOrAnchor(url) {
|
|
168
|
+
if (url.startsWith("http://") || url.startsWith("https://")) return true;
|
|
169
|
+
if (url.startsWith("mailto:")) return true;
|
|
170
|
+
if (url.startsWith("#")) return true;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
function stripFragment(url) {
|
|
174
|
+
const i = url.indexOf("#");
|
|
175
|
+
return i < 0 ? url : url.slice(0, i);
|
|
176
|
+
}
|
|
177
|
+
function resolveLink(repoRoot, sourceFile, link) {
|
|
178
|
+
const noFrag = stripFragment(link);
|
|
179
|
+
if (isAbsolute(noFrag)) return join2(repoRoot, noFrag);
|
|
180
|
+
return resolve(dirname(join2(repoRoot, sourceFile)), noFrag);
|
|
181
|
+
}
|
|
182
|
+
var crossLinks = {
|
|
183
|
+
id: "lint:cross-links",
|
|
184
|
+
pillars: ["all"],
|
|
185
|
+
defaultSeverity: "error",
|
|
186
|
+
run(repoRoot, ctx) {
|
|
187
|
+
const extra = ctx?.config.ignore ?? [];
|
|
188
|
+
const issues = [];
|
|
189
|
+
const allDocs = fg2.sync("**/*.md", {
|
|
190
|
+
cwd: repoRoot,
|
|
191
|
+
absolute: false,
|
|
192
|
+
ignore: [
|
|
193
|
+
"**/node_modules/**",
|
|
194
|
+
"**/dist/**",
|
|
195
|
+
".harness-overrides/**",
|
|
196
|
+
"skills/**",
|
|
197
|
+
"agents/**",
|
|
198
|
+
"commands/**",
|
|
199
|
+
"templates/**",
|
|
200
|
+
"**/*.skill.md",
|
|
201
|
+
"**/*.cmd.md",
|
|
202
|
+
"**/*.agent.md",
|
|
203
|
+
...extra
|
|
204
|
+
]
|
|
205
|
+
});
|
|
206
|
+
for (const f of allDocs) {
|
|
207
|
+
const text = maskCodeSpans(readText(repoRoot, f));
|
|
208
|
+
const lines = text.split("\n");
|
|
209
|
+
for (let lineNo = 0; lineNo < lines.length; lineNo++) {
|
|
210
|
+
const line = lines[lineNo] ?? "";
|
|
211
|
+
LINK_REGEX.lastIndex = 0;
|
|
212
|
+
let match;
|
|
213
|
+
match = LINK_REGEX.exec(line);
|
|
214
|
+
while (match !== null) {
|
|
215
|
+
const url = match[2];
|
|
216
|
+
if (url && !isExternalOrAnchor(url)) {
|
|
217
|
+
const noFrag = stripFragment(url);
|
|
218
|
+
if (noFrag !== "" && !noFrag.startsWith("data:")) {
|
|
219
|
+
const target = resolveLink(repoRoot, f, url);
|
|
220
|
+
if (!existsSync2(target)) {
|
|
221
|
+
issues.push({
|
|
222
|
+
linter: this.id,
|
|
223
|
+
severity: "error",
|
|
224
|
+
file: f,
|
|
225
|
+
line: lineNo + 1,
|
|
226
|
+
message: `broken link: ${url}`
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
match = LINK_REGEX.exec(line);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return issues;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// src/linters/decision-schema.ts
|
|
240
|
+
var REQUIRED_SECTIONS = [
|
|
241
|
+
{ label: "Context", pattern: /^##\s+(Context|Contexte)\b/m },
|
|
242
|
+
{ label: "Decision", pattern: /^##\s+(Decision|D[ée]cision)\b/m },
|
|
243
|
+
{ label: "Consequences", pattern: /^##\s+(Consequences|Cons[ée]quences)\b/m },
|
|
244
|
+
{
|
|
245
|
+
label: "Rejected alternatives",
|
|
246
|
+
pattern: /^##\s+(Rejected\s+alternatives|Alternatives(\s+rejet[ée]es)?)\b/im
|
|
247
|
+
}
|
|
248
|
+
];
|
|
249
|
+
var decisionSchema = {
|
|
250
|
+
id: "lint:decision-schema",
|
|
251
|
+
pillars: ["decisions"],
|
|
252
|
+
defaultSeverity: "error",
|
|
253
|
+
suggestedSkills: ["/decision"],
|
|
254
|
+
run(repoRoot) {
|
|
255
|
+
const issues = [];
|
|
256
|
+
const files = listMarkdown(repoRoot, "docs/decisions");
|
|
257
|
+
for (const f of files) {
|
|
258
|
+
const text = readText(repoRoot, f);
|
|
259
|
+
for (const { label, pattern } of REQUIRED_SECTIONS) {
|
|
260
|
+
if (!pattern.test(text)) {
|
|
261
|
+
issues.push({
|
|
262
|
+
linter: this.id,
|
|
263
|
+
severity: "error",
|
|
264
|
+
file: f,
|
|
265
|
+
message: `missing required section: "${label}"`
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return issues;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// src/linters/doc-layout.ts
|
|
275
|
+
var REQUIRED_DIRS = [
|
|
276
|
+
"docs/spec",
|
|
277
|
+
"docs/decisions",
|
|
278
|
+
"docs/handbook",
|
|
279
|
+
"docs/tests",
|
|
280
|
+
"docs/workflow"
|
|
281
|
+
];
|
|
282
|
+
var docLayout = {
|
|
283
|
+
id: "lint:doc-layout",
|
|
284
|
+
pillars: ["all"],
|
|
285
|
+
defaultSeverity: "error",
|
|
286
|
+
run(repoRoot) {
|
|
287
|
+
const issues = [];
|
|
288
|
+
for (const dir of REQUIRED_DIRS) {
|
|
289
|
+
if (!dirExists(repoRoot, dir)) {
|
|
290
|
+
issues.push({
|
|
291
|
+
linter: this.id,
|
|
292
|
+
severity: "error",
|
|
293
|
+
file: dir,
|
|
294
|
+
message: `missing required directory: ${dir}/`
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return issues;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/linters/frontmatter-tracks.ts
|
|
303
|
+
import { existsSync as existsSync3 } from "fs";
|
|
304
|
+
import { join as join3 } from "path";
|
|
305
|
+
import fg3 from "fast-glob";
|
|
306
|
+
var TRACKED_DIRS = ["docs/handbook", "docs/tests", "docs/workflow"];
|
|
307
|
+
function trackedPathExists(repoRoot, pattern) {
|
|
308
|
+
if (pattern.includes("*")) {
|
|
309
|
+
return fg3.sync(pattern, { cwd: repoRoot, absolute: false }).length > 0;
|
|
310
|
+
}
|
|
311
|
+
return existsSync3(join3(repoRoot, pattern));
|
|
312
|
+
}
|
|
313
|
+
var frontmatterTracks = {
|
|
314
|
+
id: "lint:frontmatter-tracks",
|
|
315
|
+
pillars: ["handbook", "tests", "workflow"],
|
|
316
|
+
defaultSeverity: "error",
|
|
317
|
+
suggestedSkills: ["/handbook", "/test-plan", "/workflow"],
|
|
318
|
+
run(repoRoot) {
|
|
319
|
+
const issues = [];
|
|
320
|
+
for (const dir of TRACKED_DIRS) {
|
|
321
|
+
const files = listMarkdown(repoRoot, dir);
|
|
322
|
+
for (const f of files) {
|
|
323
|
+
const text = readText(repoRoot, f);
|
|
324
|
+
const { data } = parseFrontmatter(text);
|
|
325
|
+
if (!Array.isArray(data.tracks)) {
|
|
326
|
+
issues.push({
|
|
327
|
+
linter: this.id,
|
|
328
|
+
severity: "error",
|
|
329
|
+
file: f,
|
|
330
|
+
message: "missing frontmatter `tracks:` array"
|
|
331
|
+
});
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
for (const tracked of data.tracks) {
|
|
335
|
+
if (typeof tracked !== "string") {
|
|
336
|
+
issues.push({
|
|
337
|
+
linter: this.id,
|
|
338
|
+
severity: "error",
|
|
339
|
+
file: f,
|
|
340
|
+
message: "`tracks` entries must be strings"
|
|
341
|
+
});
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (!trackedPathExists(repoRoot, tracked)) {
|
|
345
|
+
issues.push({
|
|
346
|
+
linter: this.id,
|
|
347
|
+
severity: "error",
|
|
348
|
+
file: f,
|
|
349
|
+
message: `tracked path does not exist: ${tracked}`
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return issues;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// src/linters/gitignore-harness.ts
|
|
360
|
+
var REQUIRED_GITIGNORE_ENTRIES = [
|
|
361
|
+
".harness-overrides/",
|
|
362
|
+
".harness-manifest.json",
|
|
363
|
+
".harness/",
|
|
364
|
+
".claude/",
|
|
365
|
+
".codex/",
|
|
366
|
+
".opencode/",
|
|
367
|
+
".agents/",
|
|
368
|
+
".vaubaan/",
|
|
369
|
+
"AGENTS.md",
|
|
370
|
+
"CLAUDE.md",
|
|
371
|
+
".mcp.json"
|
|
372
|
+
];
|
|
373
|
+
var gitignoreHarness = {
|
|
374
|
+
id: "lint:gitignore-harness",
|
|
375
|
+
pillars: ["repo-root"],
|
|
376
|
+
defaultSeverity: "error",
|
|
377
|
+
run(repoRoot) {
|
|
378
|
+
const issues = [];
|
|
379
|
+
if (!fileExists(repoRoot, ".gitignore")) {
|
|
380
|
+
issues.push({
|
|
381
|
+
linter: this.id,
|
|
382
|
+
severity: "error",
|
|
383
|
+
file: ".gitignore",
|
|
384
|
+
message: ".gitignore is missing at repo root"
|
|
385
|
+
});
|
|
386
|
+
return issues;
|
|
387
|
+
}
|
|
388
|
+
const text = readText(repoRoot, ".gitignore");
|
|
389
|
+
const lines = new Set(
|
|
390
|
+
text.split("\n").map((l) => l.trim()).filter(Boolean)
|
|
391
|
+
);
|
|
392
|
+
for (const entry of REQUIRED_GITIGNORE_ENTRIES) {
|
|
393
|
+
const variants = [entry, entry.replace(/\/$/, "")];
|
|
394
|
+
if (!variants.some((v) => lines.has(v))) {
|
|
395
|
+
issues.push({
|
|
396
|
+
linter: this.id,
|
|
397
|
+
severity: "error",
|
|
398
|
+
file: ".gitignore",
|
|
399
|
+
message: `.gitignore must contain "${entry}"`
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return issues;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// src/linters/orphan-decisions.ts
|
|
408
|
+
import { basename } from "path";
|
|
409
|
+
import fg4 from "fast-glob";
|
|
410
|
+
var orphanDecisions = {
|
|
411
|
+
id: "lint:orphan-decisions",
|
|
412
|
+
pillars: ["decisions"],
|
|
413
|
+
defaultSeverity: "warning",
|
|
414
|
+
suggestedSkills: ["/handbook"],
|
|
415
|
+
run(repoRoot, ctx) {
|
|
416
|
+
const extra = ctx?.config.ignore ?? [];
|
|
417
|
+
const issues = [];
|
|
418
|
+
const adrs = listMarkdown(repoRoot, "docs/decisions");
|
|
419
|
+
if (adrs.length === 0) return issues;
|
|
420
|
+
const otherDocs = fg4.sync("**/*.md", {
|
|
421
|
+
cwd: repoRoot,
|
|
422
|
+
absolute: false,
|
|
423
|
+
ignore: [
|
|
424
|
+
"**/node_modules/**",
|
|
425
|
+
"**/dist/**",
|
|
426
|
+
".harness-overrides/**",
|
|
427
|
+
"docs/decisions/**",
|
|
428
|
+
"skills/**",
|
|
429
|
+
"agents/**",
|
|
430
|
+
"commands/**",
|
|
431
|
+
"templates/**",
|
|
432
|
+
...extra
|
|
433
|
+
]
|
|
434
|
+
});
|
|
435
|
+
const referenceCorpus = otherDocs.map((f) => readText(repoRoot, f)).join("\n");
|
|
436
|
+
for (const adr of adrs) {
|
|
437
|
+
const filename = basename(adr);
|
|
438
|
+
const stem = filename.replace(/\.md$/, "");
|
|
439
|
+
const referenced = referenceCorpus.includes(filename) || referenceCorpus.includes(stem);
|
|
440
|
+
if (!referenced) {
|
|
441
|
+
issues.push({
|
|
442
|
+
linter: this.id,
|
|
443
|
+
severity: "warning",
|
|
444
|
+
file: adr,
|
|
445
|
+
message: "ADR is not referenced by any other doc file (orphan)"
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return issues;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// src/linters/quality-schema.ts
|
|
454
|
+
var QUALITY_DIR = "docs/quality";
|
|
455
|
+
var CARDINAL_FILE = `${QUALITY_DIR}/code-quality.md`;
|
|
456
|
+
var REQUIRED_SECTIONS2 = [
|
|
457
|
+
{
|
|
458
|
+
label: "Mechanical checks",
|
|
459
|
+
pattern: /^##\s+(Mechanical\s+checks|V[ée]rifications\s+m[ée]caniques)\s*$/im
|
|
460
|
+
},
|
|
461
|
+
{ label: "Project rules", pattern: /^##\s+(Project\s+rules|R[èe]gles\s+du\s+projet)\s*$/im },
|
|
462
|
+
{
|
|
463
|
+
label: "Accepted deviations",
|
|
464
|
+
pattern: /^##\s+(Accepted\s+deviations|[ÉE]carts\s+assum[ée]s)\s*$/im
|
|
465
|
+
}
|
|
466
|
+
];
|
|
467
|
+
var qualitySchema = {
|
|
468
|
+
id: "lint:quality-schema",
|
|
469
|
+
pillars: ["quality"],
|
|
470
|
+
defaultSeverity: "error",
|
|
471
|
+
run(repoRoot) {
|
|
472
|
+
if (!dirExists(repoRoot, QUALITY_DIR)) return [];
|
|
473
|
+
if (!fileExists(repoRoot, CARDINAL_FILE)) {
|
|
474
|
+
return [
|
|
475
|
+
{
|
|
476
|
+
linter: this.id,
|
|
477
|
+
severity: "error",
|
|
478
|
+
file: CARDINAL_FILE,
|
|
479
|
+
message: `missing required cardinal file: ${CARDINAL_FILE}`
|
|
480
|
+
}
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
const text = readText(repoRoot, CARDINAL_FILE);
|
|
484
|
+
return REQUIRED_SECTIONS2.filter(({ pattern }) => !pattern.test(text)).map(({ label }) => ({
|
|
485
|
+
linter: this.id,
|
|
486
|
+
severity: "error",
|
|
487
|
+
file: CARDINAL_FILE,
|
|
488
|
+
message: `missing required section: "${label}"`
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// src/linters/readme-presence.ts
|
|
494
|
+
var readmePresence = {
|
|
495
|
+
id: "lint:readme-presence",
|
|
496
|
+
pillars: ["readme"],
|
|
497
|
+
defaultSeverity: "error",
|
|
498
|
+
suggestedSkills: ["/readme"],
|
|
499
|
+
run(repoRoot) {
|
|
500
|
+
if (fileExists(repoRoot, "README.md")) return [];
|
|
501
|
+
return [
|
|
502
|
+
{
|
|
503
|
+
linter: this.id,
|
|
504
|
+
severity: "error",
|
|
505
|
+
file: "README.md",
|
|
506
|
+
message: "README.md is missing at repo root"
|
|
507
|
+
}
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// src/linters/readme-sections.ts
|
|
513
|
+
var REQUIRED_SECTIONS3 = [
|
|
514
|
+
/^#\s+\S/m,
|
|
515
|
+
/^##?\s+(What is this|Quick start)/im,
|
|
516
|
+
/^##?\s+(Quick start|Install|Getting started)/im
|
|
517
|
+
];
|
|
518
|
+
var REQUIRED_LABELS = ["title (# H1)", "What is this / intro", "Quick start / Install"];
|
|
519
|
+
var readmeSections = {
|
|
520
|
+
id: "lint:readme-sections",
|
|
521
|
+
pillars: ["readme"],
|
|
522
|
+
defaultSeverity: "error",
|
|
523
|
+
suggestedSkills: ["/readme"],
|
|
524
|
+
run(repoRoot) {
|
|
525
|
+
if (!fileExists(repoRoot, "README.md")) return [];
|
|
526
|
+
const text = readText(repoRoot, "README.md");
|
|
527
|
+
const issues = [];
|
|
528
|
+
for (let i = 0; i < REQUIRED_SECTIONS3.length; i++) {
|
|
529
|
+
const pattern = REQUIRED_SECTIONS3[i];
|
|
530
|
+
const label = REQUIRED_LABELS[i];
|
|
531
|
+
if (pattern && !pattern.test(text)) {
|
|
532
|
+
issues.push({
|
|
533
|
+
linter: this.id,
|
|
534
|
+
severity: "error",
|
|
535
|
+
file: "README.md",
|
|
536
|
+
message: `missing required section: ${label}`
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return issues;
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// src/linters/spec-has-content.ts
|
|
545
|
+
var specHasContent = {
|
|
546
|
+
id: "lint:spec-has-content",
|
|
547
|
+
pillars: ["spec"],
|
|
548
|
+
defaultSeverity: "warning",
|
|
549
|
+
suggestedSkills: ["/spec"],
|
|
550
|
+
run(repoRoot) {
|
|
551
|
+
if (!dirExists(repoRoot, "docs/spec")) return [];
|
|
552
|
+
const files = listMarkdown(repoRoot, "docs/spec");
|
|
553
|
+
if (files.length > 0) return [];
|
|
554
|
+
return [
|
|
555
|
+
{
|
|
556
|
+
linter: this.id,
|
|
557
|
+
severity: "warning",
|
|
558
|
+
file: "docs/spec",
|
|
559
|
+
message: "docs/spec/ has no markdown files \u2014 write at least one spec document"
|
|
560
|
+
}
|
|
561
|
+
];
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// src/linters/spec-tech-agnostic.ts
|
|
566
|
+
function maskFencedBlocks(text) {
|
|
567
|
+
return text.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, " "));
|
|
568
|
+
}
|
|
569
|
+
var TECH_BLOCKLIST = [
|
|
570
|
+
{ pattern: /\bNext\.?js\b/i, keyword: "Next.js" },
|
|
571
|
+
{ pattern: /\bSupabase\b/i, keyword: "Supabase" },
|
|
572
|
+
{ pattern: /\bVercel\b/i, keyword: "Vercel" },
|
|
573
|
+
{ pattern: /\bReact\b/, keyword: "React" },
|
|
574
|
+
{ pattern: /\bTypeScript\b/, keyword: "TypeScript" },
|
|
575
|
+
{ pattern: /\bDocker\b/, keyword: "Docker" },
|
|
576
|
+
{ pattern: /\bKubernetes\b/i, keyword: "Kubernetes" },
|
|
577
|
+
{ pattern: /\bPostgres(QL)?\b/i, keyword: "Postgres" },
|
|
578
|
+
{ pattern: /\bMySQL\b/i, keyword: "MySQL" },
|
|
579
|
+
{ pattern: /\bRedis\b/i, keyword: "Redis" },
|
|
580
|
+
{ pattern: /\bnpm\b/, keyword: "npm" },
|
|
581
|
+
{ pattern: /\bpnpm\b/, keyword: "pnpm" },
|
|
582
|
+
{ pattern: /\byarn\b/, keyword: "yarn" }
|
|
583
|
+
];
|
|
584
|
+
var specTechAgnostic = {
|
|
585
|
+
id: "lint:spec-tech-agnostic",
|
|
586
|
+
pillars: ["spec"],
|
|
587
|
+
defaultSeverity: "error",
|
|
588
|
+
suggestedSkills: ["/spec"],
|
|
589
|
+
run(repoRoot) {
|
|
590
|
+
const issues = [];
|
|
591
|
+
const files = listMarkdown(repoRoot, "docs/spec");
|
|
592
|
+
for (const f of files) {
|
|
593
|
+
const text = maskFencedBlocks(readText(repoRoot, f));
|
|
594
|
+
for (const { pattern, keyword } of TECH_BLOCKLIST) {
|
|
595
|
+
if (pattern.test(text)) {
|
|
596
|
+
const issue = {
|
|
597
|
+
linter: this.id,
|
|
598
|
+
severity: "error",
|
|
599
|
+
file: f,
|
|
600
|
+
message: `forbidden tech keyword "${keyword}" \u2014 should be in handbook/ or decisions/`
|
|
601
|
+
};
|
|
602
|
+
const line = findLineNumber(text, pattern);
|
|
603
|
+
if (line !== void 0) issue.line = line;
|
|
604
|
+
issues.push(issue);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return issues;
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// src/linters/tracks-drift.ts
|
|
613
|
+
import fg5 from "fast-glob";
|
|
614
|
+
var TRACKED_DIRS2 = ["docs/handbook", "docs/tests", "docs/workflow"];
|
|
615
|
+
var DEFAULT_DRIFT_THRESHOLD = 10;
|
|
616
|
+
function expandGlob(repoRoot, pattern) {
|
|
617
|
+
if (pattern.includes("*")) {
|
|
618
|
+
return fg5.sync(pattern, { cwd: repoRoot, absolute: false });
|
|
619
|
+
}
|
|
620
|
+
return [pattern];
|
|
621
|
+
}
|
|
622
|
+
var tracksDrift = {
|
|
623
|
+
id: "lint:tracks-drift",
|
|
624
|
+
pillars: ["handbook", "tests", "workflow"],
|
|
625
|
+
defaultSeverity: "warning",
|
|
626
|
+
suggestedSkills: ["/drift", "/handbook"],
|
|
627
|
+
run(repoRoot) {
|
|
628
|
+
const issues = [];
|
|
629
|
+
for (const dir of TRACKED_DIRS2) {
|
|
630
|
+
const files = listMarkdown(repoRoot, dir);
|
|
631
|
+
for (const f of files) {
|
|
632
|
+
const text = readText(repoRoot, f);
|
|
633
|
+
const { data } = parseFrontmatter(text);
|
|
634
|
+
if (!Array.isArray(data.tracks)) continue;
|
|
635
|
+
const docCommit = gitLastCommitForPath(repoRoot, f);
|
|
636
|
+
if (!docCommit) continue;
|
|
637
|
+
const threshold = typeof data.driftThreshold === "number" && data.driftThreshold > 0 ? data.driftThreshold : DEFAULT_DRIFT_THRESHOLD;
|
|
638
|
+
let driftCount = 0;
|
|
639
|
+
for (const tracked of data.tracks) {
|
|
640
|
+
if (typeof tracked !== "string") continue;
|
|
641
|
+
const targets = expandGlob(repoRoot, tracked);
|
|
642
|
+
for (const t of targets.length > 0 ? targets : [tracked]) {
|
|
643
|
+
driftCount += gitCommitsSince(repoRoot, t, docCommit.hash);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (driftCount > threshold) {
|
|
647
|
+
issues.push({
|
|
648
|
+
linter: this.id,
|
|
649
|
+
severity: "warning",
|
|
650
|
+
file: f,
|
|
651
|
+
message: `tracked files changed in ${driftCount} commits since doc last edit (threshold ${threshold})`
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return issues;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// src/linters/index.ts
|
|
661
|
+
var ALL_LINTERS = [
|
|
662
|
+
readmePresence,
|
|
663
|
+
readmeSections,
|
|
664
|
+
docLayout,
|
|
665
|
+
cardinalFiles,
|
|
666
|
+
decisionSchema,
|
|
667
|
+
adrNoTracks,
|
|
668
|
+
frontmatterTracks,
|
|
669
|
+
tracksDrift,
|
|
670
|
+
specTechAgnostic,
|
|
671
|
+
specHasContent,
|
|
672
|
+
crossLinks,
|
|
673
|
+
orphanDecisions,
|
|
674
|
+
gitignoreHarness,
|
|
675
|
+
qualitySchema
|
|
676
|
+
];
|
|
677
|
+
function runLinters(repoRoot, opts = {}) {
|
|
678
|
+
const config = loadLintConfig(repoRoot);
|
|
679
|
+
const ctx = { repoRoot, config };
|
|
680
|
+
const filtered = ALL_LINTERS.filter((l) => {
|
|
681
|
+
if (opts.only && !opts.only.includes(l.id)) return false;
|
|
682
|
+
if (opts.pillar && !l.pillars.includes(opts.pillar) && !l.pillars.includes("all")) return false;
|
|
683
|
+
return true;
|
|
684
|
+
});
|
|
685
|
+
const issues = [];
|
|
686
|
+
for (const linter of filtered) {
|
|
687
|
+
try {
|
|
688
|
+
issues.push(...linter.run(repoRoot, ctx));
|
|
689
|
+
} catch (err) {
|
|
690
|
+
issues.push({
|
|
691
|
+
linter: linter.id,
|
|
692
|
+
severity: "error",
|
|
693
|
+
file: "<internal>",
|
|
694
|
+
message: `linter crashed: ${err instanceof Error ? err.message : String(err)}`
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
699
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
700
|
+
return {
|
|
701
|
+
version: 1,
|
|
702
|
+
summary: { errors, warnings, pass: errors === 0 },
|
|
703
|
+
issues
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function suggestedSkillsFor(linterId) {
|
|
707
|
+
const linter = ALL_LINTERS.find((l) => l.id === linterId);
|
|
708
|
+
return linter?.suggestedSkills ?? [];
|
|
709
|
+
}
|
|
710
|
+
function formatReportText(report) {
|
|
711
|
+
const lines = [];
|
|
712
|
+
for (const issue of report.issues) {
|
|
713
|
+
const location = issue.line !== void 0 ? `:${issue.line}` : "";
|
|
714
|
+
lines.push(`${issue.file}${location}: ${issue.severity}[${issue.linter}]
|
|
715
|
+
${issue.message}`);
|
|
716
|
+
const hints = suggestedSkillsFor(issue.linter);
|
|
717
|
+
if (hints.length > 0) {
|
|
718
|
+
lines.push(` \u2192 Try: ${hints.join(", ")}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const { errors, warnings, pass } = report.summary;
|
|
722
|
+
lines.push("");
|
|
723
|
+
lines.push(
|
|
724
|
+
`${errors} ${errors === 1 ? "error" : "errors"}, ${warnings} ${warnings === 1 ? "warning" : "warnings"}. ${pass ? "\u2714 CI passes." : "\u2718 CI fails."}`
|
|
725
|
+
);
|
|
726
|
+
return lines.join("\n");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/version.ts
|
|
730
|
+
var VERSION = "1.3.2";
|
|
731
|
+
|
|
732
|
+
// src/cli/check.ts
|
|
733
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
734
|
+
import { join as join4 } from "path";
|
|
735
|
+
var REQUIRED_DIRS2 = [
|
|
736
|
+
"docs/spec",
|
|
737
|
+
"docs/decisions",
|
|
738
|
+
"docs/handbook",
|
|
739
|
+
"docs/tests",
|
|
740
|
+
"docs/workflow"
|
|
741
|
+
];
|
|
742
|
+
function checkCommand(repoRoot) {
|
|
743
|
+
const issues = [];
|
|
744
|
+
if (!existsSync4(join4(repoRoot, "README.md"))) {
|
|
745
|
+
issues.push("README.md missing at repo root");
|
|
746
|
+
}
|
|
747
|
+
for (const d of REQUIRED_DIRS2) {
|
|
748
|
+
if (!existsSync4(join4(repoRoot, d))) {
|
|
749
|
+
issues.push(`missing directory: ${d}/`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const gitignorePath = join4(repoRoot, ".gitignore");
|
|
753
|
+
if (!existsSync4(gitignorePath)) {
|
|
754
|
+
issues.push(".gitignore missing at repo root");
|
|
755
|
+
} else {
|
|
756
|
+
const text = readFileSync2(gitignorePath, "utf8");
|
|
757
|
+
const lines = new Set(
|
|
758
|
+
text.split("\n").map((l) => l.trim()).filter(Boolean)
|
|
759
|
+
);
|
|
760
|
+
for (const entry of REQUIRED_GITIGNORE_ENTRIES) {
|
|
761
|
+
const variants = [entry, entry.replace(/\/$/, "")];
|
|
762
|
+
if (!variants.some((v) => lines.has(v))) {
|
|
763
|
+
issues.push(`.gitignore missing entry: ${entry}`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return { pass: issues.length === 0, issues };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/cli/init.ts
|
|
771
|
+
import { existsSync as existsSync10, mkdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
772
|
+
import { dirname as dirname4, join as join11, resolve as resolve4 } from "path";
|
|
773
|
+
import { fileURLToPath } from "url";
|
|
774
|
+
|
|
775
|
+
// src/loader.ts
|
|
776
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
777
|
+
import { join as join5 } from "path";
|
|
778
|
+
import fg6 from "fast-glob";
|
|
779
|
+
import { z as z2 } from "zod";
|
|
780
|
+
|
|
781
|
+
// src/parser/common.ts
|
|
782
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
783
|
+
import matter2 from "gray-matter";
|
|
784
|
+
|
|
785
|
+
// src/parser/schemas.ts
|
|
786
|
+
import { z } from "zod";
|
|
787
|
+
|
|
788
|
+
// src/types.ts
|
|
789
|
+
var AGENT_IDS = ["coding", "review", "merge", "planning"];
|
|
790
|
+
var ENGINES = ["claude-code", "codex-cli", "opencode"];
|
|
791
|
+
var CAPABILITIES = ["multi-step", "slash-command", "inline-exec"];
|
|
792
|
+
var CAPABILITIES_MATRIX = {
|
|
793
|
+
"claude-code": ["multi-step", "slash-command", "inline-exec"],
|
|
794
|
+
"codex-cli": ["multi-step", "slash-command", "inline-exec"],
|
|
795
|
+
opencode: ["multi-step", "slash-command", "inline-exec"]
|
|
796
|
+
};
|
|
797
|
+
var DEGRADED_CAPABILITIES = {
|
|
798
|
+
"codex-cli": ["multi-step"]
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
// src/parser/schemas.ts
|
|
802
|
+
var IdSchema = z.string().min(1).regex(/^[a-z][a-z0-9-]*$/, "id must be kebab-case (lowercase + digits + hyphens)");
|
|
803
|
+
var SkillFrontmatterSchema = z.object({
|
|
804
|
+
id: IdSchema,
|
|
805
|
+
description: z.string().min(1),
|
|
806
|
+
engines: z.array(z.enum(ENGINES)).min(1).optional(),
|
|
807
|
+
requires: z.array(z.enum(CAPABILITIES)).optional(),
|
|
808
|
+
tags: z.array(z.string()).optional()
|
|
809
|
+
}).strict();
|
|
810
|
+
var CmdArgEnumSchema = z.object({
|
|
811
|
+
name: z.string().min(1),
|
|
812
|
+
type: z.literal("enum"),
|
|
813
|
+
values: z.array(z.string()).min(1),
|
|
814
|
+
default: z.string().optional()
|
|
815
|
+
}).strict();
|
|
816
|
+
var CmdArgStringSchema = z.object({
|
|
817
|
+
name: z.string().min(1),
|
|
818
|
+
type: z.literal("string"),
|
|
819
|
+
default: z.string().optional()
|
|
820
|
+
}).strict();
|
|
821
|
+
var CmdArgSchema = z.discriminatedUnion("type", [CmdArgEnumSchema, CmdArgStringSchema]);
|
|
822
|
+
var CmdFrontmatterSchema = z.object({
|
|
823
|
+
id: IdSchema,
|
|
824
|
+
trigger: z.string().regex(/^\/[a-z][a-z0-9-]*$/, "trigger must be /kebab-case"),
|
|
825
|
+
invokes_skill: IdSchema,
|
|
826
|
+
args: z.array(CmdArgSchema).optional(),
|
|
827
|
+
description: z.string().min(1)
|
|
828
|
+
}).strict();
|
|
829
|
+
var AgentFrontmatterSchema = z.object({
|
|
830
|
+
id: z.enum(AGENT_IDS),
|
|
831
|
+
description: z.string().min(1)
|
|
832
|
+
}).strict();
|
|
833
|
+
var Schemas = {
|
|
834
|
+
Skill: SkillFrontmatterSchema,
|
|
835
|
+
Cmd: CmdFrontmatterSchema,
|
|
836
|
+
Agent: AgentFrontmatterSchema
|
|
837
|
+
};
|
|
838
|
+
var ParseError = class extends Error {
|
|
839
|
+
constructor(file, issues) {
|
|
840
|
+
const messages = issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
|
|
841
|
+
super(`Frontmatter validation failed for ${file}:
|
|
842
|
+
${messages}`);
|
|
843
|
+
this.file = file;
|
|
844
|
+
this.issues = issues;
|
|
845
|
+
this.name = "ParseError";
|
|
846
|
+
}
|
|
847
|
+
file;
|
|
848
|
+
issues;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
// src/parser/common.ts
|
|
852
|
+
function parseWithSchema(filePath, source, schema) {
|
|
853
|
+
const parsed = matter2(source);
|
|
854
|
+
const result = schema.safeParse(parsed.data);
|
|
855
|
+
if (!result.success) {
|
|
856
|
+
throw new ParseError(filePath, result.error.issues);
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
meta: result.data,
|
|
860
|
+
body: parsed.content.trim(),
|
|
861
|
+
sourcePath: filePath
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// src/parser/agent.ts
|
|
866
|
+
function parseAgent(filePath, source) {
|
|
867
|
+
return parseWithSchema(filePath, source, Schemas.Agent);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/parser/cmd.ts
|
|
871
|
+
function parseCmd(filePath, source) {
|
|
872
|
+
return parseWithSchema(filePath, source, Schemas.Cmd);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/parser/skill.ts
|
|
876
|
+
function parseSkill(filePath, source) {
|
|
877
|
+
return parseWithSchema(filePath, source, Schemas.Skill);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// src/util/yaml.ts
|
|
881
|
+
import { parse as yamlParse } from "yaml";
|
|
882
|
+
function parseYAML(source) {
|
|
883
|
+
return yamlParse(source);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/loader.ts
|
|
887
|
+
var DisableYamlSchema = z2.object({ disable: z2.array(z2.string()).optional() }).passthrough();
|
|
888
|
+
function loadHarness(packageDir, opts = {}) {
|
|
889
|
+
const agentsDir = opts.agentsDir ?? join5(packageDir, "agents");
|
|
890
|
+
const skillsDir = opts.skillsDir ?? join5(packageDir, "skills");
|
|
891
|
+
const commandsDir = opts.commandsDir ?? join5(packageDir, "commands");
|
|
892
|
+
const agentFiles = fg6.sync("*.agent.md", { cwd: agentsDir, absolute: true });
|
|
893
|
+
const skillFiles = fg6.sync("*.skill.md", { cwd: skillsDir, absolute: true });
|
|
894
|
+
const cmdFiles = fg6.sync("*.cmd.md", { cwd: commandsDir, absolute: true });
|
|
895
|
+
return {
|
|
896
|
+
agents: agentFiles.map((f) => parseAgent(f, readFileSync4(f, "utf8"))),
|
|
897
|
+
skills: skillFiles.map((f) => parseSkill(f, readFileSync4(f, "utf8"))),
|
|
898
|
+
cmds: cmdFiles.map((f) => parseCmd(f, readFileSync4(f, "utf8")))
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function loadProjectSkills(targetDir) {
|
|
902
|
+
const skillsDir = join5(targetDir, "docs", "skills");
|
|
903
|
+
if (!existsSync5(skillsDir)) return [];
|
|
904
|
+
const skillFiles = fg6.sync("*.skill.md", { cwd: skillsDir, absolute: true });
|
|
905
|
+
return skillFiles.map((f) => parseSkill(f, readFileSync4(f, "utf8")));
|
|
906
|
+
}
|
|
907
|
+
function loadOverrides(worktreeDir) {
|
|
908
|
+
const overridesDir = join5(worktreeDir, ".harness-overrides");
|
|
909
|
+
if (!existsSync5(overridesDir)) return void 0;
|
|
910
|
+
const added = loadHarness(overridesDir, {
|
|
911
|
+
agentsDir: join5(overridesDir, "agents"),
|
|
912
|
+
skillsDir: join5(overridesDir, "skills"),
|
|
913
|
+
commandsDir: join5(overridesDir, "commands")
|
|
914
|
+
});
|
|
915
|
+
const disablePath = join5(overridesDir, "disable.yaml");
|
|
916
|
+
let disable = [];
|
|
917
|
+
if (existsSync5(disablePath)) {
|
|
918
|
+
try {
|
|
919
|
+
const parsed = DisableYamlSchema.safeParse(parseYAML(readFileSync4(disablePath, "utf8")));
|
|
920
|
+
if (parsed.success && parsed.data.disable) {
|
|
921
|
+
disable = parsed.data.disable;
|
|
922
|
+
}
|
|
923
|
+
} catch {
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return { add: added, disable };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// src/mcp.ts
|
|
930
|
+
var MCP_SERVER_IDS = ["context7"];
|
|
931
|
+
var MCP_SERVERS = {
|
|
932
|
+
context7: {
|
|
933
|
+
id: "context7",
|
|
934
|
+
description: "Upstash Context7 \u2014 fetches up-to-date library docs via resolve-library-id + get-library-docs.",
|
|
935
|
+
command: "npx",
|
|
936
|
+
args: ["-y", "@upstash/context7-mcp"]
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
function resolveMcpIds(requested) {
|
|
940
|
+
return requested.map((id) => {
|
|
941
|
+
if (!MCP_SERVER_IDS.includes(id)) {
|
|
942
|
+
throw new Error(`unknown MCP server: ${id} (expected one of ${MCP_SERVER_IDS.join(", ")})`);
|
|
943
|
+
}
|
|
944
|
+
return id;
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// src/renderers/common.ts
|
|
949
|
+
import { stringify as yamlStringify } from "yaml";
|
|
950
|
+
var STOP_HOOK_PACKAGE = "@vaubaan/harness";
|
|
951
|
+
function checkSkillCapabilities(skill, engine) {
|
|
952
|
+
const blockingWarnings = [];
|
|
953
|
+
const degradedWarnings = [];
|
|
954
|
+
const supported = CAPABILITIES_MATRIX[engine];
|
|
955
|
+
const degraded = DEGRADED_CAPABILITIES[engine] ?? [];
|
|
956
|
+
if (!skill.meta.requires) return { blockingWarnings, degradedWarnings };
|
|
957
|
+
for (const cap of skill.meta.requires) {
|
|
958
|
+
if (!supported.includes(cap)) {
|
|
959
|
+
blockingWarnings.push(
|
|
960
|
+
`skill "${skill.meta.id}" requires "${cap}" not supported by "${engine}" \u2014 skipped`
|
|
961
|
+
);
|
|
962
|
+
} else if (degraded.includes(cap)) {
|
|
963
|
+
degradedWarnings.push(
|
|
964
|
+
`skill "${skill.meta.id}" requires "${cap}" \u2014 supported by "${engine}" with degradation`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return { blockingWarnings, degradedWarnings };
|
|
969
|
+
}
|
|
970
|
+
function isSkillEngineAllowed(skill, engine) {
|
|
971
|
+
if (!skill.meta.engines) return true;
|
|
972
|
+
return skill.meta.engines.includes(engine);
|
|
973
|
+
}
|
|
974
|
+
function yamlFrontmatter(data) {
|
|
975
|
+
const rendered = yamlStringify(data, { lineWidth: 0 }).trimEnd();
|
|
976
|
+
return `---
|
|
977
|
+
${rendered}
|
|
978
|
+
---
|
|
979
|
+
`;
|
|
980
|
+
}
|
|
981
|
+
var SCALAR_NEEDS_QUOTE = /[^a-zA-Z0-9_./-]/;
|
|
982
|
+
function tomlEscapeString(s) {
|
|
983
|
+
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
|
984
|
+
}
|
|
985
|
+
function tomlValue(v) {
|
|
986
|
+
if (Array.isArray(v)) {
|
|
987
|
+
return `[${v.map((x) => tomlValue(x)).join(", ")}]`;
|
|
988
|
+
}
|
|
989
|
+
if (typeof v === "string") {
|
|
990
|
+
if (v.includes("\n")) return tomlEscapeString(v);
|
|
991
|
+
return SCALAR_NEEDS_QUOTE.test(v) ? tomlEscapeString(v) : `"${v}"`;
|
|
992
|
+
}
|
|
993
|
+
if (typeof v === "boolean" || typeof v === "number") {
|
|
994
|
+
return String(v);
|
|
995
|
+
}
|
|
996
|
+
return tomlEscapeString(String(v));
|
|
997
|
+
}
|
|
998
|
+
function tomlMultilineString(s) {
|
|
999
|
+
const escaped = s.replace(/\\/g, "\\\\").replace(/"""/g, '""\\"');
|
|
1000
|
+
return `"""
|
|
1001
|
+
${escaped}
|
|
1002
|
+
"""`;
|
|
1003
|
+
}
|
|
1004
|
+
function tomlQuoteString(s) {
|
|
1005
|
+
return tomlValue(s);
|
|
1006
|
+
}
|
|
1007
|
+
function tomlInlineArray(items) {
|
|
1008
|
+
return tomlValue([...items]);
|
|
1009
|
+
}
|
|
1010
|
+
function tomlInlineTable(table) {
|
|
1011
|
+
const entries = Object.entries(table).map(([k, v]) => `${k} = ${tomlValue(v)}`);
|
|
1012
|
+
return `{ ${entries.join(", ")} }`;
|
|
1013
|
+
}
|
|
1014
|
+
function frontmatterDataForSkill(skill) {
|
|
1015
|
+
const data = {
|
|
1016
|
+
name: skill.meta.id,
|
|
1017
|
+
description: skill.meta.description
|
|
1018
|
+
};
|
|
1019
|
+
if (skill.meta.tags && skill.meta.tags.length > 0) data.tags = skill.meta.tags;
|
|
1020
|
+
return data;
|
|
1021
|
+
}
|
|
1022
|
+
function frontmatterDataForCmd(cmd) {
|
|
1023
|
+
const data = {
|
|
1024
|
+
id: cmd.meta.id,
|
|
1025
|
+
trigger: cmd.meta.trigger,
|
|
1026
|
+
description: cmd.meta.description
|
|
1027
|
+
};
|
|
1028
|
+
if (cmd.meta.args && cmd.meta.args.length > 0) {
|
|
1029
|
+
data.args = cmd.meta.args.map((a) => {
|
|
1030
|
+
if (a.type === "enum") {
|
|
1031
|
+
return a.default !== void 0 ? { name: a.name, type: a.type, values: a.values, default: a.default } : { name: a.name, type: a.type, values: a.values };
|
|
1032
|
+
}
|
|
1033
|
+
return a.default !== void 0 ? { name: a.name, type: a.type, default: a.default } : { name: a.name, type: a.type };
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
return data;
|
|
1037
|
+
}
|
|
1038
|
+
function buildAgentBody(agent) {
|
|
1039
|
+
return `${agent.body.trim()}
|
|
1040
|
+
`;
|
|
1041
|
+
}
|
|
1042
|
+
function buildUmbrellaBody(_agents, extras) {
|
|
1043
|
+
const triggerBySkill = new Map(
|
|
1044
|
+
extras.availableCommands.map((c) => [c.meta.invokes_skill, c.meta.trigger])
|
|
1045
|
+
);
|
|
1046
|
+
const skillLines = extras.availableSkills.map((s) => {
|
|
1047
|
+
const trigger = triggerBySkill.get(s.meta.id);
|
|
1048
|
+
const label = trigger ? `\`${s.meta.id}\` (\`${trigger}\`)` : `\`${s.meta.id}\``;
|
|
1049
|
+
return `- ${label} \u2014 ${s.meta.description.split("\n")[0]}`;
|
|
1050
|
+
});
|
|
1051
|
+
const sections = [
|
|
1052
|
+
"# How we work",
|
|
1053
|
+
"",
|
|
1054
|
+
"This file describes **how to work in this project**. The **business context** (what the project does, how to run it, which subsystems it contains) lives in `README.md` and `docs/`. Read them first.",
|
|
1055
|
+
"",
|
|
1056
|
+
"## Project map",
|
|
1057
|
+
"",
|
|
1058
|
+
"- `README.md` \u2014 pitch, quick start, scripts. Entry point.",
|
|
1059
|
+
"- `docs/spec/<subsystem>.md` \u2014 *what* and *why* (tech-agnostic).",
|
|
1060
|
+
"- `docs/handbook/<feature>.md` \u2014 *how it actually works* today.",
|
|
1061
|
+
"- `docs/decisions/<slug>.md` \u2014 ADRs: structural choices and rejected alternatives.",
|
|
1062
|
+
"- `docs/tests/integration-tests.md` & `e2e-tests.md` \u2014 core scenarios.",
|
|
1063
|
+
"- `docs/workflow/coding-workflow.md` \u2014 CI, branches, PR, release.",
|
|
1064
|
+
...extras.hasProjectSkills ? ["- `docs/skills/*.skill.md` \u2014 the project's domain skills."] : [],
|
|
1065
|
+
...extras.hasQualityDoc ? [
|
|
1066
|
+
"- `docs/quality/code-quality.md` \u2014 the project's code-quality rules: mechanical checks, judgment rules, accepted deviations. Read it before writing or auditing code."
|
|
1067
|
+
] : [],
|
|
1068
|
+
"",
|
|
1069
|
+
"## Doc-first",
|
|
1070
|
+
"",
|
|
1071
|
+
"The order is: **understand the docs \u2192 fill the gaps \u2192 code \u2192 update the docs \u2192 commit**. If the docs are silent or stale on something you change, fill the gap upfront with the doc-skill writers \u2014 not downstream, not after the fact.",
|
|
1072
|
+
"",
|
|
1073
|
+
"- Missing spec for a subsystem you touch \u2192 **do not code**, report the gap.",
|
|
1074
|
+
"- Implicit structural decision in your changes \u2192 an ADR (`/decision`).",
|
|
1075
|
+
"- Changed feature behavior \u2192 update the matching handbook (`/handbook`).",
|
|
1076
|
+
"- Suspected code \u2194 doc drift \u2192 `/drift` before moving on."
|
|
1077
|
+
];
|
|
1078
|
+
if (skillLines.length > 0) {
|
|
1079
|
+
sections.push("", "## Doc-skills", "", skillLines.join("\n"));
|
|
1080
|
+
}
|
|
1081
|
+
return `${sections.join("\n")}
|
|
1082
|
+
`;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// src/renderers/claude-code.ts
|
|
1086
|
+
var ENGINE = "claude-code";
|
|
1087
|
+
var BASE = ".claude";
|
|
1088
|
+
var claudeCodeRenderer = {
|
|
1089
|
+
engine: ENGINE,
|
|
1090
|
+
renderSkill(skill) {
|
|
1091
|
+
if (!isSkillEngineAllowed(skill, ENGINE)) {
|
|
1092
|
+
return {
|
|
1093
|
+
files: [],
|
|
1094
|
+
warnings: [`skill "${skill.meta.id}" excluded from engine "${ENGINE}"`]
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
const check = checkSkillCapabilities(skill, ENGINE);
|
|
1098
|
+
if (check.blockingWarnings.length > 0) {
|
|
1099
|
+
return { files: [], warnings: check.blockingWarnings };
|
|
1100
|
+
}
|
|
1101
|
+
const content = `${yamlFrontmatter(frontmatterDataForSkill(skill))}
|
|
1102
|
+
${skill.body}
|
|
1103
|
+
`;
|
|
1104
|
+
return {
|
|
1105
|
+
files: [{ path: `${BASE}/skills/${skill.meta.id}/SKILL.md`, content }],
|
|
1106
|
+
warnings: check.degradedWarnings
|
|
1107
|
+
};
|
|
1108
|
+
},
|
|
1109
|
+
renderCmd(cmd) {
|
|
1110
|
+
const content = `${yamlFrontmatter(frontmatterDataForCmd(cmd))}
|
|
1111
|
+
${cmd.body}
|
|
1112
|
+
`;
|
|
1113
|
+
return {
|
|
1114
|
+
files: [{ path: `${BASE}/commands/${cmd.meta.id}.md`, content }],
|
|
1115
|
+
warnings: []
|
|
1116
|
+
};
|
|
1117
|
+
},
|
|
1118
|
+
renderAgents(agents) {
|
|
1119
|
+
const files = [];
|
|
1120
|
+
for (const agent of agents) {
|
|
1121
|
+
const subagentFrontmatter = yamlFrontmatter({
|
|
1122
|
+
name: agent.meta.id,
|
|
1123
|
+
description: agent.meta.description
|
|
1124
|
+
});
|
|
1125
|
+
const body = buildAgentBody(agent);
|
|
1126
|
+
const content = `${subagentFrontmatter}
|
|
1127
|
+
${body}`;
|
|
1128
|
+
files.push({ path: `${BASE}/agents/${agent.meta.id}.md`, content });
|
|
1129
|
+
}
|
|
1130
|
+
return { files, warnings: [] };
|
|
1131
|
+
},
|
|
1132
|
+
renderMcp(servers) {
|
|
1133
|
+
if (servers.length === 0) return { files: [], warnings: [] };
|
|
1134
|
+
const mcpServers = {};
|
|
1135
|
+
for (const s of servers) {
|
|
1136
|
+
const entry = { command: s.command, args: [...s.args] };
|
|
1137
|
+
if (s.env) entry.env = s.env;
|
|
1138
|
+
mcpServers[s.id] = entry;
|
|
1139
|
+
}
|
|
1140
|
+
const content = `${JSON.stringify({ mcpServers }, null, 2)}
|
|
1141
|
+
`;
|
|
1142
|
+
return { files: [{ path: ".mcp.json", content }], warnings: [] };
|
|
1143
|
+
},
|
|
1144
|
+
renderStopHook() {
|
|
1145
|
+
const settings = {
|
|
1146
|
+
hooks: {
|
|
1147
|
+
Stop: [
|
|
1148
|
+
{
|
|
1149
|
+
matcher: "",
|
|
1150
|
+
hooks: [
|
|
1151
|
+
{
|
|
1152
|
+
type: "command",
|
|
1153
|
+
command: `npx -y ${STOP_HOOK_PACKAGE} stop-hook --engine claude`
|
|
1154
|
+
}
|
|
1155
|
+
]
|
|
1156
|
+
}
|
|
1157
|
+
]
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
return {
|
|
1161
|
+
files: [{ path: `${BASE}/settings.json`, content: `${JSON.stringify(settings, null, 2)}
|
|
1162
|
+
` }],
|
|
1163
|
+
warnings: []
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
// src/renderers/codex-cli.ts
|
|
1169
|
+
var ENGINE2 = "codex-cli";
|
|
1170
|
+
var BASE2 = ".codex";
|
|
1171
|
+
var SKILLS_BASE = ".agents/skills";
|
|
1172
|
+
var codexCliRenderer = {
|
|
1173
|
+
engine: ENGINE2,
|
|
1174
|
+
renderSkill(skill) {
|
|
1175
|
+
if (!isSkillEngineAllowed(skill, ENGINE2)) {
|
|
1176
|
+
return {
|
|
1177
|
+
files: [],
|
|
1178
|
+
warnings: [`skill "${skill.meta.id}" excluded from engine "${ENGINE2}"`]
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
const check = checkSkillCapabilities(skill, ENGINE2);
|
|
1182
|
+
if (check.blockingWarnings.length > 0) {
|
|
1183
|
+
return { files: [], warnings: check.blockingWarnings };
|
|
1184
|
+
}
|
|
1185
|
+
const content = `${yamlFrontmatter(frontmatterDataForSkill(skill))}
|
|
1186
|
+
${skill.body}
|
|
1187
|
+
`;
|
|
1188
|
+
return {
|
|
1189
|
+
files: [{ path: `${SKILLS_BASE}/${skill.meta.id}/SKILL.md`, content }],
|
|
1190
|
+
warnings: check.degradedWarnings
|
|
1191
|
+
};
|
|
1192
|
+
},
|
|
1193
|
+
// Codex custom prompts are deprecated in favour of skills: nothing to render.
|
|
1194
|
+
renderCmd(_cmd) {
|
|
1195
|
+
return { files: [], warnings: [] };
|
|
1196
|
+
},
|
|
1197
|
+
renderAgents(agents) {
|
|
1198
|
+
const files = [];
|
|
1199
|
+
for (const agent of agents) {
|
|
1200
|
+
const content = [
|
|
1201
|
+
`name = ${tomlQuoteString(agent.meta.id)}`,
|
|
1202
|
+
`description = ${tomlQuoteString(agent.meta.description)}`,
|
|
1203
|
+
`developer_instructions = ${tomlMultilineString(buildAgentBody(agent).trimEnd())}`,
|
|
1204
|
+
""
|
|
1205
|
+
].join("\n");
|
|
1206
|
+
files.push({ path: `${BASE2}/agents/${agent.meta.id}.toml`, content });
|
|
1207
|
+
}
|
|
1208
|
+
return { files, warnings: [] };
|
|
1209
|
+
},
|
|
1210
|
+
renderMcp(servers) {
|
|
1211
|
+
if (servers.length === 0) return { files: [], warnings: [] };
|
|
1212
|
+
const blocks = [];
|
|
1213
|
+
for (const s of servers) {
|
|
1214
|
+
const lines = [
|
|
1215
|
+
`[mcp_servers.${s.id}]`,
|
|
1216
|
+
`command = ${tomlQuoteString(s.command)}`,
|
|
1217
|
+
`args = ${tomlInlineArray([...s.args])}`
|
|
1218
|
+
];
|
|
1219
|
+
if (s.env) lines.push(`env = ${tomlInlineTable(s.env)}`);
|
|
1220
|
+
blocks.push(lines.join("\n"));
|
|
1221
|
+
}
|
|
1222
|
+
const content = `${blocks.join("\n\n")}
|
|
1223
|
+
`;
|
|
1224
|
+
return { files: [{ path: `${BASE2}/config.toml`, content }], warnings: [] };
|
|
1225
|
+
},
|
|
1226
|
+
renderStopHook() {
|
|
1227
|
+
const hooks = {
|
|
1228
|
+
hooks: {
|
|
1229
|
+
Stop: [
|
|
1230
|
+
{
|
|
1231
|
+
hooks: [
|
|
1232
|
+
{
|
|
1233
|
+
type: "command",
|
|
1234
|
+
command: `npx -y ${STOP_HOOK_PACKAGE} stop-hook --engine codex`
|
|
1235
|
+
}
|
|
1236
|
+
]
|
|
1237
|
+
}
|
|
1238
|
+
]
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
return {
|
|
1242
|
+
files: [{ path: `${BASE2}/hooks.json`, content: `${JSON.stringify(hooks, null, 2)}
|
|
1243
|
+
` }],
|
|
1244
|
+
warnings: []
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
// src/renderers/opencode.ts
|
|
1250
|
+
var ENGINE3 = "opencode";
|
|
1251
|
+
var BASE3 = ".opencode";
|
|
1252
|
+
var opencodeRenderer = {
|
|
1253
|
+
engine: ENGINE3,
|
|
1254
|
+
renderSkill(skill) {
|
|
1255
|
+
if (!isSkillEngineAllowed(skill, ENGINE3)) {
|
|
1256
|
+
return {
|
|
1257
|
+
files: [],
|
|
1258
|
+
warnings: [`skill "${skill.meta.id}" excluded from engine "${ENGINE3}"`]
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
const check = checkSkillCapabilities(skill, ENGINE3);
|
|
1262
|
+
if (check.blockingWarnings.length > 0) {
|
|
1263
|
+
return { files: [], warnings: check.blockingWarnings };
|
|
1264
|
+
}
|
|
1265
|
+
const content = `${yamlFrontmatter(frontmatterDataForSkill(skill))}
|
|
1266
|
+
${skill.body}
|
|
1267
|
+
`;
|
|
1268
|
+
return {
|
|
1269
|
+
files: [{ path: `${BASE3}/skills/${skill.meta.id}/SKILL.md`, content }],
|
|
1270
|
+
warnings: check.degradedWarnings
|
|
1271
|
+
};
|
|
1272
|
+
},
|
|
1273
|
+
renderCmd(cmd) {
|
|
1274
|
+
const content = `${yamlFrontmatter({ description: cmd.meta.description })}
|
|
1275
|
+
${cmd.body}
|
|
1276
|
+
`;
|
|
1277
|
+
return {
|
|
1278
|
+
files: [{ path: `${BASE3}/commands/${cmd.meta.id}.md`, content }],
|
|
1279
|
+
warnings: []
|
|
1280
|
+
};
|
|
1281
|
+
},
|
|
1282
|
+
renderAgents(agents) {
|
|
1283
|
+
const files = [];
|
|
1284
|
+
for (const agent of agents) {
|
|
1285
|
+
const fm = yamlFrontmatter({
|
|
1286
|
+
description: agent.meta.description,
|
|
1287
|
+
mode: "all"
|
|
1288
|
+
});
|
|
1289
|
+
const body = buildAgentBody(agent);
|
|
1290
|
+
files.push({ path: `${BASE3}/agents/${agent.meta.id}.md`, content: `${fm}
|
|
1291
|
+
${body}` });
|
|
1292
|
+
}
|
|
1293
|
+
return { files, warnings: [] };
|
|
1294
|
+
},
|
|
1295
|
+
renderMcp(servers) {
|
|
1296
|
+
if (servers.length === 0) return { files: [], warnings: [] };
|
|
1297
|
+
const mcp = {};
|
|
1298
|
+
for (const s of servers) {
|
|
1299
|
+
const entry = {
|
|
1300
|
+
type: "local",
|
|
1301
|
+
command: [s.command, ...s.args],
|
|
1302
|
+
enabled: true
|
|
1303
|
+
};
|
|
1304
|
+
if (s.env) entry.environment = s.env;
|
|
1305
|
+
mcp[s.id] = entry;
|
|
1306
|
+
}
|
|
1307
|
+
const config = { $schema: "https://opencode.ai/config.json", mcp };
|
|
1308
|
+
const content = `${JSON.stringify(config, null, 2)}
|
|
1309
|
+
`;
|
|
1310
|
+
return { files: [{ path: `${BASE3}/opencode.json`, content }], warnings: [] };
|
|
1311
|
+
},
|
|
1312
|
+
renderStopHook() {
|
|
1313
|
+
const content = `import { spawnSync } from "node:child_process";
|
|
1314
|
+
|
|
1315
|
+
// Runs the harness lint and returns the report to inject, or "" when the
|
|
1316
|
+
// turn may end.
|
|
1317
|
+
const lintReport = (directory, sessionID) => {
|
|
1318
|
+
const r = spawnSync(
|
|
1319
|
+
"npx",
|
|
1320
|
+
["-y", "${STOP_HOOK_PACKAGE}", "stop-hook", "--engine", "opencode", "--cwd", directory],
|
|
1321
|
+
{ input: JSON.stringify({ sessionID }), encoding: "utf-8" },
|
|
1322
|
+
);
|
|
1323
|
+
const report = (r.stderr ?? "").trim();
|
|
1324
|
+
return r.status === 2 ? report : "";
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
// OpenCode 1.x entry point, exposed as server() below.
|
|
1328
|
+
const HarnessStopHook = async ({ client, directory }) => ({
|
|
1329
|
+
event: async ({ event }) => {
|
|
1330
|
+
if (event.type !== "session.idle") return;
|
|
1331
|
+
const sessionID = event.properties?.sessionID;
|
|
1332
|
+
if (!sessionID) return;
|
|
1333
|
+
const report = lintReport(directory, sessionID);
|
|
1334
|
+
if (report === "") return;
|
|
1335
|
+
// OpenCode defaults to its build agent when a prompt omits agent.
|
|
1336
|
+
// Preserve the last conversation persona, excluding compaction summaries.
|
|
1337
|
+
const history = await client.session.messages({ path: { id: sessionID } }).catch(() => undefined);
|
|
1338
|
+
if (history?.error || !Array.isArray(history?.data)) return;
|
|
1339
|
+
const current = history.data.findLast(({ info }) =>
|
|
1340
|
+
(info.role === "user" || (info.role === "assistant" && !info.summary)) &&
|
|
1341
|
+
typeof info.agent === "string" && info.agent.trim() !== "",
|
|
1342
|
+
);
|
|
1343
|
+
if (!current) return;
|
|
1344
|
+
await client.session.prompt({
|
|
1345
|
+
path: { id: sessionID },
|
|
1346
|
+
body: { agent: current.info.agent, parts: [{ type: "text", text: report }] },
|
|
1347
|
+
});
|
|
1348
|
+
},
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
// One default export for both majors: OpenCode 1.x calls server(),
|
|
1352
|
+
// OpenCode 2.x calls setup(). A V2 session keeps its agent, so the report
|
|
1353
|
+
// is sent without naming the persona.
|
|
1354
|
+
export default {
|
|
1355
|
+
id: "vaubaan-harness-stop-hook",
|
|
1356
|
+
server: HarnessStopHook,
|
|
1357
|
+
setup(context) {
|
|
1358
|
+
const directory = context.location.directory;
|
|
1359
|
+
const controller = new AbortController();
|
|
1360
|
+
const listen = async () => {
|
|
1361
|
+
for await (const event of context.event.subscribe({ signal: controller.signal })) {
|
|
1362
|
+
if (event.type !== "session.idle") continue;
|
|
1363
|
+
if (event.location?.directory && event.location.directory !== directory) continue;
|
|
1364
|
+
const sessionID = event.data?.sessionID;
|
|
1365
|
+
if (!sessionID) continue;
|
|
1366
|
+
const report = lintReport(directory, sessionID);
|
|
1367
|
+
if (report === "") continue;
|
|
1368
|
+
await context.session.prompt({ sessionID, text: report }).catch(() => undefined);
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
listen().catch(() => undefined);
|
|
1372
|
+
return () => controller.abort();
|
|
1373
|
+
},
|
|
1374
|
+
};
|
|
1375
|
+
`;
|
|
1376
|
+
return {
|
|
1377
|
+
files: [{ path: `${BASE3}/plugins/harness-stop-hook.ts`, content }],
|
|
1378
|
+
warnings: []
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
// src/renderers/index.ts
|
|
1384
|
+
var RENDERERS = {
|
|
1385
|
+
"claude-code": claudeCodeRenderer,
|
|
1386
|
+
"codex-cli": codexCliRenderer,
|
|
1387
|
+
opencode: opencodeRenderer
|
|
1388
|
+
};
|
|
1389
|
+
function getRenderer(engine) {
|
|
1390
|
+
return RENDERERS[engine];
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/render.ts
|
|
1394
|
+
var RenderError = class extends Error {
|
|
1395
|
+
constructor(message) {
|
|
1396
|
+
super(message);
|
|
1397
|
+
this.name = "RenderError";
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
function mergeById(stock, added) {
|
|
1401
|
+
const result = /* @__PURE__ */ new Map();
|
|
1402
|
+
for (const item of stock) result.set(item.meta.id, item);
|
|
1403
|
+
if (added) {
|
|
1404
|
+
for (const item of added) result.set(item.meta.id, item);
|
|
1405
|
+
}
|
|
1406
|
+
return Array.from(result.values());
|
|
1407
|
+
}
|
|
1408
|
+
function removeByIds(items, disabled) {
|
|
1409
|
+
if (disabled.length === 0) return [...items];
|
|
1410
|
+
const disabledSet = new Set(disabled);
|
|
1411
|
+
return items.filter((item) => !disabledSet.has(item.meta.id));
|
|
1412
|
+
}
|
|
1413
|
+
function applyOverrides(harness, overrides) {
|
|
1414
|
+
const disabled = overrides?.disable ?? [];
|
|
1415
|
+
return {
|
|
1416
|
+
agents: removeByIds(mergeById(harness.agents, overrides?.add?.agents), disabled),
|
|
1417
|
+
skills: removeByIds(mergeById(harness.skills, overrides?.add?.skills), disabled),
|
|
1418
|
+
cmds: removeByIds(mergeById(harness.cmds, overrides?.add?.cmds), disabled)
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
var CLAUDE_MD_STUB = "@AGENTS.md\n";
|
|
1422
|
+
function opencodeLast(engines) {
|
|
1423
|
+
return [...engines].sort((a, b) => Number(a === "opencode") - Number(b === "opencode"));
|
|
1424
|
+
}
|
|
1425
|
+
function render(input) {
|
|
1426
|
+
const { engines } = input;
|
|
1427
|
+
if (engines.length === 0) {
|
|
1428
|
+
throw new RenderError("at least one engine must be selected");
|
|
1429
|
+
}
|
|
1430
|
+
const base = input.projectSkills?.length ? { ...input.harness, skills: mergeById(input.harness.skills, input.projectSkills) } : input.harness;
|
|
1431
|
+
const harness = applyOverrides(base, input.overrides);
|
|
1432
|
+
if (harness.agents.length === 0) {
|
|
1433
|
+
throw new RenderError("No agents found in harness");
|
|
1434
|
+
}
|
|
1435
|
+
const files = [];
|
|
1436
|
+
const warnings = [];
|
|
1437
|
+
const exposedSkills = /* @__PURE__ */ new Map();
|
|
1438
|
+
const exposedCmds = /* @__PURE__ */ new Map();
|
|
1439
|
+
for (const engine of opencodeLast(engines)) {
|
|
1440
|
+
const renderer = getRenderer(engine);
|
|
1441
|
+
const renderedSkills = [];
|
|
1442
|
+
for (const skill of harness.skills) {
|
|
1443
|
+
const result = renderer.renderSkill(skill);
|
|
1444
|
+
warnings.push(...result.warnings);
|
|
1445
|
+
if (result.files.length > 0) {
|
|
1446
|
+
const alreadyVisible = engine === "opencode" && exposedSkills.has(skill.meta.id);
|
|
1447
|
+
if (!alreadyVisible) files.push(...result.files);
|
|
1448
|
+
renderedSkills.push(skill);
|
|
1449
|
+
exposedSkills.set(skill.meta.id, skill);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
const renderedSkillIds = new Set(renderedSkills.map((s) => s.meta.id));
|
|
1453
|
+
const renderedCmds = [];
|
|
1454
|
+
for (const cmd of harness.cmds) {
|
|
1455
|
+
if (!renderedSkillIds.has(cmd.meta.invokes_skill)) continue;
|
|
1456
|
+
const result = renderer.renderCmd(cmd);
|
|
1457
|
+
warnings.push(...result.warnings);
|
|
1458
|
+
if (result.files.length > 0) {
|
|
1459
|
+
files.push(...result.files);
|
|
1460
|
+
renderedCmds.push(cmd);
|
|
1461
|
+
exposedCmds.set(cmd.meta.id, cmd);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
const agentsResult = renderer.renderAgents(harness.agents);
|
|
1465
|
+
warnings.push(...agentsResult.warnings);
|
|
1466
|
+
files.push(...agentsResult.files);
|
|
1467
|
+
if (input.mcpServers && input.mcpServers.length > 0) {
|
|
1468
|
+
const specs = input.mcpServers.map((id) => MCP_SERVERS[id]);
|
|
1469
|
+
const mcpResult = renderer.renderMcp(specs);
|
|
1470
|
+
warnings.push(...mcpResult.warnings);
|
|
1471
|
+
files.push(...mcpResult.files);
|
|
1472
|
+
}
|
|
1473
|
+
if (input.withStopHook !== false) {
|
|
1474
|
+
const hookResult = renderer.renderStopHook();
|
|
1475
|
+
warnings.push(...hookResult.warnings);
|
|
1476
|
+
files.push(...hookResult.files);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
const hasProjectSkills = (input.projectSkills?.length ?? 0) > 0;
|
|
1480
|
+
const umbrellaContent = buildUmbrellaBody(harness.agents, {
|
|
1481
|
+
availableSkills: Array.from(exposedSkills.values()),
|
|
1482
|
+
availableCommands: Array.from(exposedCmds.values()),
|
|
1483
|
+
hasProjectSkills,
|
|
1484
|
+
hasQualityDoc: input.hasQualityDoc === true
|
|
1485
|
+
});
|
|
1486
|
+
files.push({ path: "AGENTS.md", content: umbrellaContent });
|
|
1487
|
+
if (engines.includes("claude-code")) {
|
|
1488
|
+
files.push({ path: "CLAUDE.md", content: CLAUDE_MD_STUB });
|
|
1489
|
+
}
|
|
1490
|
+
return { files, warnings };
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// src/cli/managed-paths.ts
|
|
1494
|
+
import { lstatSync, realpathSync, statSync as statSync2 } from "fs";
|
|
1495
|
+
import { basename as basename2, dirname as dirname2, isAbsolute as isAbsolute2, join as join6, resolve as resolve2, win32 } from "path";
|
|
1496
|
+
var MANAGED_FILES = /* @__PURE__ */ new Set([
|
|
1497
|
+
"AGENTS.md",
|
|
1498
|
+
"CLAUDE.md",
|
|
1499
|
+
".mcp.json",
|
|
1500
|
+
".harness-manifest.json",
|
|
1501
|
+
".claude/settings.json",
|
|
1502
|
+
".codex/config.toml",
|
|
1503
|
+
".codex/hooks.json",
|
|
1504
|
+
".opencode/opencode.json",
|
|
1505
|
+
".opencode/mcp.json"
|
|
1506
|
+
]);
|
|
1507
|
+
var MANAGED_DIRECTORIES = [
|
|
1508
|
+
".claude/agents/",
|
|
1509
|
+
".claude/commands/",
|
|
1510
|
+
".claude/skills/",
|
|
1511
|
+
".codex/agents/",
|
|
1512
|
+
".codex/commands/",
|
|
1513
|
+
".codex/skills/",
|
|
1514
|
+
".agents/skills/",
|
|
1515
|
+
".opencode/agents/",
|
|
1516
|
+
".opencode/commands/",
|
|
1517
|
+
".opencode/skills/",
|
|
1518
|
+
".opencode/plugins/"
|
|
1519
|
+
];
|
|
1520
|
+
function canonicalTargetRoot(root) {
|
|
1521
|
+
let ancestor = resolve2(root);
|
|
1522
|
+
const missing = [];
|
|
1523
|
+
while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
|
|
1524
|
+
missing.unshift(basename2(ancestor));
|
|
1525
|
+
ancestor = dirname2(ancestor);
|
|
1526
|
+
}
|
|
1527
|
+
const canonical = realpathSync(ancestor);
|
|
1528
|
+
if (!statSync2(canonical).isDirectory()) {
|
|
1529
|
+
throw new Error(`harness target is not a directory: ${root}`);
|
|
1530
|
+
}
|
|
1531
|
+
return join6(canonical, ...missing);
|
|
1532
|
+
}
|
|
1533
|
+
function assertManagedLocation(path) {
|
|
1534
|
+
const invalidSegment = path.split("/").some((part) => part === "" || part === "." || part === "..");
|
|
1535
|
+
if (isAbsolute2(path) || win32.isAbsolute(path) || path.includes("\\") || path.includes("\0") || invalidSegment) {
|
|
1536
|
+
throw new Error(`unsafe harness path: ${path}`);
|
|
1537
|
+
}
|
|
1538
|
+
if (!MANAGED_FILES.has(path) && !MANAGED_DIRECTORIES.some((prefix) => path.startsWith(prefix))) {
|
|
1539
|
+
throw new Error(`path is outside harness-managed locations: ${path}`);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
function assertNoSymbolicComponents(root, path) {
|
|
1543
|
+
const parts = path.split("/");
|
|
1544
|
+
let current = root;
|
|
1545
|
+
for (const [index, part] of parts.entries()) {
|
|
1546
|
+
current = join6(current, part);
|
|
1547
|
+
const info = lstatSync(current, { throwIfNoEntry: false });
|
|
1548
|
+
if (!info) return;
|
|
1549
|
+
if (info.isSymbolicLink()) {
|
|
1550
|
+
throw new Error(`symbolic link in harness path: ${path}`);
|
|
1551
|
+
}
|
|
1552
|
+
const isLeaf = index === parts.length - 1;
|
|
1553
|
+
if (isLeaf ? !info.isFile() : !info.isDirectory()) {
|
|
1554
|
+
throw new Error(`unexpected file type in harness path: ${path}`);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
function assertManagedPaths(root, files) {
|
|
1559
|
+
for (const path of files) assertManagedLocation(path);
|
|
1560
|
+
const canonicalRoot2 = canonicalTargetRoot(root);
|
|
1561
|
+
for (const path of files) assertNoSymbolicComponents(canonicalRoot2, path);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// src/cli/manifest.ts
|
|
1565
|
+
import { createHash } from "crypto";
|
|
1566
|
+
import { chmodSync, existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync } from "fs";
|
|
1567
|
+
import { join as join9 } from "path";
|
|
1568
|
+
import { z as z5 } from "zod";
|
|
1569
|
+
|
|
1570
|
+
// src/cli/opencode-config.ts
|
|
1571
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
|
|
1572
|
+
import { join as join7 } from "path";
|
|
1573
|
+
import { isDeepStrictEqual } from "util";
|
|
1574
|
+
import { z as z3 } from "zod";
|
|
1575
|
+
var OPENCODE_CONFIG_PATH = ".opencode/opencode.json";
|
|
1576
|
+
var OPENCODE_SCHEMA = "https://opencode.ai/config.json";
|
|
1577
|
+
var ObjectSchema = z3.record(z3.unknown());
|
|
1578
|
+
var OpenCodeConfigOwnershipSchema = z3.object({
|
|
1579
|
+
createdFile: z3.boolean(),
|
|
1580
|
+
addedMcp: z3.boolean(),
|
|
1581
|
+
addedSchema: z3.boolean(),
|
|
1582
|
+
entries: z3.record(ObjectSchema)
|
|
1583
|
+
});
|
|
1584
|
+
function parseConfig(content) {
|
|
1585
|
+
try {
|
|
1586
|
+
const config = ObjectSchema.parse(JSON.parse(content));
|
|
1587
|
+
if (Object.hasOwn(config, "mcp")) ObjectSchema.parse(config.mcp);
|
|
1588
|
+
return config;
|
|
1589
|
+
} catch {
|
|
1590
|
+
throw new Error(`invalid ${OPENCODE_CONFIG_PATH}: expected a JSON object with an MCP object`);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
function removeOwnedEntries(config, ownership, warnings) {
|
|
1594
|
+
if (Object.hasOwn(config, "mcp")) {
|
|
1595
|
+
const mcp = ObjectSchema.parse(config.mcp);
|
|
1596
|
+
for (const [id, installed] of Object.entries(ownership.entries)) {
|
|
1597
|
+
if (!Object.hasOwn(mcp, id)) continue;
|
|
1598
|
+
if (isDeepStrictEqual(mcp[id], installed)) delete mcp[id];
|
|
1599
|
+
else warnings.push(`preserved modified OpenCode MCP server: ${id}`);
|
|
1600
|
+
}
|
|
1601
|
+
config.mcp = mcp;
|
|
1602
|
+
if (ownership.addedMcp && Object.keys(mcp).length === 0) Reflect.deleteProperty(config, "mcp");
|
|
1603
|
+
}
|
|
1604
|
+
if (ownership.addedSchema && config.$schema === OPENCODE_SCHEMA)
|
|
1605
|
+
Reflect.deleteProperty(config, "$schema");
|
|
1606
|
+
}
|
|
1607
|
+
function addMissingEntries(config, desired, createdFile, warnings) {
|
|
1608
|
+
const requested = ObjectSchema.parse(parseConfig(desired.content).mcp);
|
|
1609
|
+
const mcp = ObjectSchema.parse(config.mcp ?? {});
|
|
1610
|
+
const entries = {};
|
|
1611
|
+
for (const [id, entry] of Object.entries(requested)) {
|
|
1612
|
+
if (Object.hasOwn(mcp, id)) {
|
|
1613
|
+
if (!isDeepStrictEqual(mcp[id], entry)) {
|
|
1614
|
+
warnings.push(`preserved existing OpenCode MCP server instead of replacing it: ${id}`);
|
|
1615
|
+
}
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
entries[id] = ObjectSchema.parse(entry);
|
|
1619
|
+
mcp[id] = entry;
|
|
1620
|
+
}
|
|
1621
|
+
if (Object.keys(entries).length === 0) return void 0;
|
|
1622
|
+
const ownership = {
|
|
1623
|
+
createdFile,
|
|
1624
|
+
addedMcp: !Object.hasOwn(config, "mcp"),
|
|
1625
|
+
addedSchema: !Object.hasOwn(config, "$schema"),
|
|
1626
|
+
entries
|
|
1627
|
+
};
|
|
1628
|
+
config.mcp = mcp;
|
|
1629
|
+
if (ownership.addedSchema) config.$schema = OPENCODE_SCHEMA;
|
|
1630
|
+
return ownership;
|
|
1631
|
+
}
|
|
1632
|
+
function planOpenCodeConfig(root, desired, previous) {
|
|
1633
|
+
const warnings = [];
|
|
1634
|
+
if (!desired && !previous) return { remove: false, warnings };
|
|
1635
|
+
const path = join7(root, OPENCODE_CONFIG_PATH);
|
|
1636
|
+
const existed = existsSync6(path);
|
|
1637
|
+
if (!existed && !desired) return { remove: false, warnings };
|
|
1638
|
+
const original = existed ? readFileSync5(path, "utf8") : void 0;
|
|
1639
|
+
const config = original === void 0 ? {} : parseConfig(original);
|
|
1640
|
+
const before = structuredClone(config);
|
|
1641
|
+
if (previous) removeOwnedEntries(config, previous, warnings);
|
|
1642
|
+
const createdFile = !existed || previous?.createdFile === true;
|
|
1643
|
+
const ownership = desired ? addMissingEntries(config, desired, createdFile, warnings) : void 0;
|
|
1644
|
+
if (!ownership && createdFile && Object.keys(config).length === 0) {
|
|
1645
|
+
return { remove: existed, warnings };
|
|
1646
|
+
}
|
|
1647
|
+
return {
|
|
1648
|
+
remove: false,
|
|
1649
|
+
ownership,
|
|
1650
|
+
warnings,
|
|
1651
|
+
...!existed || !isDeepStrictEqual(before, config) ? { write: { path: OPENCODE_CONFIG_PATH, content: `${JSON.stringify(config, null, 2)}
|
|
1652
|
+
` } } : {}
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// src/cli/update-config.ts
|
|
1657
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
1658
|
+
import { join as join8 } from "path";
|
|
1659
|
+
import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
|
|
1660
|
+
import { z as z4 } from "zod";
|
|
1661
|
+
var LEGACY_OPENCODE_CONFIG = ".opencode/mcp.json";
|
|
1662
|
+
var SHARED_CONFIG_PATHS = [
|
|
1663
|
+
".mcp.json",
|
|
1664
|
+
".codex/config.toml",
|
|
1665
|
+
OPENCODE_CONFIG_PATH,
|
|
1666
|
+
LEGACY_OPENCODE_CONFIG,
|
|
1667
|
+
".claude/settings.json",
|
|
1668
|
+
".codex/hooks.json"
|
|
1669
|
+
];
|
|
1670
|
+
var ObjectSchema2 = z4.record(z4.unknown());
|
|
1671
|
+
var HookEntrySchema = z4.object({ group: ObjectSchema2, hook: ObjectSchema2 });
|
|
1672
|
+
var HookConfigOwnershipSchema = z4.object({
|
|
1673
|
+
createdFile: z4.boolean(),
|
|
1674
|
+
addedHooks: z4.boolean(),
|
|
1675
|
+
addedStop: z4.boolean(),
|
|
1676
|
+
entries: z4.array(HookEntrySchema)
|
|
1677
|
+
});
|
|
1678
|
+
var HookConfigsOwnershipSchema = z4.object({
|
|
1679
|
+
".claude/settings.json": HookConfigOwnershipSchema.optional(),
|
|
1680
|
+
".codex/hooks.json": HookConfigOwnershipSchema.optional()
|
|
1681
|
+
}).strict();
|
|
1682
|
+
var HOOK_ENGINES = {
|
|
1683
|
+
".claude/settings.json": "claude-code",
|
|
1684
|
+
".codex/hooks.json": "codex-cli"
|
|
1685
|
+
};
|
|
1686
|
+
function parseObject(content, path) {
|
|
1687
|
+
try {
|
|
1688
|
+
return ObjectSchema2.parse(JSON.parse(content));
|
|
1689
|
+
} catch {
|
|
1690
|
+
throw new Error(`invalid shared configuration ${path}: expected a JSON object`);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
function asObject(value) {
|
|
1694
|
+
const parsed = ObjectSchema2.safeParse(value);
|
|
1695
|
+
return parsed.success ? parsed.data : void 0;
|
|
1696
|
+
}
|
|
1697
|
+
function stopHooks(config, path) {
|
|
1698
|
+
if (!Object.hasOwn(config, "hooks")) return [];
|
|
1699
|
+
const hooks = asObject(config.hooks);
|
|
1700
|
+
if (!hooks || Object.hasOwn(hooks, "Stop") && !Array.isArray(hooks.Stop)) {
|
|
1701
|
+
throw new Error(`invalid shared configuration ${path}: expected hooks.Stop to be an array`);
|
|
1702
|
+
}
|
|
1703
|
+
return Array.isArray(hooks.Stop) ? hooks.Stop : [];
|
|
1704
|
+
}
|
|
1705
|
+
function groupMetadata(group) {
|
|
1706
|
+
return Object.fromEntries(Object.entries(group).filter(([key]) => key !== "hooks"));
|
|
1707
|
+
}
|
|
1708
|
+
function sameEntry(left, right) {
|
|
1709
|
+
return isDeepStrictEqual2(left.group, right.group) && isDeepStrictEqual2(left.hook, right.hook);
|
|
1710
|
+
}
|
|
1711
|
+
function appendEntry(entries, entry) {
|
|
1712
|
+
if (!entries.some((existing) => sameEntry(existing, entry))) entries.push(entry);
|
|
1713
|
+
}
|
|
1714
|
+
function defaultStopGroup(engine) {
|
|
1715
|
+
const file = getRenderer(engine).renderStopHook().files[0];
|
|
1716
|
+
if (!file) throw new Error(`missing Stop hook renderer for ${engine}`);
|
|
1717
|
+
const group = asObject(stopHooks(parseObject(file.content, file.path), file.path)[0]);
|
|
1718
|
+
if (!group || !Array.isArray(group.hooks) || !asObject(group.hooks[0])) {
|
|
1719
|
+
throw new Error(`invalid Stop hook renderer for ${engine}`);
|
|
1720
|
+
}
|
|
1721
|
+
return group;
|
|
1722
|
+
}
|
|
1723
|
+
function normalizeHarnessHooks(groups, expected, previous, claimExisting, warnings, path) {
|
|
1724
|
+
const defaultHook = ObjectSchema2.parse(expected.hooks[0]);
|
|
1725
|
+
const entries = [...previous?.entries ?? []];
|
|
1726
|
+
let found = false;
|
|
1727
|
+
const normalized = groups.map((value) => {
|
|
1728
|
+
const group = asObject(value);
|
|
1729
|
+
if (!group) return value;
|
|
1730
|
+
const flat = group.type === "command" && group.command === defaultHook.command;
|
|
1731
|
+
const hooks = flat ? [group] : Array.isArray(group.hooks) ? group.hooks : [];
|
|
1732
|
+
const metadata = flat ? groupMetadata(expected) : groupMetadata(group);
|
|
1733
|
+
for (const value2 of hooks) {
|
|
1734
|
+
const hook = asObject(value2);
|
|
1735
|
+
if (!hook || hook.type !== "command" || hook.command !== defaultHook.command) continue;
|
|
1736
|
+
found = true;
|
|
1737
|
+
const entry = { group: metadata, hook };
|
|
1738
|
+
if (!claimExisting) continue;
|
|
1739
|
+
if (previous && !previous.entries.some((old) => sameEntry(old, entry))) {
|
|
1740
|
+
warnings.push(`preserved modified harness Stop hook: ${path}`);
|
|
1741
|
+
} else {
|
|
1742
|
+
appendEntry(entries, entry);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
return flat && claimExisting ? { ...metadata, hooks: [group] } : value;
|
|
1746
|
+
});
|
|
1747
|
+
if (!found && !previous?.entries.length) {
|
|
1748
|
+
normalized.push(expected);
|
|
1749
|
+
appendEntry(entries, { group: groupMetadata(expected), hook: defaultHook });
|
|
1750
|
+
} else if (!found) {
|
|
1751
|
+
warnings.push(`preserved removed or modified harness Stop hook: ${path}`);
|
|
1752
|
+
}
|
|
1753
|
+
return { groups: normalized, entries };
|
|
1754
|
+
}
|
|
1755
|
+
function removeOwnedHooks(config, ownership, path, warnings) {
|
|
1756
|
+
const groups = stopHooks(config, path);
|
|
1757
|
+
const remaining = [];
|
|
1758
|
+
const matched = /* @__PURE__ */ new Set();
|
|
1759
|
+
for (const value of groups) {
|
|
1760
|
+
const group = asObject(value);
|
|
1761
|
+
if (!group || !Array.isArray(group.hooks)) {
|
|
1762
|
+
remaining.push(value);
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
const metadata = groupMetadata(group);
|
|
1766
|
+
const hooks2 = group.hooks.filter((value2) => {
|
|
1767
|
+
const hook = asObject(value2);
|
|
1768
|
+
const entry = hook && ownership.entries.find((old) => sameEntry(old, { group: metadata, hook }));
|
|
1769
|
+
if (entry) matched.add(entry);
|
|
1770
|
+
return !entry;
|
|
1771
|
+
});
|
|
1772
|
+
if (hooks2.length > 0 || hooks2.length === group.hooks.length) {
|
|
1773
|
+
remaining.push({ ...group, hooks: hooks2 });
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
if (matched.size < ownership.entries.length) {
|
|
1777
|
+
warnings.push(`preserved removed or modified harness Stop hook: ${path}`);
|
|
1778
|
+
}
|
|
1779
|
+
const hooks = asObject(config.hooks);
|
|
1780
|
+
if (!hooks) return;
|
|
1781
|
+
if (ownership.addedStop && remaining.length === 0) Reflect.deleteProperty(hooks, "Stop");
|
|
1782
|
+
else if (Object.hasOwn(hooks, "Stop")) hooks.Stop = remaining;
|
|
1783
|
+
if (ownership.addedHooks && Object.keys(hooks).length === 0)
|
|
1784
|
+
Reflect.deleteProperty(config, "hooks");
|
|
1785
|
+
else config.hooks = hooks;
|
|
1786
|
+
}
|
|
1787
|
+
function recordMutation(plan, path, before, after, createdFile) {
|
|
1788
|
+
if (createdFile && Object.keys(after).length === 0) {
|
|
1789
|
+
if (before) plan.removals.push(path);
|
|
1790
|
+
} else if (!isDeepStrictEqual2(before, after)) {
|
|
1791
|
+
plan.writes.push({ path, content: `${JSON.stringify(after, null, 2)}
|
|
1792
|
+
` });
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
function planHookConfigCleanup(root, ownership) {
|
|
1796
|
+
const plan = { writes: [], removals: [], warnings: [] };
|
|
1797
|
+
if (!ownership) return plan;
|
|
1798
|
+
const validated = HookConfigsOwnershipSchema.parse(ownership);
|
|
1799
|
+
assertManagedPaths(root, Object.keys(validated));
|
|
1800
|
+
for (const path of Object.keys(HOOK_ENGINES)) {
|
|
1801
|
+
const owned = validated[path];
|
|
1802
|
+
if (!owned || !existsSync7(join8(root, path))) continue;
|
|
1803
|
+
const before = parseObject(readFileSync6(join8(root, path), "utf8"), path);
|
|
1804
|
+
const config = structuredClone(before);
|
|
1805
|
+
removeOwnedHooks(config, owned, path, plan.warnings);
|
|
1806
|
+
recordMutation(plan, path, before, config, owned.createdFile);
|
|
1807
|
+
}
|
|
1808
|
+
return plan;
|
|
1809
|
+
}
|
|
1810
|
+
function planActiveHook(root, path, previous, claimExisting, plan) {
|
|
1811
|
+
assertManagedPaths(root, [path]);
|
|
1812
|
+
const existed = existsSync7(join8(root, path));
|
|
1813
|
+
if (!existed && previous?.entries.length) {
|
|
1814
|
+
plan.warnings.push(`preserved removed harness Stop hook configuration: ${path}`);
|
|
1815
|
+
return previous;
|
|
1816
|
+
}
|
|
1817
|
+
const before = existed ? parseObject(readFileSync6(join8(root, path), "utf8"), path) : void 0;
|
|
1818
|
+
const config = structuredClone(before ?? {});
|
|
1819
|
+
const groups = stopHooks(config, path);
|
|
1820
|
+
const hooks = asObject(config.hooks) ?? {};
|
|
1821
|
+
const normalized = normalizeHarnessHooks(
|
|
1822
|
+
groups,
|
|
1823
|
+
defaultStopGroup(HOOK_ENGINES[path]),
|
|
1824
|
+
previous,
|
|
1825
|
+
claimExisting,
|
|
1826
|
+
plan.warnings,
|
|
1827
|
+
path
|
|
1828
|
+
);
|
|
1829
|
+
const ownership = {
|
|
1830
|
+
createdFile: !existed || previous?.createdFile === true,
|
|
1831
|
+
addedHooks: previous?.addedHooks ?? !Object.hasOwn(config, "hooks"),
|
|
1832
|
+
addedStop: previous?.addedStop ?? !Object.hasOwn(hooks, "Stop"),
|
|
1833
|
+
entries: normalized.entries
|
|
1834
|
+
};
|
|
1835
|
+
if (!isDeepStrictEqual2(groups, normalized.groups)) {
|
|
1836
|
+
config.hooks = { ...hooks, Stop: normalized.groups };
|
|
1837
|
+
}
|
|
1838
|
+
recordMutation(plan, path, before, config, ownership.createdFile);
|
|
1839
|
+
return ownership.entries.length > 0 ? ownership : void 0;
|
|
1840
|
+
}
|
|
1841
|
+
function planLegacyOpenCode(root, previous, plan) {
|
|
1842
|
+
if (!previous.files.includes(LEGACY_OPENCODE_CONFIG)) return;
|
|
1843
|
+
const legacyPath = join8(root, LEGACY_OPENCODE_CONFIG);
|
|
1844
|
+
if (!existsSync7(legacyPath)) return;
|
|
1845
|
+
assertManagedPaths(root, [LEGACY_OPENCODE_CONFIG, OPENCODE_CONFIG_PATH]);
|
|
1846
|
+
const legacy = parseObject(readFileSync6(legacyPath, "utf8"), LEGACY_OPENCODE_CONFIG);
|
|
1847
|
+
if (!asObject(legacy.mcp)) {
|
|
1848
|
+
throw new Error(
|
|
1849
|
+
`invalid shared configuration ${LEGACY_OPENCODE_CONFIG}: expected an MCP object`
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
const merged = planOpenCodeConfig(
|
|
1853
|
+
root,
|
|
1854
|
+
{ path: OPENCODE_CONFIG_PATH, content: JSON.stringify(legacy) },
|
|
1855
|
+
void 0
|
|
1856
|
+
);
|
|
1857
|
+
if (merged.write) plan.writes.push(merged.write);
|
|
1858
|
+
plan.warnings.push(...merged.warnings);
|
|
1859
|
+
if (merged.ownership) {
|
|
1860
|
+
const old = previous.opencodeConfig;
|
|
1861
|
+
plan.opencodeConfig = {
|
|
1862
|
+
createdFile: merged.ownership.createdFile || old?.createdFile === true,
|
|
1863
|
+
addedMcp: merged.ownership.addedMcp || old?.addedMcp === true,
|
|
1864
|
+
addedSchema: merged.ownership.addedSchema || old?.addedSchema === true,
|
|
1865
|
+
entries: { ...old?.entries, ...merged.ownership.entries }
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
plan.removals.push(LEGACY_OPENCODE_CONFIG);
|
|
1869
|
+
}
|
|
1870
|
+
function planUpdateConfigs(root, previous, hookEngines) {
|
|
1871
|
+
const plan = {
|
|
1872
|
+
writes: [],
|
|
1873
|
+
removals: [],
|
|
1874
|
+
ownedFiles: [],
|
|
1875
|
+
warnings: [],
|
|
1876
|
+
...previous.opencodeConfig ? { opencodeConfig: previous.opencodeConfig } : {}
|
|
1877
|
+
};
|
|
1878
|
+
planLegacyOpenCode(root, previous, plan);
|
|
1879
|
+
const hookConfigs = {};
|
|
1880
|
+
for (const path of Object.keys(HOOK_ENGINES)) {
|
|
1881
|
+
const owned = previous.hookConfigs?.[path];
|
|
1882
|
+
if (hookEngines.includes(HOOK_ENGINES[path])) {
|
|
1883
|
+
hookConfigs[path] = planActiveHook(
|
|
1884
|
+
root,
|
|
1885
|
+
path,
|
|
1886
|
+
owned,
|
|
1887
|
+
previous.files.includes(path) || Boolean(owned),
|
|
1888
|
+
plan
|
|
1889
|
+
);
|
|
1890
|
+
} else if (owned) {
|
|
1891
|
+
const cleanup = planHookConfigCleanup(root, { [path]: owned });
|
|
1892
|
+
plan.writes.push(...cleanup.writes);
|
|
1893
|
+
plan.removals.push(...cleanup.removals);
|
|
1894
|
+
plan.warnings.push(...cleanup.warnings);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
if (Object.values(hookConfigs).some(Boolean)) plan.hookConfigs = hookConfigs;
|
|
1898
|
+
if (hookEngines.includes("opencode")) {
|
|
1899
|
+
const plugin = getRenderer("opencode").renderStopHook();
|
|
1900
|
+
plan.writes.push(...plugin.files);
|
|
1901
|
+
plan.ownedFiles.push(...plugin.files.map((file) => file.path));
|
|
1902
|
+
plan.warnings.push(...plugin.warnings);
|
|
1903
|
+
}
|
|
1904
|
+
return plan;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// src/cli/manifest.ts
|
|
1908
|
+
var MANIFEST_FILENAME = ".harness-manifest.json";
|
|
1909
|
+
var MANIFEST_VERSION = "6";
|
|
1910
|
+
var ManifestSchema = z5.object({
|
|
1911
|
+
version: z5.enum(["4", "5", MANIFEST_VERSION]),
|
|
1912
|
+
engines: z5.array(z5.string()),
|
|
1913
|
+
renderedAt: z5.string(),
|
|
1914
|
+
files: z5.array(z5.string()),
|
|
1915
|
+
stopHookInstalled: z5.boolean().optional(),
|
|
1916
|
+
stopHookEngines: z5.array(z5.string()).optional(),
|
|
1917
|
+
opencodeConfig: OpenCodeConfigOwnershipSchema.optional(),
|
|
1918
|
+
hookConfigs: HookConfigsOwnershipSchema.optional(),
|
|
1919
|
+
harnessVersion: z5.string().optional(),
|
|
1920
|
+
overridesDir: z5.string().optional(),
|
|
1921
|
+
fileHashes: z5.record(z5.string().regex(/^[a-f0-9]{64}$/)).optional(),
|
|
1922
|
+
preservedFiles: z5.array(z5.string()).optional()
|
|
1923
|
+
});
|
|
1924
|
+
function manifestPath(targetDir) {
|
|
1925
|
+
return join9(targetDir, MANIFEST_FILENAME);
|
|
1926
|
+
}
|
|
1927
|
+
function writeManifest(targetDir, engines, files, extras = {}) {
|
|
1928
|
+
assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
|
|
1929
|
+
const manifest = {
|
|
1930
|
+
version: MANIFEST_VERSION,
|
|
1931
|
+
harnessVersion: VERSION,
|
|
1932
|
+
engines: [...engines].sort(),
|
|
1933
|
+
renderedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1934
|
+
files: [...files].sort(),
|
|
1935
|
+
fileHashes: Object.fromEntries(
|
|
1936
|
+
files.filter((file) => existsSync8(join9(targetDir, file))).sort().map((file) => [file, hashContent(readFileSync7(join9(targetDir, file)))])
|
|
1937
|
+
),
|
|
1938
|
+
...extras.overridesDir ? { overridesDir: extras.overridesDir } : {},
|
|
1939
|
+
...extras.preservedFiles ? { preservedFiles: extras.preservedFiles } : {},
|
|
1940
|
+
...extras.hookConfigs ? { hookConfigs: extras.hookConfigs } : {},
|
|
1941
|
+
...extras.opencodeConfig ? { opencodeConfig: extras.opencodeConfig } : {},
|
|
1942
|
+
...extras.stopHookInstalled !== void 0 ? { stopHookInstalled: extras.stopHookInstalled } : {},
|
|
1943
|
+
...extras.stopHookEngines !== void 0 ? { stopHookEngines: [...extras.stopHookEngines].sort() } : {}
|
|
1944
|
+
};
|
|
1945
|
+
const path = manifestPath(targetDir);
|
|
1946
|
+
if (existsSync8(path)) chmodSync(path, 384);
|
|
1947
|
+
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}
|
|
1948
|
+
`, { mode: 384 });
|
|
1949
|
+
}
|
|
1950
|
+
function hashContent(content) {
|
|
1951
|
+
return createHash("sha256").update(content).digest("hex");
|
|
1952
|
+
}
|
|
1953
|
+
function readManifest(targetDir) {
|
|
1954
|
+
assertManagedPaths(targetDir, [MANIFEST_FILENAME]);
|
|
1955
|
+
const path = manifestPath(targetDir);
|
|
1956
|
+
if (!existsSync8(path)) return null;
|
|
1957
|
+
const raw = JSON.parse(readFileSync7(path, "utf8"));
|
|
1958
|
+
return ManifestSchema.parse(raw);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// src/cli/uninstall.ts
|
|
1962
|
+
import { existsSync as existsSync9, readdirSync, rmdirSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1963
|
+
import { dirname as dirname3, join as join10, relative as relative2, resolve as resolve3 } from "path";
|
|
1964
|
+
function uninstallCommand(args) {
|
|
1965
|
+
const root = resolve3(args.targetDir);
|
|
1966
|
+
const warnings = [];
|
|
1967
|
+
assertManagedPaths(root, [MANIFEST_FILENAME]);
|
|
1968
|
+
const manifest = readManifest(root);
|
|
1969
|
+
if (!manifest) {
|
|
1970
|
+
warnings.push(`no manifest found at ${manifestPath(root)} \u2014 nothing to uninstall`);
|
|
1971
|
+
return { filesRemoved: [], dirsPruned: [], warnings };
|
|
1972
|
+
}
|
|
1973
|
+
assertManagedPaths(root, [
|
|
1974
|
+
...manifest.files,
|
|
1975
|
+
...manifest.opencodeConfig ? [OPENCODE_CONFIG_PATH] : [],
|
|
1976
|
+
...Object.keys(manifest.hookConfigs ?? {})
|
|
1977
|
+
]);
|
|
1978
|
+
const config = planOpenCodeConfig(root, void 0, manifest.opencodeConfig);
|
|
1979
|
+
const hooks = planHookConfigCleanup(root, manifest.hookConfigs);
|
|
1980
|
+
const sharedHooks = new Set(Object.keys(manifest.hookConfigs ?? {}));
|
|
1981
|
+
const ownedFiles = manifest.files.filter(
|
|
1982
|
+
(file) => file !== OPENCODE_CONFIG_PATH && !sharedHooks.has(file)
|
|
1983
|
+
);
|
|
1984
|
+
if (config.remove) ownedFiles.push(OPENCODE_CONFIG_PATH);
|
|
1985
|
+
ownedFiles.push(...hooks.removals);
|
|
1986
|
+
const { filesRemoved, dirsPruned, missing } = removeRenderedFiles(root, ownedFiles);
|
|
1987
|
+
if (config.write) writeFileSync2(join10(root, config.write.path), config.write.content);
|
|
1988
|
+
for (const file of hooks.writes) writeFileSync2(join10(root, file.path), file.content);
|
|
1989
|
+
warnings.push(...config.warnings, ...hooks.warnings);
|
|
1990
|
+
if (manifest.files.includes(OPENCODE_CONFIG_PATH) && !manifest.opencodeConfig) {
|
|
1991
|
+
warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
|
|
1992
|
+
}
|
|
1993
|
+
for (const rel of missing) warnings.push(`file in manifest is already missing: ${rel}`);
|
|
1994
|
+
unlinkSync(manifestPath(root));
|
|
1995
|
+
return { filesRemoved, dirsPruned, warnings };
|
|
1996
|
+
}
|
|
1997
|
+
function removeRenderedFiles(root, files) {
|
|
1998
|
+
assertManagedPaths(root, files);
|
|
1999
|
+
const filesRemoved = [];
|
|
2000
|
+
const missing = [];
|
|
2001
|
+
const dirsToConsider = /* @__PURE__ */ new Set();
|
|
2002
|
+
for (const rel of files) {
|
|
2003
|
+
const full = join10(root, rel);
|
|
2004
|
+
if (existsSync9(full)) {
|
|
2005
|
+
unlinkSync(full);
|
|
2006
|
+
filesRemoved.push(rel);
|
|
2007
|
+
dirsToConsider.add(dirname3(full));
|
|
2008
|
+
} else {
|
|
2009
|
+
missing.push(rel);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
return { filesRemoved, dirsPruned: pruneEmptyDirs(root, dirsToConsider), missing };
|
|
2013
|
+
}
|
|
2014
|
+
function pruneEmptyDirs(root, leaves) {
|
|
2015
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
2016
|
+
for (const leaf of leaves) {
|
|
2017
|
+
let cur = leaf;
|
|
2018
|
+
while (cur !== root && cur.startsWith(`${root}/`)) {
|
|
2019
|
+
candidates.add(cur);
|
|
2020
|
+
cur = dirname3(cur);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
const pruned = [];
|
|
2024
|
+
const sorted = [...candidates].sort((a, b) => b.length - a.length);
|
|
2025
|
+
for (const dir of sorted) {
|
|
2026
|
+
if (!existsSync9(dir)) continue;
|
|
2027
|
+
if (readdirSync(dir).length > 0) continue;
|
|
2028
|
+
rmdirSync(dir);
|
|
2029
|
+
pruned.push(relative2(root, dir));
|
|
2030
|
+
}
|
|
2031
|
+
return pruned;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// src/cli/init.ts
|
|
2035
|
+
var QUALITY_DOC_PATH = "docs/quality/code-quality.md";
|
|
2036
|
+
function resolveEngineList(requested) {
|
|
2037
|
+
if (!requested || requested.length === 0) return [...ENGINES];
|
|
2038
|
+
return requested.map((e) => {
|
|
2039
|
+
if (!ENGINES.includes(e)) {
|
|
2040
|
+
throw new Error(`unknown engine: ${e} (expected one of ${ENGINES.join(", ")})`);
|
|
2041
|
+
}
|
|
2042
|
+
return e;
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
function defaultPackageDir() {
|
|
2046
|
+
const here = dirname4(fileURLToPath(import.meta.url));
|
|
2047
|
+
if (existsSync10(join11(here, "agents"))) return here;
|
|
2048
|
+
return resolve4(here, "..");
|
|
2049
|
+
}
|
|
2050
|
+
function planInit(args) {
|
|
2051
|
+
const engines = resolveEngineList(args.engines);
|
|
2052
|
+
const packageDir = args.packageDir ?? defaultPackageDir();
|
|
2053
|
+
const harness = loadHarness(packageDir);
|
|
2054
|
+
const overrides = args.overridesDir ? loadOverrides(args.overridesDir) : void 0;
|
|
2055
|
+
const projectSkills = loadProjectSkills(args.targetDir);
|
|
2056
|
+
const hasQualityDoc = existsSync10(join11(args.targetDir, QUALITY_DOC_PATH));
|
|
2057
|
+
const mcpServers = args.mcpServers ? resolveMcpIds(args.mcpServers) : void 0;
|
|
2058
|
+
const stopHookInstalled = args.withStopHook !== false;
|
|
2059
|
+
const output = render({
|
|
2060
|
+
harness,
|
|
2061
|
+
engines,
|
|
2062
|
+
overrides,
|
|
2063
|
+
projectSkills,
|
|
2064
|
+
hasQualityDoc,
|
|
2065
|
+
mcpServers,
|
|
2066
|
+
withStopHook: stopHookInstalled
|
|
2067
|
+
});
|
|
2068
|
+
return { engines, output, stopHookInstalled };
|
|
2069
|
+
}
|
|
2070
|
+
function writeFilesToTarget(files, targetDir) {
|
|
2071
|
+
assertManagedPaths(
|
|
2072
|
+
targetDir,
|
|
2073
|
+
files.map((file) => file.path)
|
|
2074
|
+
);
|
|
2075
|
+
const written = [];
|
|
2076
|
+
for (const f of files) {
|
|
2077
|
+
const full = join11(targetDir, f.path);
|
|
2078
|
+
mkdirSync(dirname4(full), { recursive: true });
|
|
2079
|
+
writeFileSync3(full, f.content);
|
|
2080
|
+
written.push(f.path);
|
|
2081
|
+
}
|
|
2082
|
+
return written;
|
|
2083
|
+
}
|
|
2084
|
+
function initCommand(args) {
|
|
2085
|
+
const { engines, output, stopHookInstalled } = planInit(args);
|
|
2086
|
+
const previous = readManifest(args.targetDir);
|
|
2087
|
+
assertManagedPaths(args.targetDir, [
|
|
2088
|
+
MANIFEST_FILENAME,
|
|
2089
|
+
...previous?.files ?? [],
|
|
2090
|
+
...output.files.map((file) => file.path),
|
|
2091
|
+
...previous?.opencodeConfig ? [OPENCODE_CONFIG_PATH] : []
|
|
2092
|
+
]);
|
|
2093
|
+
const desiredConfig = output.files.find((file) => file.path === OPENCODE_CONFIG_PATH);
|
|
2094
|
+
const config = planOpenCodeConfig(args.targetDir, desiredConfig, previous?.opencodeConfig);
|
|
2095
|
+
const next = new Set(output.files.map((f) => f.path));
|
|
2096
|
+
const stale = (previous?.files ?? []).filter((f) => !next.has(f) && f !== OPENCODE_CONFIG_PATH);
|
|
2097
|
+
if (config.remove) stale.push(OPENCODE_CONFIG_PATH);
|
|
2098
|
+
const staleFilesRemoved = removeRenderedFiles(resolve4(args.targetDir), stale).filesRemoved;
|
|
2099
|
+
const ownedFiles = output.files.filter((file) => file.path !== OPENCODE_CONFIG_PATH);
|
|
2100
|
+
const filesWritten = writeFilesToTarget(ownedFiles, args.targetDir);
|
|
2101
|
+
if (config.write) writeFilesToTarget([config.write], args.targetDir);
|
|
2102
|
+
writeManifest(args.targetDir, engines, filesWritten, {
|
|
2103
|
+
stopHookInstalled,
|
|
2104
|
+
stopHookEngines: stopHookInstalled ? engines : void 0,
|
|
2105
|
+
opencodeConfig: config.ownership,
|
|
2106
|
+
overridesDir: args.overridesDir ? resolve4(args.overridesDir) === resolve4(args.targetDir) ? "." : resolve4(args.overridesDir) : void 0
|
|
2107
|
+
});
|
|
2108
|
+
if (config.write) filesWritten.push(OPENCODE_CONFIG_PATH);
|
|
2109
|
+
const warnings = [...output.warnings, ...config.warnings];
|
|
2110
|
+
if (previous?.files.includes(OPENCODE_CONFIG_PATH) && !previous.opencodeConfig) {
|
|
2111
|
+
warnings.push(`preserved legacy shared configuration: ${OPENCODE_CONFIG_PATH}`);
|
|
2112
|
+
}
|
|
2113
|
+
return { filesWritten, staleFilesRemoved, warnings, stopHookInstalled };
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// src/cli/render.ts
|
|
2117
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
2118
|
+
import { dirname as dirname5, join as join12 } from "path";
|
|
2119
|
+
function renderCommand(args) {
|
|
2120
|
+
const engineList = args.engines && args.engines.length > 0 ? args.engines.map((e) => {
|
|
2121
|
+
if (!ENGINES.includes(e)) {
|
|
2122
|
+
throw new Error(`unknown engine: ${e}`);
|
|
2123
|
+
}
|
|
2124
|
+
return e;
|
|
2125
|
+
}) : [...ENGINES];
|
|
2126
|
+
const harness = loadHarness(args.packageDir);
|
|
2127
|
+
const mcpServers = args.mcpServers ? resolveMcpIds(args.mcpServers) : void 0;
|
|
2128
|
+
const output = render({
|
|
2129
|
+
harness,
|
|
2130
|
+
engines: engineList,
|
|
2131
|
+
mcpServers,
|
|
2132
|
+
withStopHook: args.withStopHook !== false
|
|
2133
|
+
});
|
|
2134
|
+
const filesWritten = [];
|
|
2135
|
+
for (const f of output.files) {
|
|
2136
|
+
const full = join12(args.outDir, f.path);
|
|
2137
|
+
mkdirSync2(dirname5(full), { recursive: true });
|
|
2138
|
+
writeFileSync4(full, f.content);
|
|
2139
|
+
filesWritten.push(f.path);
|
|
2140
|
+
}
|
|
2141
|
+
return { filesWritten, warnings: output.warnings };
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// src/cli/stop-hook.ts
|
|
2145
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
2146
|
+
import { resolve as resolve5 } from "path";
|
|
2147
|
+
|
|
2148
|
+
// src/stop-hook/adapters/claude.ts
|
|
2149
|
+
var claudeAdapter = {
|
|
2150
|
+
parseInput(stdin) {
|
|
2151
|
+
const parsed = stdin.trim() === "" ? {} : JSON.parse(stdin);
|
|
2152
|
+
return {
|
|
2153
|
+
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "default",
|
|
2154
|
+
stopHookActive: parsed.stop_hook_active === true
|
|
2155
|
+
};
|
|
2156
|
+
},
|
|
2157
|
+
respond(decision) {
|
|
2158
|
+
if (decision.kind === "inject") {
|
|
2159
|
+
return {
|
|
2160
|
+
stdout: `${JSON.stringify({ decision: "block", reason: decision.reason ?? "" })}
|
|
2161
|
+
`,
|
|
2162
|
+
stderr: "",
|
|
2163
|
+
exitCode: 0
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
if (decision.kind === "terminate") {
|
|
2167
|
+
return {
|
|
2168
|
+
stdout: "",
|
|
2169
|
+
stderr: `${decision.reason ?? ""}
|
|
2170
|
+
`,
|
|
2171
|
+
exitCode: 0
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
2175
|
+
}
|
|
2176
|
+
};
|
|
2177
|
+
|
|
2178
|
+
// src/stop-hook/adapters/codex.ts
|
|
2179
|
+
var codexAdapter = {
|
|
2180
|
+
parseInput(stdin) {
|
|
2181
|
+
const parsed = stdin.trim() === "" ? {} : JSON.parse(stdin);
|
|
2182
|
+
return {
|
|
2183
|
+
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "default",
|
|
2184
|
+
stopHookActive: parsed.stop_hook_active === true
|
|
2185
|
+
};
|
|
2186
|
+
},
|
|
2187
|
+
respond(decision) {
|
|
2188
|
+
if (decision.kind === "inject") {
|
|
2189
|
+
return {
|
|
2190
|
+
stdout: `${JSON.stringify({ decision: "block", reason: decision.reason ?? "" })}
|
|
2191
|
+
`,
|
|
2192
|
+
stderr: "",
|
|
2193
|
+
exitCode: 0
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
if (decision.kind === "terminate") {
|
|
2197
|
+
return {
|
|
2198
|
+
stdout: "",
|
|
2199
|
+
stderr: `${decision.reason ?? ""}
|
|
2200
|
+
`,
|
|
2201
|
+
exitCode: 0
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
2205
|
+
}
|
|
2206
|
+
};
|
|
2207
|
+
|
|
2208
|
+
// src/stop-hook/adapters/opencode.ts
|
|
2209
|
+
var opencodeAdapter = {
|
|
2210
|
+
parseInput(stdin) {
|
|
2211
|
+
const parsed = stdin.trim() === "" ? {} : JSON.parse(stdin);
|
|
2212
|
+
const sessionId = typeof parsed.sessionID === "string" ? parsed.sessionID : typeof parsed.session_id === "string" ? parsed.session_id : "default";
|
|
2213
|
+
return { sessionId, stopHookActive: false };
|
|
2214
|
+
},
|
|
2215
|
+
respond(decision) {
|
|
2216
|
+
if (decision.kind === "inject") {
|
|
2217
|
+
return { stdout: "", stderr: `${decision.reason ?? ""}
|
|
2218
|
+
`, exitCode: 2 };
|
|
2219
|
+
}
|
|
2220
|
+
if (decision.kind === "terminate") {
|
|
2221
|
+
return { stdout: "", stderr: `${decision.reason ?? ""}
|
|
2222
|
+
`, exitCode: 0 };
|
|
2223
|
+
}
|
|
2224
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
2225
|
+
}
|
|
2226
|
+
};
|
|
2227
|
+
|
|
2228
|
+
// src/stop-hook/adapters/index.ts
|
|
2229
|
+
var ADAPTERS = {
|
|
2230
|
+
claude: claudeAdapter,
|
|
2231
|
+
codex: codexAdapter,
|
|
2232
|
+
opencode: opencodeAdapter
|
|
2233
|
+
};
|
|
2234
|
+
function getAdapter(engine) {
|
|
2235
|
+
return ADAPTERS[engine];
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
// src/stop-hook/core.ts
|
|
2239
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
2240
|
+
import { createHash as createHash2 } from "crypto";
|
|
2241
|
+
|
|
2242
|
+
// src/stop-hook/state.ts
|
|
2243
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync5 } from "fs";
|
|
2244
|
+
import { dirname as dirname6, join as join13 } from "path";
|
|
2245
|
+
import { z as z6 } from "zod";
|
|
2246
|
+
var LintStateSchema = z6.object({
|
|
2247
|
+
attempts: z6.number().int().nonnegative(),
|
|
2248
|
+
lastLintHash: z6.string(),
|
|
2249
|
+
updatedAt: z6.string()
|
|
2250
|
+
});
|
|
2251
|
+
var STATE_DIR = ".harness/state";
|
|
2252
|
+
function safeSessionId(sessionId) {
|
|
2253
|
+
return sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80) || "default";
|
|
2254
|
+
}
|
|
2255
|
+
function statePath(cwd, sessionId) {
|
|
2256
|
+
return join13(cwd, STATE_DIR, `lint-${safeSessionId(sessionId)}.json`);
|
|
2257
|
+
}
|
|
2258
|
+
function readState(cwd, sessionId) {
|
|
2259
|
+
const path = statePath(cwd, sessionId);
|
|
2260
|
+
if (!existsSync11(path)) return null;
|
|
2261
|
+
try {
|
|
2262
|
+
const raw = JSON.parse(readFileSync8(path, "utf8"));
|
|
2263
|
+
return LintStateSchema.parse(raw);
|
|
2264
|
+
} catch {
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
function writeState(cwd, sessionId, state) {
|
|
2269
|
+
const path = statePath(cwd, sessionId);
|
|
2270
|
+
mkdirSync3(dirname6(path), { recursive: true });
|
|
2271
|
+
writeFileSync5(path, `${JSON.stringify(state, null, 2)}
|
|
2272
|
+
`);
|
|
2273
|
+
}
|
|
2274
|
+
function resetState(cwd, sessionId) {
|
|
2275
|
+
const path = statePath(cwd, sessionId);
|
|
2276
|
+
if (existsSync11(path)) rmSync(path, { force: true });
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// src/stop-hook/types.ts
|
|
2280
|
+
var STOP_HOOK_ENGINES = ["claude", "codex", "opencode"];
|
|
2281
|
+
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
2282
|
+
|
|
2283
|
+
// src/stop-hook/core.ts
|
|
2284
|
+
function exhaustedReason(attempts, lintOutput) {
|
|
2285
|
+
return `Harness lint: still failing after ${attempts} attempts.
|
|
2286
|
+
|
|
2287
|
+
${lintOutput}`;
|
|
2288
|
+
}
|
|
2289
|
+
function noProgressReason(lintOutput) {
|
|
2290
|
+
return `Harness lint: no progress between attempts.
|
|
2291
|
+
|
|
2292
|
+
${lintOutput}`;
|
|
2293
|
+
}
|
|
2294
|
+
function hash(s) {
|
|
2295
|
+
return createHash2("sha256").update(s).digest("hex").slice(0, 16);
|
|
2296
|
+
}
|
|
2297
|
+
function maxAttempts(env) {
|
|
2298
|
+
const raw = env.HARNESS_LINT_MAX_ATTEMPTS;
|
|
2299
|
+
if (!raw) return DEFAULT_MAX_ATTEMPTS;
|
|
2300
|
+
const n = Number.parseInt(raw, 10);
|
|
2301
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_ATTEMPTS;
|
|
2302
|
+
}
|
|
2303
|
+
function defaultGitStatus(cwd) {
|
|
2304
|
+
try {
|
|
2305
|
+
return execFileSync2("git", ["status", "--porcelain"], {
|
|
2306
|
+
cwd,
|
|
2307
|
+
encoding: "utf8",
|
|
2308
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2309
|
+
});
|
|
2310
|
+
} catch {
|
|
2311
|
+
return "";
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
function defaultRunLint(cwd) {
|
|
2315
|
+
const report = runLinters(cwd);
|
|
2316
|
+
return { pass: report.summary.pass, output: formatReportText(report) };
|
|
2317
|
+
}
|
|
2318
|
+
function decide(payload, coreEnv) {
|
|
2319
|
+
if (coreEnv.env.HARNESS_LINT_DISABLE === "1") {
|
|
2320
|
+
return { kind: "passthrough" };
|
|
2321
|
+
}
|
|
2322
|
+
if (payload.stopHookActive) {
|
|
2323
|
+
return { kind: "passthrough" };
|
|
2324
|
+
}
|
|
2325
|
+
const dirty = coreEnv.gitStatus(coreEnv.cwd);
|
|
2326
|
+
if (dirty.trim() === "") {
|
|
2327
|
+
resetState(coreEnv.cwd, payload.sessionId);
|
|
2328
|
+
return { kind: "passthrough" };
|
|
2329
|
+
}
|
|
2330
|
+
const lintResult = coreEnv.runLint(coreEnv.cwd);
|
|
2331
|
+
if (lintResult.pass) {
|
|
2332
|
+
resetState(coreEnv.cwd, payload.sessionId);
|
|
2333
|
+
return { kind: "passthrough" };
|
|
2334
|
+
}
|
|
2335
|
+
const lintHash = hash(lintResult.output);
|
|
2336
|
+
const prev = readState(coreEnv.cwd, payload.sessionId);
|
|
2337
|
+
if (prev && prev.attempts > 0 && prev.lastLintHash === lintHash) {
|
|
2338
|
+
resetState(coreEnv.cwd, payload.sessionId);
|
|
2339
|
+
return { kind: "terminate", reason: noProgressReason(lintResult.output) };
|
|
2340
|
+
}
|
|
2341
|
+
const nextAttempts = (prev?.attempts ?? 0) + 1;
|
|
2342
|
+
const limit = maxAttempts(coreEnv.env);
|
|
2343
|
+
if (nextAttempts > limit) {
|
|
2344
|
+
resetState(coreEnv.cwd, payload.sessionId);
|
|
2345
|
+
return { kind: "terminate", reason: exhaustedReason(limit, lintResult.output) };
|
|
2346
|
+
}
|
|
2347
|
+
writeState(coreEnv.cwd, payload.sessionId, {
|
|
2348
|
+
attempts: nextAttempts,
|
|
2349
|
+
lastLintHash: lintHash,
|
|
2350
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2351
|
+
});
|
|
2352
|
+
return { kind: "inject", reason: lintResult.output };
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
// src/cli/stop-hook.ts
|
|
2356
|
+
function readStdinSync() {
|
|
2357
|
+
try {
|
|
2358
|
+
return readFileSync9(0, "utf8");
|
|
2359
|
+
} catch {
|
|
2360
|
+
return "";
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
function assertEngine(engine) {
|
|
2364
|
+
if (!STOP_HOOK_ENGINES.includes(engine)) {
|
|
2365
|
+
throw new Error(`unknown engine: ${engine} (expected one of ${STOP_HOOK_ENGINES.join(", ")})`);
|
|
2366
|
+
}
|
|
2367
|
+
return engine;
|
|
2368
|
+
}
|
|
2369
|
+
function stopHookCommand(args, stdin) {
|
|
2370
|
+
const engine = assertEngine(args.engine);
|
|
2371
|
+
const cwd = resolve5(args.cwd ?? ".");
|
|
2372
|
+
const adapter = getAdapter(engine);
|
|
2373
|
+
const payload = adapter.parseInput(stdin, process.env);
|
|
2374
|
+
const decision = decide(payload, {
|
|
2375
|
+
cwd,
|
|
2376
|
+
env: process.env,
|
|
2377
|
+
gitStatus: defaultGitStatus,
|
|
2378
|
+
runLint: defaultRunLint
|
|
2379
|
+
});
|
|
2380
|
+
return adapter.respond(decision);
|
|
2381
|
+
}
|
|
2382
|
+
function stopHookCommandFromStdin(args) {
|
|
2383
|
+
return stopHookCommand(args, readStdinSync());
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// src/cli/update.ts
|
|
2387
|
+
import { existsSync as existsSync13, readFileSync as readFileSync12 } from "fs";
|
|
2388
|
+
import { join as join16, resolve as resolve7 } from "path";
|
|
2389
|
+
import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
|
|
2390
|
+
|
|
2391
|
+
// src/cli/update-manifest.ts
|
|
2392
|
+
import { existsSync as existsSync12, readFileSync as readFileSync10 } from "fs";
|
|
2393
|
+
import { join as join14 } from "path";
|
|
2394
|
+
import { z as z7 } from "zod";
|
|
2395
|
+
var EngineSchema = z7.enum(ENGINES);
|
|
2396
|
+
var LegacySchema = z7.object({
|
|
2397
|
+
version: z7.enum(["1", "2", "3"]),
|
|
2398
|
+
engine: EngineSchema.optional(),
|
|
2399
|
+
engines: z7.array(EngineSchema).nonempty().optional(),
|
|
2400
|
+
renderedAt: z7.string(),
|
|
2401
|
+
files: z7.array(z7.string())
|
|
2402
|
+
});
|
|
2403
|
+
var HOOK_PATHS = {
|
|
2404
|
+
"claude-code": [".claude/settings.json"],
|
|
2405
|
+
"codex-cli": [".codex/hooks.json"],
|
|
2406
|
+
opencode: [
|
|
2407
|
+
".opencode/plugins/harness-stop-hook/index.ts",
|
|
2408
|
+
".opencode/plugins/harness-stop-hook.ts"
|
|
2409
|
+
]
|
|
2410
|
+
};
|
|
2411
|
+
function readUpdateInstallation(root) {
|
|
2412
|
+
assertManagedPaths(root, [MANIFEST_FILENAME]);
|
|
2413
|
+
if (!existsSync12(join14(root, MANIFEST_FILENAME))) {
|
|
2414
|
+
throw new Error("no harness manifest found; use init for a new installation");
|
|
2415
|
+
}
|
|
2416
|
+
const raw = JSON.parse(readFileSync10(join14(root, MANIFEST_FILENAME), "utf8"));
|
|
2417
|
+
const sourceVersion = String(raw?.version);
|
|
2418
|
+
let manifest;
|
|
2419
|
+
if (["1", "2", "3"].includes(sourceVersion)) {
|
|
2420
|
+
const legacy = LegacySchema.parse(raw);
|
|
2421
|
+
const engines2 = legacy.version === "1" ? [EngineSchema.parse(legacy.engine)] : legacy.engines;
|
|
2422
|
+
manifest = {
|
|
2423
|
+
version: "4",
|
|
2424
|
+
engines: z7.array(EngineSchema).nonempty().parse(engines2),
|
|
2425
|
+
files: legacy.files,
|
|
2426
|
+
renderedAt: legacy.renderedAt
|
|
2427
|
+
};
|
|
2428
|
+
} else {
|
|
2429
|
+
const current = readManifest(root);
|
|
2430
|
+
if (!current) throw new Error("harness manifest disappeared during planning");
|
|
2431
|
+
manifest = current;
|
|
2432
|
+
}
|
|
2433
|
+
const engines = [...new Set(z7.array(EngineSchema).nonempty().parse(manifest.engines))];
|
|
2434
|
+
const hookEngines = manifest.stopHookInstalled === false ? [] : manifest.stopHookEngines !== void 0 ? z7.array(EngineSchema).parse(manifest.stopHookEngines) : manifest.stopHookInstalled === true ? engines : engines.filter(
|
|
2435
|
+
(engine) => HOOK_PATHS[engine].some((path) => manifest.files.includes(path))
|
|
2436
|
+
);
|
|
2437
|
+
if (hookEngines.some((engine) => !engines.includes(engine))) {
|
|
2438
|
+
throw new Error("manifest stopHookEngines must be a subset of installed engines");
|
|
2439
|
+
}
|
|
2440
|
+
return { manifest, sourceVersion, engines, hookEngines: [...new Set(hookEngines)] };
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// src/cli/update-transaction.ts
|
|
2444
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
2445
|
+
import {
|
|
2446
|
+
chmodSync as chmodSync2,
|
|
2447
|
+
closeSync,
|
|
2448
|
+
fsyncSync,
|
|
2449
|
+
lstatSync as lstatSync2,
|
|
2450
|
+
mkdirSync as mkdirSync4,
|
|
2451
|
+
openSync,
|
|
2452
|
+
readFileSync as readFileSync11,
|
|
2453
|
+
readdirSync as readdirSync2,
|
|
2454
|
+
realpathSync as realpathSync2,
|
|
2455
|
+
rmdirSync as rmdirSync2,
|
|
2456
|
+
unlinkSync as unlinkSync2,
|
|
2457
|
+
writeFileSync as writeFileSync6
|
|
2458
|
+
} from "fs";
|
|
2459
|
+
import { homedir } from "os";
|
|
2460
|
+
import { basename as basename3, dirname as dirname7, isAbsolute as isAbsolute3, join as join15, relative as relative3, resolve as resolve6, sep } from "path";
|
|
2461
|
+
var MANIFEST_PATH = ".harness-manifest.json";
|
|
2462
|
+
var PRIVATE_FILES = /* @__PURE__ */ new Set([
|
|
2463
|
+
MANIFEST_PATH,
|
|
2464
|
+
".mcp.json",
|
|
2465
|
+
".codex/config.toml",
|
|
2466
|
+
".codex/hooks.json",
|
|
2467
|
+
".claude/settings.json",
|
|
2468
|
+
".opencode/opencode.json",
|
|
2469
|
+
".opencode/mcp.json"
|
|
2470
|
+
]);
|
|
2471
|
+
function canonicalRoot(root) {
|
|
2472
|
+
let ancestor = resolve6(root);
|
|
2473
|
+
const missing = [];
|
|
2474
|
+
while (!lstatSync2(ancestor, { throwIfNoEntry: false })) {
|
|
2475
|
+
missing.unshift(basename3(ancestor));
|
|
2476
|
+
ancestor = dirname7(ancestor);
|
|
2477
|
+
}
|
|
2478
|
+
return join15(realpathSync2(ancestor), ...missing);
|
|
2479
|
+
}
|
|
2480
|
+
function contains(parent, child) {
|
|
2481
|
+
const path = relative3(parent, child);
|
|
2482
|
+
return path === "" || !isAbsolute3(path) && path !== ".." && !path.startsWith(`..${sep}`);
|
|
2483
|
+
}
|
|
2484
|
+
function assertBackupLocation(root, backup) {
|
|
2485
|
+
if (contains(root, backup) || contains(backup, root)) {
|
|
2486
|
+
throw new Error(`update backup must be outside the target project: ${backup}`);
|
|
2487
|
+
}
|
|
2488
|
+
const ancestors = [];
|
|
2489
|
+
for (let path = backup; ; path = dirname7(path)) {
|
|
2490
|
+
ancestors.unshift(path);
|
|
2491
|
+
if (dirname7(path) === path) break;
|
|
2492
|
+
}
|
|
2493
|
+
for (const path of ancestors) {
|
|
2494
|
+
const info = lstatSync2(path, { throwIfNoEntry: false });
|
|
2495
|
+
if (!info) continue;
|
|
2496
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
2497
|
+
throw new Error(`unsafe update backup directory: ${path}`);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
if (lstatSync2(backup, { throwIfNoEntry: false })) {
|
|
2501
|
+
throw new Error(`update backup directory already exists: ${backup}`);
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
function createDirectories(path, created, mode = 511) {
|
|
2505
|
+
const missing = [];
|
|
2506
|
+
for (let current = path; !lstatSync2(current, { throwIfNoEntry: false }); current = dirname7(current)) {
|
|
2507
|
+
missing.unshift(current);
|
|
2508
|
+
}
|
|
2509
|
+
for (const directory of missing) {
|
|
2510
|
+
mkdirSync4(directory, { mode });
|
|
2511
|
+
created.push(directory);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
function writeBackupFile(path, content) {
|
|
2515
|
+
createDirectories(dirname7(path), [], 448);
|
|
2516
|
+
const fd = openSync(path, "wx", 384);
|
|
2517
|
+
try {
|
|
2518
|
+
writeFileSync6(fd, content);
|
|
2519
|
+
fsyncSync(fd);
|
|
2520
|
+
} finally {
|
|
2521
|
+
closeSync(fd);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
function saveSnapshot(root, paths, backup) {
|
|
2525
|
+
const entries = [];
|
|
2526
|
+
createDirectories(dirname7(backup), [], 448);
|
|
2527
|
+
mkdirSync4(backup, { mode: 448 });
|
|
2528
|
+
for (const path of paths) {
|
|
2529
|
+
const full = join15(root, path);
|
|
2530
|
+
const info = lstatSync2(full, { throwIfNoEntry: false });
|
|
2531
|
+
if (!info) {
|
|
2532
|
+
entries.push({ path, exists: false });
|
|
2533
|
+
continue;
|
|
2534
|
+
}
|
|
2535
|
+
const content = readFileSync11(full);
|
|
2536
|
+
writeBackupFile(join15(backup, "files", path), content);
|
|
2537
|
+
entries.push({
|
|
2538
|
+
path,
|
|
2539
|
+
exists: true,
|
|
2540
|
+
mode: info.mode & 4095,
|
|
2541
|
+
sha256: createHash3("sha256").update(content).digest("hex")
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
writeBackupFile(
|
|
2545
|
+
join15(backup, "snapshot.json"),
|
|
2546
|
+
`${JSON.stringify({ version: 1, root, createdAt: (/* @__PURE__ */ new Date()).toISOString(), entries }, null, 2)}
|
|
2547
|
+
`
|
|
2548
|
+
);
|
|
2549
|
+
return entries;
|
|
2550
|
+
}
|
|
2551
|
+
function errorMessage(error) {
|
|
2552
|
+
return error instanceof Error ? error.message : String(error);
|
|
2553
|
+
}
|
|
2554
|
+
function rollback(root, backup, entries, touched, createdDirectories) {
|
|
2555
|
+
const errors = [];
|
|
2556
|
+
for (const path of [...touched].reverse()) {
|
|
2557
|
+
try {
|
|
2558
|
+
assertManagedPaths(root, [path]);
|
|
2559
|
+
const entry = entries.get(path);
|
|
2560
|
+
const full = join15(root, path);
|
|
2561
|
+
const current = lstatSync2(full, { throwIfNoEntry: false });
|
|
2562
|
+
if (!entry?.exists) {
|
|
2563
|
+
if (current) unlinkSync2(full);
|
|
2564
|
+
continue;
|
|
2565
|
+
}
|
|
2566
|
+
const original = readFileSync11(join15(backup, "files", path));
|
|
2567
|
+
if (!current || !readFileSync11(full).equals(original)) {
|
|
2568
|
+
if (current) unlinkSync2(full);
|
|
2569
|
+
writeFileSync6(full, original, { mode: 384 });
|
|
2570
|
+
}
|
|
2571
|
+
chmodSync2(full, entry.mode);
|
|
2572
|
+
} catch (error) {
|
|
2573
|
+
errors.push(`${path}: ${errorMessage(error)}`);
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
for (const directory of [...createdDirectories].reverse()) {
|
|
2577
|
+
try {
|
|
2578
|
+
if (readdirSync2(directory).length === 0) rmdirSync2(directory);
|
|
2579
|
+
} catch (error) {
|
|
2580
|
+
errors.push(`${directory}: ${errorMessage(error)}`);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
return errors;
|
|
2584
|
+
}
|
|
2585
|
+
function applyUpdateTransaction(root, writes, removals, backupDir) {
|
|
2586
|
+
const paths = [.../* @__PURE__ */ new Set([...removals, ...writes.map((file) => file.path)])];
|
|
2587
|
+
assertManagedPaths(root, paths);
|
|
2588
|
+
const target = canonicalRoot(root);
|
|
2589
|
+
const backup = resolve6(
|
|
2590
|
+
backupDir ?? join15(homedir(), ".local", "state", "vaubaan-harness", "backups", randomUUID())
|
|
2591
|
+
);
|
|
2592
|
+
assertBackupLocation(target, backup);
|
|
2593
|
+
let snapshot;
|
|
2594
|
+
try {
|
|
2595
|
+
snapshot = saveSnapshot(target, paths, backup);
|
|
2596
|
+
} catch (error) {
|
|
2597
|
+
throw new Error(
|
|
2598
|
+
`could not prepare update backup at ${backup}; project unchanged: ${errorMessage(error)}`,
|
|
2599
|
+
{ cause: error }
|
|
2600
|
+
);
|
|
2601
|
+
}
|
|
2602
|
+
const entries = new Map(snapshot.map((entry) => [entry.path, entry]));
|
|
2603
|
+
const touched = /* @__PURE__ */ new Set();
|
|
2604
|
+
const createdDirectories = [];
|
|
2605
|
+
try {
|
|
2606
|
+
assertManagedPaths(target, paths);
|
|
2607
|
+
for (const path of removals) {
|
|
2608
|
+
if (!lstatSync2(join15(target, path), { throwIfNoEntry: false })) continue;
|
|
2609
|
+
touched.add(path);
|
|
2610
|
+
unlinkSync2(join15(target, path));
|
|
2611
|
+
}
|
|
2612
|
+
for (const file of writes) {
|
|
2613
|
+
const full = join15(target, file.path);
|
|
2614
|
+
createDirectories(dirname7(full), createdDirectories);
|
|
2615
|
+
touched.add(file.path);
|
|
2616
|
+
const entry = entries.get(file.path);
|
|
2617
|
+
const mode = file.path === MANIFEST_PATH ? 384 : entry?.exists ? entry.mode : PRIVATE_FILES.has(file.path) ? 384 : 420;
|
|
2618
|
+
if (entry?.exists && file.path === MANIFEST_PATH) chmodSync2(full, mode);
|
|
2619
|
+
writeFileSync6(full, file.content, { mode });
|
|
2620
|
+
if (entry?.exists) chmodSync2(full, mode);
|
|
2621
|
+
}
|
|
2622
|
+
} catch (error) {
|
|
2623
|
+
const failures = rollback(target, backup, entries, touched, createdDirectories);
|
|
2624
|
+
const restoration = failures.length > 0 ? `rollback errors: ${failures.join("; ")}` : "rollback completed";
|
|
2625
|
+
throw new Error(`update failed; backup: ${backup}; ${restoration}: ${errorMessage(error)}`, {
|
|
2626
|
+
cause: error
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
return { backupDir: backup };
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// src/cli/update.ts
|
|
2633
|
+
function currentContent(root, path) {
|
|
2634
|
+
return existsSync13(join16(root, path)) ? readFileSync12(join16(root, path), "utf8") : void 0;
|
|
2635
|
+
}
|
|
2636
|
+
function checkArtifactConflicts(root, previous, writes, removals) {
|
|
2637
|
+
const desired = new Map(writes.map((file) => [file.path, file.content]));
|
|
2638
|
+
for (const path of [...desired.keys(), ...removals]) {
|
|
2639
|
+
if (SHARED_CONFIG_PATHS.includes(path)) continue;
|
|
2640
|
+
const content = currentContent(root, path);
|
|
2641
|
+
if (content === void 0) continue;
|
|
2642
|
+
if (!previous.files.includes(path)) {
|
|
2643
|
+
throw new Error(
|
|
2644
|
+
`existing file outside harness manifest: ${path}; use overrides or --preserve`
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
const hash2 = previous.fileHashes?.[path];
|
|
2648
|
+
if (hash2 && hash2 !== hashContent(content) && content !== desired.get(path)) {
|
|
2649
|
+
throw new Error(`modified harness file: ${path}; move edits to overrides or use --preserve`);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
function planUpdate(args) {
|
|
2654
|
+
const root = resolve7(args.targetDir);
|
|
2655
|
+
const { manifest: previous, sourceVersion, engines, hookEngines } = readUpdateInstallation(root);
|
|
2656
|
+
const preserved = [
|
|
2657
|
+
.../* @__PURE__ */ new Set([...previous.preservedFiles ?? [], ...args.preserveFiles ?? []])
|
|
2658
|
+
].sort();
|
|
2659
|
+
if (preserved.includes(MANIFEST_FILENAME))
|
|
2660
|
+
throw new Error("cannot preserve the harness manifest");
|
|
2661
|
+
if (preserved.some((path) => SHARED_CONFIG_PATHS.includes(path))) {
|
|
2662
|
+
throw new Error(
|
|
2663
|
+
"--preserve applies to generated artifacts; shared configurations use entry-level preservation"
|
|
2664
|
+
);
|
|
2665
|
+
}
|
|
2666
|
+
assertManagedPaths(root, [...previous.files, ...preserved, ...SHARED_CONFIG_PATHS]);
|
|
2667
|
+
const requestedOverrides = args.overridesDir ?? previous.overridesDir;
|
|
2668
|
+
const overridesDir = args.overridesDir ? resolve7(args.overridesDir) : previous.overridesDir ? resolve7(root, previous.overridesDir) : root;
|
|
2669
|
+
if (requestedOverrides && !existsSync13(join16(overridesDir, ".harness-overrides"))) {
|
|
2670
|
+
throw new Error(`recorded overrides are missing: ${overridesDir}/.harness-overrides`);
|
|
2671
|
+
}
|
|
2672
|
+
const hasOverrides = existsSync13(join16(overridesDir, ".harness-overrides"));
|
|
2673
|
+
const disablePath = join16(overridesDir, ".harness-overrides", "disable.yaml");
|
|
2674
|
+
if (existsSync13(disablePath)) {
|
|
2675
|
+
try {
|
|
2676
|
+
DisableYamlSchema.parse(parseYAML(readFileSync12(disablePath, "utf8")));
|
|
2677
|
+
} catch {
|
|
2678
|
+
throw new Error(`invalid override selection: ${disablePath}`);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
const rendered = planInit({
|
|
2682
|
+
targetDir: root,
|
|
2683
|
+
packageDir: args.packageDir,
|
|
2684
|
+
engines,
|
|
2685
|
+
overridesDir: hasOverrides ? overridesDir : void 0,
|
|
2686
|
+
withStopHook: false
|
|
2687
|
+
});
|
|
2688
|
+
const config = planUpdateConfigs(root, previous, hookEngines);
|
|
2689
|
+
const output = [...rendered.output.files, ...config.writes].filter(
|
|
2690
|
+
(file) => !preserved.includes(file.path)
|
|
2691
|
+
);
|
|
2692
|
+
const owned = [
|
|
2693
|
+
.../* @__PURE__ */ new Set([...rendered.output.files.map((file) => file.path), ...config.ownedFiles])
|
|
2694
|
+
].filter((file) => !preserved.includes(file)).sort();
|
|
2695
|
+
const removals = [
|
|
2696
|
+
.../* @__PURE__ */ new Set([
|
|
2697
|
+
...previous.files.filter(
|
|
2698
|
+
(file) => !owned.includes(file) && !SHARED_CONFIG_PATHS.includes(file)
|
|
2699
|
+
),
|
|
2700
|
+
...config.removals
|
|
2701
|
+
])
|
|
2702
|
+
].filter((file) => !preserved.includes(file));
|
|
2703
|
+
assertManagedPaths(root, [...output.map((file) => file.path), ...removals]);
|
|
2704
|
+
checkArtifactConflicts(root, previous, output, removals);
|
|
2705
|
+
const hashes = Object.fromEntries(
|
|
2706
|
+
owned.map((path) => {
|
|
2707
|
+
const content = output.find((file) => file.path === path)?.content ?? currentContent(root, path);
|
|
2708
|
+
if (content === void 0) throw new Error(`missing planned harness file: ${path}`);
|
|
2709
|
+
return [path, hashContent(content)];
|
|
2710
|
+
})
|
|
2711
|
+
);
|
|
2712
|
+
const manifest = {
|
|
2713
|
+
version: MANIFEST_VERSION,
|
|
2714
|
+
harnessVersion: VERSION,
|
|
2715
|
+
engines: [...engines].sort(),
|
|
2716
|
+
renderedAt: previous.renderedAt,
|
|
2717
|
+
files: owned,
|
|
2718
|
+
fileHashes: hashes,
|
|
2719
|
+
stopHookInstalled: hookEngines.length > 0,
|
|
2720
|
+
stopHookEngines: [...hookEngines].sort(),
|
|
2721
|
+
...hasOverrides ? { overridesDir: overridesDir === root ? "." : overridesDir } : {},
|
|
2722
|
+
...preserved.length ? { preservedFiles: preserved } : {},
|
|
2723
|
+
...config.opencodeConfig ? { opencodeConfig: config.opencodeConfig } : {},
|
|
2724
|
+
...config.hookConfigs ? { hookConfigs: config.hookConfigs } : {}
|
|
2725
|
+
};
|
|
2726
|
+
const writes = output.filter((file) => currentContent(root, file.path) !== file.content);
|
|
2727
|
+
const existingRemovals = removals.filter((path) => existsSync13(join16(root, path)));
|
|
2728
|
+
if (writes.length || existingRemovals.length || !isDeepStrictEqual3(previous, manifest)) {
|
|
2729
|
+
manifest.renderedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2730
|
+
writes.push({ path: MANIFEST_FILENAME, content: `${JSON.stringify(manifest, null, 2)}
|
|
2731
|
+
` });
|
|
2732
|
+
}
|
|
2733
|
+
const warnings = [...rendered.output.warnings, ...config.warnings];
|
|
2734
|
+
if (!previous.fileHashes) {
|
|
2735
|
+
warnings.push(
|
|
2736
|
+
"legacy manifest has no file fingerprints: managed artifacts will be regenerated; backup preserves their previous contents"
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
if (preserved.length) warnings.push(`preserved user-owned artifacts: ${preserved.join(", ")}`);
|
|
2740
|
+
if (engines.includes("codex-cli"))
|
|
2741
|
+
warnings.push("ensure .agents/ is ignored by Git (run harness check)");
|
|
2742
|
+
return {
|
|
2743
|
+
writes,
|
|
2744
|
+
removals: existingRemovals,
|
|
2745
|
+
result: {
|
|
2746
|
+
version: VERSION,
|
|
2747
|
+
fromManifestVersion: sourceVersion,
|
|
2748
|
+
dryRun: args.dryRun === true,
|
|
2749
|
+
upToDate: writes.length === 0 && existingRemovals.length === 0,
|
|
2750
|
+
filesToWrite: writes.map((file) => file.path),
|
|
2751
|
+
filesToRemove: existingRemovals,
|
|
2752
|
+
warnings
|
|
2753
|
+
}
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
function updateCommand(args) {
|
|
2757
|
+
const plan = planUpdate(args);
|
|
2758
|
+
if (args.dryRun || plan.result.upToDate) return plan.result;
|
|
2759
|
+
const { backupDir } = applyUpdateTransaction(
|
|
2760
|
+
resolve7(args.targetDir),
|
|
2761
|
+
plan.writes,
|
|
2762
|
+
plan.removals,
|
|
2763
|
+
args.backupDir
|
|
2764
|
+
);
|
|
2765
|
+
return { ...plan.result, backupDir };
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
// src/cli/index.ts
|
|
2769
|
+
function resolvePackageDir() {
|
|
2770
|
+
const here = dirname8(fileURLToPath2(import.meta.url));
|
|
2771
|
+
return resolve8(here, "..");
|
|
2772
|
+
}
|
|
2773
|
+
var program = new Command();
|
|
2774
|
+
program.name("vaubaan-harness").description(
|
|
2775
|
+
"Vaubaan harness \u2014 render skills, commands, and all agent personas into a target directory"
|
|
2776
|
+
).version(VERSION);
|
|
2777
|
+
program.command("init").description(
|
|
2778
|
+
"Render the harness (all agents, skills, commands) into a target directory. Defaults to all engines."
|
|
2779
|
+
).argument("<target-dir>", "directory to render harness into").option(
|
|
2780
|
+
"--engines <list>",
|
|
2781
|
+
"comma-separated subset of engines (claude-code, codex-cli, opencode) \u2014 defaults to all 3"
|
|
2782
|
+
).option("--overrides-dir <dir>", "directory containing .harness-overrides/").option("--package-dir <dir>", "override harness package directory (for dev/test)").option(
|
|
2783
|
+
"--with-mcp <list>",
|
|
2784
|
+
"comma-separated MCP servers to render config for (context7) \u2014 opt-in"
|
|
2785
|
+
).option(
|
|
2786
|
+
"--no-stop-hook",
|
|
2787
|
+
"skip rendering the Stop-hook configs that run lint at end of each agent turn"
|
|
2788
|
+
).action((targetDir, opts) => {
|
|
2789
|
+
const packageDir = opts.packageDir ?? resolvePackageDir();
|
|
2790
|
+
const enginesRaw = opts.engines;
|
|
2791
|
+
const engines = enginesRaw ? enginesRaw.split(",").map((s) => s.trim()) : void 0;
|
|
2792
|
+
const withMcpRaw = opts.withMcp;
|
|
2793
|
+
const mcpServers = withMcpRaw ? withMcpRaw.split(",").map((s) => s.trim()) : void 0;
|
|
2794
|
+
const result = initCommand({
|
|
2795
|
+
targetDir,
|
|
2796
|
+
engines,
|
|
2797
|
+
overridesDir: opts.overridesDir,
|
|
2798
|
+
packageDir,
|
|
2799
|
+
mcpServers,
|
|
2800
|
+
withStopHook: opts.stopHook !== false
|
|
2801
|
+
});
|
|
2802
|
+
process.stdout.write(`rendered ${result.filesWritten.length} files to ${targetDir}
|
|
2803
|
+
`);
|
|
2804
|
+
if (result.staleFilesRemoved.length > 0) {
|
|
2805
|
+
process.stdout.write(
|
|
2806
|
+
`removed ${result.staleFilesRemoved.length} stale files from a previous render
|
|
2807
|
+
`
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
if (result.stopHookInstalled) {
|
|
2811
|
+
process.stdout.write("stop-hook installed (runs lint at end-of-turn)\n");
|
|
2812
|
+
if (result.filesWritten.includes(".codex/hooks.json")) {
|
|
2813
|
+
process.stdout.write(
|
|
2814
|
+
"codex: the hook runs once you trust this project and approve the hook (startup review or /hooks)\n"
|
|
2815
|
+
);
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
for (const w of result.warnings) process.stderr.write(`warning: ${w}
|
|
2819
|
+
`);
|
|
2820
|
+
});
|
|
2821
|
+
program.command("update").description(
|
|
2822
|
+
"Migrate an existing harness installation, retaining its engines and project settings"
|
|
2823
|
+
).argument("[target-dir]", "project directory", ".").option("--dry-run", "preview changes without writing files or backups").option("--json", "print the migration summary as JSON").option("--backup-dir <dir>", "new backup directory outside the project").option("--overrides-dir <dir>", "directory containing .harness-overrides/").option("--preserve <paths>", "comma-separated generated files to leave under user ownership").action((targetDir, opts) => {
|
|
2824
|
+
const result = updateCommand({
|
|
2825
|
+
targetDir,
|
|
2826
|
+
packageDir: resolvePackageDir(),
|
|
2827
|
+
dryRun: opts.dryRun === true,
|
|
2828
|
+
backupDir: opts.backupDir,
|
|
2829
|
+
overridesDir: opts.overridesDir,
|
|
2830
|
+
preserveFiles: opts.preserve?.split(",").map((path) => path.trim())
|
|
2831
|
+
});
|
|
2832
|
+
if (opts.json) {
|
|
2833
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2834
|
+
`);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
if (result.upToDate) process.stdout.write(`harness ${VERSION}: already up to date
|
|
2838
|
+
`);
|
|
2839
|
+
else
|
|
2840
|
+
process.stdout.write(
|
|
2841
|
+
`${result.dryRun ? "preview" : "updated"}: ${result.filesToWrite.length} writes, ${result.filesToRemove.length} removals (harness ${VERSION})
|
|
2842
|
+
`
|
|
2843
|
+
);
|
|
2844
|
+
if (result.backupDir) process.stdout.write(`backup: ${result.backupDir}
|
|
2845
|
+
`);
|
|
2846
|
+
for (const warning of result.warnings) process.stderr.write(`warning: ${warning}
|
|
2847
|
+
`);
|
|
2848
|
+
});
|
|
2849
|
+
program.command("uninstall").description("Remove all files previously written by `init` into a target directory").argument("<target-dir>", "directory to uninstall harness from").action((targetDir) => {
|
|
2850
|
+
const result = uninstallCommand({ targetDir });
|
|
2851
|
+
process.stdout.write(
|
|
2852
|
+
`removed ${result.filesRemoved.length} files and ${result.dirsPruned.length} empty dirs from ${targetDir}
|
|
2853
|
+
`
|
|
2854
|
+
);
|
|
2855
|
+
for (const w of result.warnings) process.stderr.write(`warning: ${w}
|
|
2856
|
+
`);
|
|
2857
|
+
});
|
|
2858
|
+
program.command("check").description("Verify repo layout: 6 pillars present and .gitignore correct").argument("<repo>", "path to repo to check").action((repo) => {
|
|
2859
|
+
const result = checkCommand(repo);
|
|
2860
|
+
if (result.pass) {
|
|
2861
|
+
process.stdout.write("\u2714 layout OK\n");
|
|
2862
|
+
process.exit(0);
|
|
2863
|
+
}
|
|
2864
|
+
for (const issue of result.issues) process.stdout.write(`\u2718 ${issue}
|
|
2865
|
+
`);
|
|
2866
|
+
process.exit(1);
|
|
2867
|
+
});
|
|
2868
|
+
program.command("lint").description("Run all mechanical linters and exit non-zero on errors").option(
|
|
2869
|
+
"--pillar <pillar>",
|
|
2870
|
+
"filter by pillar (spec, decisions, handbook, tests, workflow, quality)"
|
|
2871
|
+
).option("--format <fmt>", "text | json", "text").option("--cwd <dir>", "repo root", ".").action((opts) => {
|
|
2872
|
+
const repoRoot = resolve8(opts.cwd ?? ".");
|
|
2873
|
+
const lintOpts = opts.pillar ? { pillar: opts.pillar } : {};
|
|
2874
|
+
const report = runLinters(repoRoot, lintOpts);
|
|
2875
|
+
if (opts.format === "json") {
|
|
2876
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
2877
|
+
`);
|
|
2878
|
+
} else {
|
|
2879
|
+
process.stdout.write(`${formatReportText(report)}
|
|
2880
|
+
`);
|
|
2881
|
+
}
|
|
2882
|
+
process.exit(report.summary.pass ? 0 : 1);
|
|
2883
|
+
});
|
|
2884
|
+
program.command("render").description("Render harness for preview (no overrides applied). Defaults to all engines.").option(
|
|
2885
|
+
"--engines <list>",
|
|
2886
|
+
"comma-separated subset of engines (claude-code, codex-cli, opencode) \u2014 defaults to all 3"
|
|
2887
|
+
).requiredOption("--out <dir>", "output directory").option("--package-dir <dir>", "override harness package directory").option(
|
|
2888
|
+
"--with-mcp <list>",
|
|
2889
|
+
"comma-separated MCP servers to render config for (context7) \u2014 opt-in"
|
|
2890
|
+
).option("--no-stop-hook", "skip rendering the Stop-hook configs").action((opts) => {
|
|
2891
|
+
const packageDir = opts.packageDir ?? resolvePackageDir();
|
|
2892
|
+
const enginesRaw = opts.engines;
|
|
2893
|
+
const engines = enginesRaw ? enginesRaw.split(",").map((s) => s.trim()) : void 0;
|
|
2894
|
+
const withMcpRaw = opts.withMcp;
|
|
2895
|
+
const mcpServers = withMcpRaw ? withMcpRaw.split(",").map((s) => s.trim()) : void 0;
|
|
2896
|
+
const result = renderCommand({
|
|
2897
|
+
engines,
|
|
2898
|
+
outDir: opts.out,
|
|
2899
|
+
packageDir,
|
|
2900
|
+
mcpServers,
|
|
2901
|
+
withStopHook: opts.stopHook !== false
|
|
2902
|
+
});
|
|
2903
|
+
process.stdout.write(`rendered ${result.filesWritten.length} files to ${opts.out}
|
|
2904
|
+
`);
|
|
2905
|
+
for (const w of result.warnings) process.stderr.write(`warning: ${w}
|
|
2906
|
+
`);
|
|
2907
|
+
});
|
|
2908
|
+
program.command("stop-hook").description(
|
|
2909
|
+
"Stop-hook entrypoint invoked by each engine's hook config \u2014 runs lint and decides whether to inject the report into the running session, terminate, or passthrough"
|
|
2910
|
+
).requiredOption("--engine <engine>", "claude | codex | opencode").option("--cwd <dir>", "repo root", ".").action((opts) => {
|
|
2911
|
+
const result = stopHookCommandFromStdin({
|
|
2912
|
+
engine: opts.engine,
|
|
2913
|
+
cwd: opts.cwd
|
|
2914
|
+
});
|
|
2915
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
2916
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
2917
|
+
process.exit(result.exitCode);
|
|
2918
|
+
});
|
|
2919
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
2920
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
|
|
2921
|
+
`);
|
|
2922
|
+
process.exit(1);
|
|
2923
|
+
});
|
|
2924
|
+
//# sourceMappingURL=index.js.map
|