reviewer-harness 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -187,7 +187,7 @@ Expected output when everything is fine:
187
187
 
188
188
  `validate` checks for empty or duplicate rules and scans the config for leaked API keys and tokens — secrets must never be synced into instruction files (they would leak into Git).
189
189
 
190
- **Step 3 — Add the skills you want.** Pick from the 22 bundled skills (full list below):
190
+ **Step 3 — Add the skills you want.** Pick from the 27 bundled skills (full list below):
191
191
 
192
192
  ```bash
193
193
  reviewer-harness skills add safe-editing
@@ -237,12 +237,12 @@ npx reviewer-harness skills add no-hallucination
237
237
  A skill is a playbook: a short, checkable set of rules for one job (safe editing, API security, UI taste, debugging, and so on), distilled from real, reputable sources — not generic advice. Every rule cites its source.
238
238
 
239
239
  ```bash
240
- reviewer-harness skills list # see all 22 bundled skills (+ which are enabled)
240
+ reviewer-harness skills list # see all 27 bundled skills (+ which are enabled)
241
241
  reviewer-harness skills add <name> # install into the project and enable it in the config
242
242
  reviewer-harness skills remove <name> # disable it in the config and remove it
243
243
  ```
244
244
 
245
- All 22 bundled skills (v1.14.0):
245
+ All 27 bundled skills (v1.14.0):
246
246
 
247
247
  | Skill | What it does |
248
248
  |---|---|
@@ -268,6 +268,11 @@ All 22 bundled skills (v1.14.0):
268
268
  | `vengeance-ui` | Modern animated React components from VengeanceUI — 79 copy-paste components across 9 families. |
269
269
  | `brainstorming` | Find out what you actually want — and why — before anything gets built. Design doc first, approval first. |
270
270
  | `writing-plans` | Turn an approved spec into a bite-sized TDD implementation plan. |
271
+ | `fix-team` | Run an 11-agent fix over an audit report's findings — 8 domain fixers plus ub-mode, superpowers, and a verification agent that runs last. Fix plan first, user approves, verification re-checks every fix. |
272
+ | `verify_before_done` | Check defined-and-called plus remote evidence before reporting done, pushed, fixed, or working — never present an untested guess as done. |
273
+ | `instruction_registers` | Name the user's target and action registers before building, pushing, or planning, and ask one clarifying question when they are ambiguous. |
274
+ | `verify_provider_claims` | Check a provider's live documentation or the user's verified setup before asserting its current availability, free tier, pricing, endpoints, or retired status. |
275
+ | `verify_publish_install_surface` | Verify a published version on each surface the user can see (commit/tag, registry, installed file) before claiming it is installed or live. |
271
276
 
272
277
  Enabled skills render below the rules in every instruction file — and sync also generates a **slash command** for each one (`.opencode/commands/<name>.md` and `.claude/commands/<name>.md`), so every enabled skill appears in the `/` menu of OpenCode and Claude, ready to invoke on a specific task: `/safe-editing`, `/no-hallucination`, and so on.
273
278
 
@@ -304,6 +309,14 @@ Four guardrails run before any file is written:
304
309
  3. **BACKUP** — before overwriting an existing file, sync writes a `.bak` copy first.
305
310
  4. **IDEMPOTENT** — if nothing changed, sync touches nothing. Safe to run repeatedly.
306
311
 
312
+ ### Scale limit — instruction files grow linearly with skills
313
+
314
+ Every enabled skill is inlined in full into every instruction file, bundle, and slash command. Measured at audit (22 skills): **266,198 bytes** of skill source → **2,660,559 bytes** of generated output across 124 files (**10×** amplification); `CLAUDE.md` alone is **262,243 bytes** today. Growth is linear: at 10× skills (220), each instruction file would be ≈ 2.6 MB and total output ≈ 26.6 MB. The binding constraint is the AI runtime's context window, not disk.
315
+
316
+ **Target ceiling the project accepts: total generated output ≤ 10 MB, and no single instruction file larger than 1 MB** — roughly 4× today's skills (≈ 80 skills). Two cheap wins already reduce the amplification: shared bundle directories are written once (Freebuff + Antigravity share `.agents/skills`), and each skill's content is built once per `sync` run instead of once per runtime.
317
+
318
+ Beyond that ceiling, the fix is a re-architecture (summaries + lazy-loading skills on demand) — that is **out of scope** for this fix; nothing here shrinks the files, it only documents the limit and the ceiling.
319
+
307
320
  ### Validate + secret scanning
308
321
 
309
322
  `validate` inspects the config before it becomes instructions:
@@ -387,7 +400,7 @@ Requires Node.js 18+ (no dependencies). Run these inside your project — every
387
400
 
388
401
  | Command | What it does |
389
402
  |---|---|
390
- | `reviewer-harness skills list` | List all 22 bundled skills (+ which are enabled) |
403
+ | `reviewer-harness skills list` | List all 27 bundled skills (+ which are enabled) |
391
404
  | `reviewer-harness skills add <name>` | Install a skill into the project and enable it in the config |
392
405
  | `reviewer-harness skills remove <name>` | Disable a skill and remove it |
393
406
  | `reviewer-harness agents list` | List the bundled sub-agents |
@@ -452,7 +465,7 @@ Check that you edited the config in the same folder you are syncing (`--target`
452
465
  5. ✅ Sub-agents and slash commands — `agents add`, `commands add`
453
466
  6. ✅ Polish and publish — v1.0.0, LICENSE, publish-ready for GitHub/npx
454
467
  7. ✅ v1.1.0 — all-English repo, validate-and-report rule, self-governing repo, modular skills, one-liner installers, Docker
455
- 8. ✅ v1.14.0 — the working peak: 22 skills (audit-team + ub-mode, design-taste-frontend, ui-ux-pro-max, vengeance-ui, brainstorming, writing-plans), Qoder as 8th runtime, system-design, nodejs-quality, all fix rounds verified WORKS, CRLF frontmatter fix (Windows sync without manual LF conversion)
468
+ 8. ✅ v1.14.0 — the working peak: 27 skills (audit-team + fix-team, ub-mode, design-taste-frontend, ui-ux-pro-max, vengeance-ui, brainstorming, writing-plans, verification skills), Qoder as 8th runtime, system-design, nodejs-quality, all fix rounds verified WORKS, CRLF frontmatter fix (Windows sync without manual LF conversion), --help/--version flags fixed
456
469
 
457
470
  ## Contributing & Security
458
471
 
package/bin/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // Commands: status | sync | init | validate | memory | skills | agents | commands | global
5
5
 
6
6
  import { parseArgs } from "node:util";
7
- import { existsSync } from "node:fs";
7
+ import { existsSync, readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { loadConfig } from "../lib/config.js";
@@ -17,19 +17,49 @@ import { listBundledSkills, addSkill, removeSkill, loadBundledSkills, skillComma
17
17
  import { listBundledAgents, addAgent } from "../lib/agents.js";
18
18
  import { listBundledCommands, addCommand } from "../lib/commands.js";
19
19
 
20
- const { values, positionals } = parseArgs({
21
- allowPositionals: true,
22
- options: {
23
- "dry-run": { type: "boolean", default: false },
24
- target: { type: "string", default: process.cwd() },
25
- },
26
- });
20
+ // F-B1 (full fix): the top-level parse sits outside main().catch, so
21
+ // an unknown flag used to crash with a raw Node stack trace. Catch it
22
+ // here: clean one-line error + help on stderr, exit 1. helpText() is a
23
+ // hoisted function declaration, so it is callable this early.
24
+ let values;
25
+ let positionals;
26
+ try {
27
+ ({ values, positionals } = parseArgs({
28
+ allowPositionals: true,
29
+ options: {
30
+ "dry-run": { type: "boolean", default: false },
31
+ target: { type: "string", default: process.cwd() },
32
+ help: { type: "boolean", short: "h", default: false },
33
+ version: { type: "boolean", default: false },
34
+ verbose: { type: "boolean", default: false },
35
+ },
36
+ }));
37
+ } catch (err) {
38
+ console.error(`Error: ${err.message}`);
39
+ console.error(helpText());
40
+ process.exit(1);
41
+ }
27
42
  const [command, sub, arg] = positionals;
28
43
  const targetDir = values.target;
29
44
  const dryRun = values["dry-run"];
30
45
 
31
- function help() {
32
- console.log(`reviewer-harness — your own AI harness
46
+ // devops-basics rule 4: handle SIGTERM/SIGINT gracefully — finish the
47
+ // current write, then exit cleanly. Node runs a synchronous write to
48
+ // completion before a signal handler executes, and writeSafe is atomic
49
+ // (temp file + rename), so exiting here can never leave a half-written
50
+ // file behind.
51
+ let shuttingDown = false;
52
+ function handleShutdownSignal(signal) {
53
+ if (shuttingDown) return;
54
+ shuttingDown = true;
55
+ console.error(`\nReceived ${signal} — finishing the current write, then exiting.`);
56
+ process.exit(0);
57
+ }
58
+ process.on("SIGTERM", () => handleShutdownSignal("SIGTERM"));
59
+ process.on("SIGINT", () => handleShutdownSignal("SIGINT"));
60
+
61
+ function helpText() {
62
+ return `reviewer-harness — your own AI harness
33
63
 
34
64
  Usage (run inside your project):
35
65
  reviewer-harness init Create harness.config.json (one-time setup)
@@ -52,7 +82,15 @@ Usage (run inside your project):
52
82
  reviewer-harness memory list List memory notes + their links
53
83
 
54
84
  Every command accepts --target <dir> (default: the current directory).
55
- `);
85
+ `;
86
+ }
87
+
88
+ function help() {
89
+ console.log(helpText());
90
+ }
91
+
92
+ function packageVersion() {
93
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
56
94
  }
57
95
 
58
96
  function cmdStatus() {
@@ -60,6 +98,7 @@ function cmdStatus() {
60
98
  console.log(`Harness : ${config.name} v${config.version}`);
61
99
  console.log(`Target : ${targetDir}`);
62
100
  console.log(`Runtimes:`);
101
+ if (!config.runtimes?.length) console.log(` (none)`);
63
102
  for (const name of config.runtimes) {
64
103
  const adapter = adapters[name];
65
104
  const target = !adapter ? "(no adapter!)"
@@ -72,8 +111,13 @@ function cmdStatus() {
72
111
  }
73
112
 
74
113
  function cmdSync() {
114
+ const config = loadConfig(targetDir);
75
115
  const results = sync({ targetDir, dryRun });
76
116
  if (dryRun) console.log("(dry-run: nothing was written)\n");
117
+ if (!config.runtimes?.length && !config.skills?.length) {
118
+ console.log(`Nothing to sync — no runtimes and no skills are enabled.`);
119
+ console.log(`Run "skills add <name>" or add a runtime to the config to enable something.`);
120
+ }
77
121
  for (const r of results) {
78
122
  if (r.skillCommands) {
79
123
  const written = r.skillCommands.filter((f) => f.written).length;
@@ -106,10 +150,18 @@ function cmdSync() {
106
150
  }
107
151
  }
108
152
  // If the project has a local memory, refresh its index as part of
109
- // sync — so the graph the AI reads is always current.
153
+ // sync — so the graph the AI reads is always current. The index is
154
+ // a secondary artifact, so its failure is a warning, not a failed
155
+ // sync (system-design failure isolation): the sync already succeeded.
110
156
  if (!dryRun && existsSync(memoryDir(targetDir))) {
111
- const stats = buildIndex(targetDir);
112
- console.log(`- memory: index refreshed (${stats.notes} notes, ${stats.edges} links) ✓`);
157
+ try {
158
+ const stats = buildIndex(targetDir);
159
+ console.log(`- memory: index refreshed (${stats.notes} notes, ${stats.edges} links) ✓`);
160
+ } catch (err) {
161
+ console.error(
162
+ `Warning: memory index could not be updated — ${err.message} (sync itself succeeded; run "memory index" to retry.)`
163
+ );
164
+ }
113
165
  }
114
166
  }
115
167
 
@@ -137,7 +189,8 @@ function cmdMemory() {
137
189
  console.log(`- [[${n.id}]] ${n.title}${n.links.length ? ` → ${n.links.join(", ")}` : ""}`);
138
190
  }
139
191
  } else {
140
- console.log(`Usage: memory <init|index|list> [--target <dir>]`);
192
+ console.error(`Usage: memory <init|index|list> [--target <dir>]`);
193
+ process.exitCode = 1;
141
194
  }
142
195
  }
143
196
 
@@ -195,7 +248,8 @@ function cmdSkills() {
195
248
  : `Skill "${arg}" was not enabled.`
196
249
  );
197
250
  } else {
198
- console.log(`Usage: skills <list|add <name>|remove <name>> [--target <dir>]`);
251
+ console.error(`Usage: skills <list|add <name>|remove <name>> [--target <dir>]`);
252
+ process.exitCode = 1;
199
253
  }
200
254
  }
201
255
 
@@ -207,7 +261,8 @@ function cmdAgents() {
207
261
  console.log(`Agent "${arg}" installed:`);
208
262
  for (const f of r.written) console.log(` → ${f}`);
209
263
  } else {
210
- console.log(`Usage: agents <list|add <name>> [--target <dir>]`);
264
+ console.error(`Usage: agents <list|add <name>> [--target <dir>]`);
265
+ process.exitCode = 1;
211
266
  }
212
267
  }
213
268
 
@@ -219,7 +274,8 @@ function cmdCommands() {
219
274
  console.log(`Command "/${arg}" installed:`);
220
275
  for (const f of r.written) console.log(` → ${f}`);
221
276
  } else {
222
- console.log(`Usage: commands <list|add <name>> [--target <dir>]`);
277
+ console.error(`Usage: commands <list|add <name>> [--target <dir>]`);
278
+ process.exitCode = 1;
223
279
  }
224
280
  }
225
281
 
@@ -251,15 +307,21 @@ function cmdGlobal() {
251
307
  if (dryRun) console.log(`- ${runtime} (global): ${pending} would be written/updated, ${unchanged} unchanged → ${where}`);
252
308
  else console.log(`- ${runtime} (global): ${written} written ✓, ${unchanged} unchanged → ${where}`);
253
309
  }
254
- console.log(
255
- `\nDone: ${skills.map((s) => "/" + s.name).join(", ")} are now available in EVERY project.` +
256
- `\n(OpenCode + Claude: "/" menu commands. Freebuff, Copilot CLI, Antigravity, Qoder: Agent Skills bundles.)` +
257
- `\n(Per-project rules still come from "sync" inside a project.)`
258
- );
310
+ if (dryRun) {
311
+ console.log(`Dry run complete — nothing was written. Run without --dry-run to install.`);
312
+ } else {
313
+ console.log(
314
+ `\nDone: ${skills.map((s) => "/" + s.name).join(", ")} are now available in EVERY project.` +
315
+ `\n(OpenCode + Claude: "/" menu commands. Freebuff, Copilot CLI, Antigravity, Qoder: Agent Skills bundles.)` +
316
+ `\n(Per-project rules still come from "sync" inside a project.)`
317
+ );
318
+ }
259
319
  }
260
320
 
261
321
  async function main() {
262
- if (!command || command === "status") cmdStatus();
322
+ if (values.help) help();
323
+ else if (values.version) console.log(`reviewer-harness ${packageVersion()}`);
324
+ else if (!command || command === "status") cmdStatus();
263
325
  else if (command === "sync") cmdSync();
264
326
  else if (command === "init") cmdInit();
265
327
  else if (command === "validate") cmdValidate();
@@ -268,10 +330,16 @@ async function main() {
268
330
  else if (command === "agents") cmdAgents();
269
331
  else if (command === "commands") cmdCommands();
270
332
  else if (command === "global") cmdGlobal();
271
- else help();
333
+ else if (command === "help") help();
334
+ else {
335
+ console.error(`Unknown command "${command}".`);
336
+ console.error(helpText());
337
+ process.exitCode = 1;
338
+ }
272
339
  }
273
340
 
274
341
  main().catch((err) => {
275
342
  console.error("Error:", err.message);
343
+ if (values.verbose) console.error(err.stack);
276
344
  process.exit(1);
277
345
  });
@@ -9,6 +9,10 @@
9
9
  //
10
10
  // Like Freebuff and Copilot CLI, Antigravity is skills-only here:
11
11
  // it exports skillBundleDir() instead of targetFile()/renderInstructions().
12
+ //
13
+ // NOTE: this directory is shared with Freebuff (which returns the
14
+ // same ".agents/skills"). sync() writes a shared bundle directory
15
+ // once per run — the later runtime reports it as deduped (F-S44).
12
16
 
13
17
  export function skillBundleDir() {
14
18
  return ".agents/skills";
@@ -3,26 +3,12 @@
3
3
  // Claude Code reads CLAUDE.md in the project root at every session.
4
4
  // It is the best-known instruction file among AI coding tools.
5
5
 
6
+ import { renderInstructions as renderShared } from "./render.js";
7
+
6
8
  export function targetFile() {
7
9
  return "CLAUDE.md";
8
10
  }
9
11
 
10
12
  export function renderInstructions(config) {
11
- return [
12
- `# ${config.name} — harness instructions`,
13
- ``,
14
- `> This file is generated by your harness. Do not edit it directly —`,
15
- `> change harness.config.json and sync again.`,
16
- ``,
17
- // The "why" — the problem the rules solve, synced along with them.
18
- ...(config.problem
19
- ? [`## The problem this solves`, ``, config.problem, ``]
20
- : []),
21
- `## Rules — the AI must never break these`,
22
- ``,
23
- ...config.rules.map((rule, i) => `${i + 1}. ${rule}`),
24
- ``,
25
- // Skills (Lesson 4): rendered after the rules.
26
- ...(config.skillSections ?? []).flatMap((s) => [s, ``]),
27
- ].join("\n");
13
+ return renderShared(config, { numbered: true });
28
14
  }
@@ -10,6 +10,10 @@
10
10
  // Freebuff has no single instruction file like CLAUDE.md, so this
11
11
  // adapter is skills-only: it exports skillBundleDir() instead of
12
12
  // targetFile()/renderInstructions().
13
+ //
14
+ // NOTE: this directory is shared with Antigravity (which returns the
15
+ // same ".agents/skills"). sync() writes a shared bundle directory
16
+ // once per run — the later runtime reports it as deduped (F-S44).
13
17
 
14
18
  export function skillBundleDir() {
15
19
  return ".agents/skills";
@@ -5,26 +5,12 @@
5
5
  // renderInstructions) — only the target file and a little flavor
6
6
  // differ. That is the power of the adapter pattern.
7
7
 
8
+ import { renderInstructions as renderShared } from "./render.js";
9
+
8
10
  export function targetFile() {
9
11
  return "GEMINI.md";
10
12
  }
11
13
 
12
14
  export function renderInstructions(config) {
13
- return [
14
- `# ${config.name} — harness instructions`,
15
- ``,
16
- `> This file is generated by your harness. Do not edit it directly —`,
17
- `> change harness.config.json and sync again.`,
18
- ``,
19
- // The "why" — the problem the rules solve, synced along with them.
20
- ...(config.problem
21
- ? [`## The problem this solves`, ``, config.problem, ``]
22
- : []),
23
- `## Rules — the AI must never break these`,
24
- ``,
25
- ...config.rules.map((rule) => `- ${rule}`),
26
- ``,
27
- // Skills (Lesson 4): rendered after the rules.
28
- ...(config.skillSections ?? []).flatMap((s) => [s, ``]),
29
- ].join("\n");
15
+ return renderShared(config, { numbered: false });
30
16
  }
@@ -7,36 +7,12 @@
7
7
  //
8
8
  // OpenCode reads `.opencode/instructions.md` in your project.
9
9
 
10
+ import { renderInstructions as renderShared } from "./render.js";
11
+
10
12
  export function targetFile() {
11
13
  return ".opencode/instructions.md";
12
14
  }
13
15
 
14
16
  export function renderInstructions(config) {
15
- const lines = [];
16
- lines.push(`# ${config.name} — harness instructions`);
17
- lines.push(``);
18
- lines.push(
19
- `> This file is generated by your harness. Do not edit it directly —`,
20
- `> change harness.config.json and sync again.`
21
- );
22
- lines.push(``);
23
- // The "why" — the problem the rules solve, synced along with them.
24
- if (config.problem) {
25
- lines.push(`## The problem this solves`);
26
- lines.push(``);
27
- lines.push(config.problem);
28
- lines.push(``);
29
- }
30
- lines.push(`## Rules — the AI must never break these`);
31
- lines.push(``);
32
- config.rules.forEach((rule, i) => {
33
- lines.push(`${i + 1}. ${rule}`);
34
- });
35
- lines.push(``);
36
- // Skills (Lesson 4): rendered after the rules.
37
- for (const section of config.skillSections ?? []) {
38
- lines.push(section);
39
- lines.push(``);
40
- }
41
- return lines.join("\n");
17
+ return renderShared(config, { numbered: true });
42
18
  }
@@ -0,0 +1,29 @@
1
+ // lib/adapters/render.js — the ONE shared instruction-file renderer.
2
+ //
3
+ // Every adapter renders the same shape: header, generated-file notice,
4
+ // optional problem section, numbered-or-bulleted rules, skill sections.
5
+ // The only per-adapter difference is the list marker, chosen here by
6
+ // `numbered` (true → `1. …`, false → `- …`). Adapters are thin wrappers
7
+ // over this function; see lib/adapters/claude.js, gemini.js, opencode.js.
8
+
9
+ export function renderInstructions(config, { numbered }) {
10
+ return [
11
+ `# ${config.name} — harness instructions`,
12
+ ``,
13
+ `> This file is generated by your harness. Do not edit it directly —`,
14
+ `> change harness.config.json and sync again.`,
15
+ ``,
16
+ // The "why" — the problem the rules solve, synced along with them.
17
+ ...(config.problem
18
+ ? [`## The problem this solves`, ``, config.problem, ``]
19
+ : []),
20
+ `## Rules — the AI must never break these`,
21
+ ``,
22
+ ...config.rules.map((rule, i) =>
23
+ numbered ? `${i + 1}. ${rule}` : `- ${rule}`
24
+ ),
25
+ ``,
26
+ // Skills (Lesson 4): rendered after the rules.
27
+ ...(config.skillSections ?? []).flatMap((s) => [s, ``]),
28
+ ].join("\n");
29
+ }
package/lib/agents.js CHANGED
@@ -10,10 +10,12 @@
10
10
  // harness), and `agents add` generates both formats. The same pattern
11
11
  // as the rules: one source, every runtime.
12
12
 
13
- import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
13
+ import { readdirSync, readFileSync, existsSync, mkdirSync } from "node:fs";
14
14
  import { join, dirname } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { parseFrontmatter } from "./frontmatter.js";
17
+ import { assertValidName, assertInside, warnIfModified, yamlQuote } from "./skills.js";
18
+ import { writeSafe } from "./sync.js";
17
19
 
18
20
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
19
21
  const BUNDLED_DIR = join(ROOT, "agents");
@@ -31,7 +33,9 @@ export function listBundledAgents() {
31
33
 
32
34
  // Install an agent into the project: one master, two runtime files.
33
35
  export function addAgent(name, targetDir) {
36
+ assertValidName(name, "agent");
34
37
  const src = join(BUNDLED_DIR, `${name}.md`);
38
+ assertInside(BUNDLED_DIR, src);
35
39
  if (!existsSync(src)) {
36
40
  const available = listBundledAgents().map((a) => a.name).join(", ");
37
41
  throw new Error(`No bundled agent named "${name}". Available: ${available}`);
@@ -43,19 +47,21 @@ export function addAgent(name, targetDir) {
43
47
  const opencodeDir = join(targetDir, ".opencode", "agents");
44
48
  mkdirSync(opencodeDir, { recursive: true });
45
49
  const opencodeFile = join(opencodeDir, `${name}.md`);
46
- writeFileSync(
47
- opencodeFile,
48
- `---\ndescription: ${description}\nmode: subagent\n---\n\n${body.trim()}\n`
49
- );
50
+ assertInside(opencodeDir, opencodeFile);
51
+ const opencodeContent =
52
+ `---\ndescription: ${yamlQuote(description)}\nmode: subagent\n---\n\n${body.trim()}\n`;
53
+ warnIfModified(opencodeFile, opencodeContent);
54
+ writeSafe(opencodeFile, opencodeContent);
50
55
  written.push(opencodeFile);
51
56
 
52
57
  const claudeDir = join(targetDir, ".claude", "agents");
53
58
  mkdirSync(claudeDir, { recursive: true });
54
59
  const claudeFile = join(claudeDir, `${name}.md`);
55
- writeFileSync(
56
- claudeFile,
57
- `---\nname: ${name}\ndescription: ${description}\n---\n\n${body.trim()}\n`
58
- );
60
+ assertInside(claudeDir, claudeFile);
61
+ const claudeContent =
62
+ `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---\n\n${body.trim()}\n`;
63
+ warnIfModified(claudeFile, claudeContent);
64
+ writeSafe(claudeFile, claudeContent);
59
65
  written.push(claudeFile);
60
66
 
61
67
  return { written };
package/lib/commands.js CHANGED
@@ -10,10 +10,12 @@
10
10
  // $ARGUMENTS is replaced by the runtime with whatever you type
11
11
  // after the command.
12
12
 
13
- import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
13
+ import { readdirSync, readFileSync, existsSync, mkdirSync } from "node:fs";
14
14
  import { join, dirname } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { parseFrontmatter } from "./frontmatter.js";
17
+ import { assertValidName, assertInside, warnIfModified, yamlQuote } from "./skills.js";
18
+ import { writeSafe } from "./sync.js";
17
19
 
18
20
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
19
21
  const BUNDLED_DIR = join(ROOT, "commands");
@@ -30,14 +32,16 @@ export function listBundledCommands() {
30
32
  }
31
33
 
32
34
  export function addCommand(name, targetDir) {
35
+ assertValidName(name, "command");
33
36
  const src = join(BUNDLED_DIR, `${name}.md`);
37
+ assertInside(BUNDLED_DIR, src);
34
38
  if (!existsSync(src)) {
35
39
  const available = listBundledCommands().map((c) => c.name).join(", ");
36
40
  throw new Error(`No bundled command named "${name}". Available: ${available}`);
37
41
  }
38
42
  const { data, body } = parseFrontmatter(readFileSync(src, "utf8"));
39
43
  const description = data.description ?? name;
40
- const content = `---\ndescription: ${description}\n---\n\n${body.trim()}\n`;
44
+ const content = `---\ndescription: ${yamlQuote(description)}\n---\n\n${body.trim()}\n`;
41
45
  const written = [];
42
46
 
43
47
  for (const dir of [
@@ -46,7 +50,9 @@ export function addCommand(name, targetDir) {
46
50
  ]) {
47
51
  mkdirSync(dir, { recursive: true });
48
52
  const file = join(dir, `${name}.md`);
49
- writeFileSync(file, content);
53
+ assertInside(dir, file);
54
+ warnIfModified(file, content);
55
+ writeSafe(file, content);
50
56
  written.push(file);
51
57
  }
52
58
  return { written };
package/lib/config.js CHANGED
@@ -21,8 +21,21 @@ export function loadConfig(dir = ROOT) {
21
21
  let config;
22
22
  try {
23
23
  config = JSON.parse(readFileSync(path, "utf8"));
24
- } catch {
25
- throw new Error(`Config is not valid JSON: ${path}`);
24
+ } catch (err) {
25
+ throw new Error(
26
+ `Config is not valid JSON: ${path}: ${err.message}\nCheck for a trailing comma or other syntax error.`
27
+ );
28
+ }
29
+
30
+ // A valid-JSON file is not necessarily a config: null, an array, or a
31
+ // bare string must fail here with a clear error — never a raw
32
+ // TypeError from the `.name` check below (F-W20, Review Focus #5).
33
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
34
+ throw new Error(
35
+ `Config must be an object, not ${
36
+ config === null ? "null" : Array.isArray(config) ? "an array" : `a ${typeof config}`
37
+ }.`
38
+ );
26
39
  }
27
40
 
28
41
  // Light structural validation — the harness rejects a broken spec.
@@ -7,13 +7,31 @@ export function parseFrontmatter(text) {
7
7
  // use CR (\r). Without this, the "---\n" checks below fail on CRLF input and
8
8
  // the whole frontmatter is silently dropped (name/description lost).
9
9
  text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
10
+ // A byte-order mark in front of the opening delimiter is not part of
11
+ // the frontmatter — strip it so BOM-prefixed files parse.
12
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
10
13
  if (!text.startsWith("---\n")) return { data: {}, body: text };
11
- const end = text.indexOf("\n---\n", 4);
12
- if (end === -1) return { data: {}, body: text };
14
+ // Find the closing delimiter as a whole line, so a file ending exactly
15
+ // at "---" (EOF, no trailing newline) still parses, and so does empty
16
+ // frontmatter ("---\n---\nbody").
17
+ const lines = text.split("\n");
18
+ let close = -1;
19
+ for (let i = 1; i < lines.length; i++) {
20
+ if (lines[i] === "---") {
21
+ close = i;
22
+ break;
23
+ }
24
+ }
25
+ if (close === -1) {
26
+ console.warn(
27
+ "Warning: frontmatter block has no closing '---' — treating the whole file as body."
28
+ );
29
+ return { data: {}, body: text };
30
+ }
13
31
  const data = {};
14
- for (const line of text.slice(4, end).split("\n")) {
32
+ for (const line of lines.slice(1, close)) {
15
33
  const idx = line.indexOf(":");
16
34
  if (idx > 0) data[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
17
35
  }
18
- return { data, body: text.slice(end + 5) };
36
+ return { data, body: lines.slice(close + 1).join("\n") };
19
37
  }
package/lib/init.js CHANGED
@@ -3,12 +3,14 @@
3
3
  // Creates harness.config.json from the template if none exists yet.
4
4
  // Never overwrites an existing config — safe by default.
5
5
 
6
- import { writeFileSync, existsSync, mkdirSync } from "node:fs";
6
+ import { writeFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
 
9
+ const TOOL_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
+
9
11
  const TEMPLATE = {
10
12
  name: "reviewer-harness",
11
- version: "1.0.0",
13
+ version: TOOL_VERSION,
12
14
  problem:
13
15
  "The problem — an AI with no guardrails: it sometimes breaks existing code it was never asked to touch, and hallucinates — inventing functions, libraries, or output that do not actually exist. These rules are the guardrails against that.",
14
16
  runtimes: ["opencode", "claude", "gemini", "cline", "freebuff", "copilot", "antigravity", "qoder"],
package/lib/memory.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  mkdirSync,
26
26
  } from "node:fs";
27
27
  import { join } from "node:path";
28
+ import { writeSafe } from "./sync.js";
28
29
 
29
30
  export function memoryDir(targetDir) {
30
31
  return join(targetDir, "memory");
@@ -111,7 +112,10 @@ export function buildIndex(targetDir) {
111
112
  const notes = readNotes(targetDir);
112
113
  const graph = buildGraph(notes);
113
114
 
114
- writeFileSync(
115
+ // Atomic writes (F-W16 memory half): graph.json and INDEX.md go
116
+ // through writeSafe — temp file + fsync + rename, rotating backups —
117
+ // so a kill mid-index cannot leave the pair half-updated.
118
+ writeSafe(
115
119
  join(dir, "graph.json"),
116
120
  JSON.stringify({ generated_by: "reviewer-harness", ...graph }, null, 2) + "\n"
117
121
  );
@@ -147,7 +151,7 @@ export function buildIndex(targetDir) {
147
151
  lines.push(``);
148
152
  lines.push(`Graph: ${graph.nodes.length} nodes, ${graph.edges.length} edges.`);
149
153
  lines.push(``);
150
- writeFileSync(join(dir, "INDEX.md"), lines.join("\n"));
154
+ writeSafe(join(dir, "INDEX.md"), lines.join("\n"));
151
155
 
152
156
  return {
153
157
  notes: notes.length,