agent-harness-kit 0.7.0 → 0.9.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.
Files changed (60) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/bin/cli.mjs +26 -0
  4. package/package.json +1 -1
  5. package/src/core/doctor.mjs +47 -0
  6. package/src/core/render-templates.mjs +119 -5
  7. package/src/core/upgrade.mjs +81 -60
  8. package/src/templates/.claude/agents/api-consistency-reviewer.md.vi +37 -0
  9. package/src/templates/.claude/agents/architecture-reviewer.md.vi.hbs +45 -0
  10. package/src/templates/.claude/agents/performance-reviewer.md.vi +39 -0
  11. package/src/templates/.claude/agents/reliability-reviewer.md.vi +42 -0
  12. package/src/templates/.claude/agents/security-reviewer.md.vi +43 -0
  13. package/src/templates/.claude/hooks/hooks.json +46 -0
  14. package/src/templates/.claude/output-styles/harness-terse.md +42 -0
  15. package/src/templates/.claude/settings.json.hbs +2 -1
  16. package/src/templates/.claude/skills/add-adr/SKILL.md.vi +64 -0
  17. package/src/templates/.claude/skills/add-feature/SKILL.md.vi.hbs +50 -0
  18. package/src/templates/.claude/skills/debug-flow/SKILL.md.vi.hbs +42 -0
  19. package/src/templates/.claude/skills/doc-drift-scan/SKILL.md +15 -10
  20. package/src/templates/.claude/skills/doc-drift-scan/SKILL.md.vi +52 -0
  21. package/src/templates/.claude/skills/doc-drift-scan/scripts/scan-paths.mjs +64 -0
  22. package/src/templates/.claude/skills/eval-runner/SKILL.md.vi +59 -0
  23. package/src/templates/.claude/skills/garbage-collection/SKILL.md.hbs +14 -5
  24. package/src/templates/.claude/skills/garbage-collection/SKILL.md.vi.hbs +58 -0
  25. package/src/templates/.claude/skills/garbage-collection/scripts/gc-classify.mjs +77 -0
  26. package/src/templates/.claude/skills/i18n-add-locale/SKILL.md +52 -0
  27. package/src/templates/.claude/skills/i18n-add-locale/SKILL.md.vi +56 -0
  28. package/src/templates/.claude/skills/i18n-add-locale/scripts/locale-scaffold.mjs +120 -0
  29. package/src/templates/.claude/skills/inspect-app/SKILL.md.vi +61 -0
  30. package/src/templates/.claude/skills/inspect-module/SKILL.md.hbs +17 -14
  31. package/src/templates/.claude/skills/inspect-module/SKILL.md.vi.hbs +57 -0
  32. package/src/templates/.claude/skills/inspect-module/scripts/module-summary.mjs +144 -0
  33. package/src/templates/.claude/skills/map-domain/SKILL.md +42 -0
  34. package/src/templates/.claude/skills/map-domain/SKILL.md.vi +42 -0
  35. package/src/templates/.claude/skills/map-domain/scripts/domain-map.mjs +145 -0
  36. package/src/templates/.claude/skills/propose-harness-improvement/SKILL.md.vi +49 -0
  37. package/src/templates/.claude/skills/propose-harness-improvement/scripts/improvement-bundle.mjs +172 -0
  38. package/src/templates/.claude/skills/refactor-feature/SKILL.md +60 -0
  39. package/src/templates/.claude/skills/refactor-feature/SKILL.md.vi +64 -0
  40. package/src/templates/.claude/skills/refactor-feature/scripts/feature-diff.mjs +146 -0
  41. package/src/templates/.claude/skills/review-this-pr/SKILL.md +59 -0
  42. package/src/templates/.claude/skills/review-this-pr/SKILL.md.vi +63 -0
  43. package/src/templates/.claude/skills/review-this-pr/scripts/pr-review-driver.mjs +152 -0
  44. package/src/templates/.claude/skills/structural-test-author/SKILL.md.vi.hbs +50 -0
  45. package/src/templates/.claude/skills/write-skill/SKILL.md.vi +43 -0
  46. package/src/templates/.harness/eval/rubrics/feature-step-done.mjs +148 -0
  47. package/src/templates/.harness/eval/tasks/feature-step-done.answer.md +53 -0
  48. package/src/templates/.harness/eval/tasks/feature-step-done.json +10 -0
  49. package/src/templates/.harness/eval/tasks/feature-step-done.prompt.md +43 -0
  50. package/src/templates/.mcp.json.example +35 -0
  51. package/src/templates/CLAUDE.md.hbs +9 -5
  52. package/src/templates/CLAUDE.md.vi.hbs +9 -5
  53. package/src/templates/scripts/notify-on-block.sh.hbs +73 -0
  54. package/src/templates/scripts/pretooluse-edit-guard.sh.hbs +115 -0
  55. package/src/templates/scripts/session-end.sh.hbs +6 -0
  56. package/src/templates/scripts/session-rollup.mjs +96 -0
  57. package/src/templates/scripts/session-start.sh.hbs +25 -0
  58. package/src/templates/scripts/statusline.mjs +63 -0
  59. package/src/templates/scripts/subagent-stop.sh.hbs +76 -0
  60. package/src/templates/scripts/userprompt-guard.sh.hbs +100 -0
@@ -11,9 +11,9 @@
11
11
  "source": {
12
12
  "source": "github",
13
13
  "repo": "tuanle96/agent-harness-kit",
14
- "ref": "v0.7.0"
14
+ "ref": "v0.9.0"
15
15
  },
16
- "version": "0.7.0",
16
+ "version": "0.9.0",
17
17
  "description": "Solo-dev harness engineering kit — layered architecture, GC ritual, structural tests, review subagents.",
18
18
  "category": "development",
19
19
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-harness-kit",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Solo-dev harness engineering kit — layered architecture, garbage-collection ritual, structural tests, review subagents. Optimized for Claude Code 2.1+.",
5
5
  "author": {
6
6
  "name": "Tuan Le"
package/bin/cli.mjs CHANGED
@@ -48,6 +48,15 @@ program
48
48
  "human language for the CLAUDE.md template (en, vi)",
49
49
  "en",
50
50
  )
51
+ .option(
52
+ "--model <id>",
53
+ "Claude model to pin in .claude/settings.json (e.g. claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5)",
54
+ )
55
+ .option(
56
+ "--with-mcp",
57
+ "copy .mcp.json.example to .mcp.json (enables Playwright + GitHub MCP servers — credentials still required)",
58
+ false,
59
+ )
51
60
  .action(async (opts) => {
52
61
  const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
53
62
  console.log(pc.bold(pc.cyan(`\nagent-harness-kit v${pkg.version}\n`)));
@@ -196,8 +205,25 @@ program
196
205
  installCi,
197
206
  kitVersion: pkg.version,
198
207
  humanLanguage: opts.lang || "en",
208
+ model: opts.model,
199
209
  });
200
210
 
211
+ // --with-mcp: promote the shipped .mcp.json.example to .mcp.json so
212
+ // the user gets a working starting point. Idempotent — if .mcp.json
213
+ // already exists we leave it alone (user owns it after first write).
214
+ if (opts.withMcp) {
215
+ const examplePath = resolve(cwd, ".mcp.json.example");
216
+ const mcpPath = resolve(cwd, ".mcp.json");
217
+ const { existsSync: fsExists } = await import("node:fs");
218
+ const { copyFile } = await import("node:fs/promises");
219
+ if (fsExists(examplePath) && !fsExists(mcpPath)) {
220
+ await copyFile(examplePath, mcpPath);
221
+ result.written.push(".mcp.json (from --with-mcp)");
222
+ } else if (fsExists(mcpPath)) {
223
+ console.log(pc.yellow(` ~ .mcp.json already present — left untouched (--with-mcp skipped overwrite)`));
224
+ }
225
+ }
226
+
201
227
  console.log("");
202
228
  for (const f of result.written) {
203
229
  console.log(` ${pc.green("✓")} ${pc.dim("wrote")} ${f}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-harness-kit",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Solo-dev harness engineering kit for Claude Code. Layered architecture, structural tests, garbage-collection ritual, review subagents — without the enterprise overhead.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,7 @@ import { readFile } from "node:fs/promises";
6
6
  import { resolve } from "node:path";
7
7
  import { execSync } from "node:child_process";
8
8
  import pc from "picocolors";
9
+ import { SUPPORTED_MODELS } from "./render-templates.mjs";
9
10
 
10
11
  function check(name, ok, info = "") {
11
12
  const mark = ok ? pc.green("✓") : pc.red("✗");
@@ -99,6 +100,52 @@ export async function doctor({ cwd, kitVersion }) {
99
100
  }
100
101
  }
101
102
 
103
+ // 4b. MCP probe — warn (don't fail) when .mcp.json is missing but the
104
+ // example file is shipped. Recommended skills (review-this-pr, garbage-
105
+ // collection) work better with Playwright + GitHub MCP servers wired up;
106
+ // running without is fine, just degrades gracefully.
107
+ const mcpPath = resolve(cwd, ".mcp.json");
108
+ const mcpExamplePath = resolve(cwd, ".mcp.json.example");
109
+ const hasMcp = existsSync(mcpPath);
110
+ const hasMcpExample = existsSync(mcpExamplePath);
111
+ if (hasMcp) {
112
+ try {
113
+ const m = JSON.parse(await readFile(mcpPath, "utf8"));
114
+ const count = m.mcpServers ? Object.keys(m.mcpServers).length : 0;
115
+ check(`.mcp.json (${count} server${count === 1 ? "" : "s"})`, count > 0,
116
+ count > 0 ? "" : "no mcpServers configured");
117
+ } catch (e) {
118
+ allOk = false;
119
+ console.log(pc.red(` ✗ .mcp.json is not valid JSON: ${e.message}`));
120
+ }
121
+ } else if (hasMcpExample) {
122
+ console.log(
123
+ ` ${pc.yellow("•")} .mcp.json ${pc.dim("— not enabled (copy .mcp.json.example to .mcp.json to wire up Playwright/GitHub MCP)")}`,
124
+ );
125
+ }
126
+
127
+ // 5. Model pin in .claude/settings.json (B4). Catches obvious typos
128
+ // that would silently no-op in Claude Code.
129
+ const settingsPath = resolve(cwd, ".claude/settings.json");
130
+ if (existsSync(settingsPath)) {
131
+ try {
132
+ const s = JSON.parse(await readFile(settingsPath, "utf8"));
133
+ if (typeof s.model === "string" && s.model.length > 0) {
134
+ if (SUPPORTED_MODELS.has(s.model)) {
135
+ check(`model pin (${s.model})`, true);
136
+ } else {
137
+ allOk = false;
138
+ console.log(
139
+ pc.red(` ✗ model pin in .claude/settings.json — "${s.model}" not recognized.`),
140
+ );
141
+ console.log(
142
+ pc.dim(` Known: ${[...SUPPORTED_MODELS].join(", ")}. Re-run \`agent-harness-kit init --model <id>\`.`),
143
+ );
144
+ }
145
+ }
146
+ } catch { /* covered by settings parse check elsewhere */ }
147
+ }
148
+
102
149
  console.log("");
103
150
  if (!allOk) {
104
151
  process.exit(1);
@@ -23,7 +23,7 @@ const TEMPLATES_ROOT = resolve(__dirname, "..", "templates");
23
23
  // Files that the user is expected to edit — they win every time, even on
24
24
  // fresh init we won't overwrite if they exist. This list is hard-coded
25
25
  // because it's tiny and security-sensitive.
26
- const USER_OWNED_FILES = new Set([
26
+ export const USER_OWNED_FILES = new Set([
27
27
  "CLAUDE.md",
28
28
  "AGENTS.md",
29
29
  "docs/architecture.md",
@@ -35,7 +35,7 @@ const USER_OWNED_FILES = new Set([
35
35
  ]);
36
36
 
37
37
  // Paths that should be made executable after rendering.
38
- const EXEC_BITS = new Set([
38
+ export const EXEC_BITS = new Set([
39
39
  "scripts/dev-up.sh",
40
40
  "scripts/pre-push.sh",
41
41
  "scripts/install-git-hooks.sh",
@@ -48,6 +48,10 @@ const EXEC_BITS = new Set([
48
48
  "scripts/pretooluse-bash-guard.sh",
49
49
  "scripts/pre-compact.sh",
50
50
  "scripts/session-end.sh",
51
+ // v0.9 hook expansion — SubagentStop + PreToolUse(Edit|Write|MultiEdit) + rollup side-car.
52
+ "scripts/subagent-stop.sh",
53
+ "scripts/pretooluse-edit-guard.sh",
54
+ "scripts/session-rollup.mjs",
51
55
  ]);
52
56
 
53
57
  export function registerHelpers() {
@@ -81,6 +85,11 @@ export function registerHelpers() {
81
85
 
82
86
  async function* walk(dir) {
83
87
  const entries = await readdir(dir, { withFileTypes: true });
88
+ // Sort lexically so locale variants (e.g. "SKILL.md.vi.hbs") sort AFTER
89
+ // their masters ("SKILL.md.hbs") and overwrite them via the
90
+ // identical-target writeFile path. Without this, OS readdir order makes
91
+ // locale overrides non-deterministic.
92
+ entries.sort((a, b) => a.name.localeCompare(b.name));
84
93
  for (const e of entries) {
85
94
  const full = join(dir, e.name);
86
95
  if (e.isDirectory()) {
@@ -97,7 +106,21 @@ async function* walk(dir) {
97
106
  // will route it. Default is "en" → uses the suffix-less CLAUDE.md.hbs.
98
107
  export const SUPPORTED_HUMAN_LANGS = new Set(["en", "vi"]);
99
108
 
100
- function buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage }) {
109
+ // SUPPORTED_MODELS IDs the kit accepts via `--model` and writes verbatim
110
+ // into `.claude/settings.json#model`. The kit does NOT try to be a model
111
+ // registry — it just rejects obvious typos before they silently no-op in
112
+ // Claude Code (which falls back to the org default on unknown IDs).
113
+ export const SUPPORTED_MODELS = new Set([
114
+ "claude-opus-4-7",
115
+ "claude-sonnet-4-6",
116
+ "claude-haiku-4-5",
117
+ // Legacy IDs we still accept on upgrade paths.
118
+ "claude-sonnet-4-5",
119
+ "claude-haiku-3-5",
120
+ ]);
121
+ export const DEFAULT_MODEL = "claude-sonnet-4-6";
122
+
123
+ export function buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage, model }) {
101
124
  const installCmd = (() => {
102
125
  if (stack.language === "python") return "pip install -e '.[dev]'";
103
126
  if (stack.language === "go") return "go mod download";
@@ -164,6 +187,7 @@ function buildContext({ projectName, preset, layers, stack, kitVersion, humanLan
164
187
  lintCmd,
165
188
  kitVersion,
166
189
  humanLanguage: humanLanguage || "en",
190
+ model: model || DEFAULT_MODEL,
167
191
  isTypescript: stack.language === "typescript",
168
192
  isPython: stack.language === "python",
169
193
  isGo: stack.language === "go",
@@ -183,7 +207,7 @@ function buildContext({ projectName, preset, layers, stack, kitVersion, humanLan
183
207
  // Decide whether a template path should be rendered for this stack/preset.
184
208
  // Adapter-specific files live under templates/_adapter-<id>/ and are merged
185
209
  // into the root.
186
- function pathForStack(rel, stack, humanLanguage = "en") {
210
+ export function pathForStack(rel, stack, humanLanguage = "en") {
187
211
  // CLAUDE.md locale routing. `CLAUDE.md.hbs` (no language suffix) is the
188
212
  // English default. `CLAUDE.md.<lang>.hbs` is rendered into the same
189
213
  // target path (`CLAUDE.md`) when the user picks that locale. The
@@ -195,6 +219,26 @@ function pathForStack(rel, stack, humanLanguage = "en") {
195
219
  if (fileLang !== humanLanguage) return null;
196
220
  return "CLAUDE.md.hbs"; // canonical target — strip locale suffix
197
221
  }
222
+ // Generic locale routing for .md.<lang>(.hbs)? under .claude/skills/* or
223
+ // .claude/agents/*. Variants sort AFTER masters in walk order, so when
224
+ // both exist the variant overwrites the master via identical writeFile
225
+ // path. Two forms (mirroring locale-scaffold.mjs):
226
+ // Master .md.hbs → variant .md.<lang>.hbs (Handlebars-active)
227
+ // Master .md → variant .md.<lang> (plain copy)
228
+ // Active-locale-only emission: variants for other locales return null.
229
+ const localeVariantHbs = rel.match(/^\.claude\/(?:skills|agents)\/.*\.md\.([a-z]{2,5})\.hbs$/);
230
+ if (localeVariantHbs) {
231
+ if (localeVariantHbs[1] !== humanLanguage) return null;
232
+ return rel.replace(/\.md\.[a-z]{2,5}\.hbs$/, ".md.hbs");
233
+ }
234
+ // "hbs" is excluded because *.md.hbs is the master (Handlebars-active),
235
+ // not a locale variant. Without this guard, "SKILL.md.hbs" would match
236
+ // with locale="hbs" and get nulled out under the en routing path.
237
+ const localeVariantPlain = rel.match(/^\.claude\/(?:skills|agents)\/.*\.md\.([a-z]{2,5})$/);
238
+ if (localeVariantPlain && localeVariantPlain[1] !== "hbs") {
239
+ if (localeVariantPlain[1] !== humanLanguage) return null;
240
+ return rel.replace(/\.md\.[a-z]{2,5}$/, ".md");
241
+ }
198
242
  if (rel.startsWith("_adapter-typescript/")) {
199
243
  const stripped = rel.slice("_adapter-typescript/".length);
200
244
  if (stack.language === "typescript") return stripped;
@@ -243,6 +287,59 @@ function sha256(buf) {
243
287
  return createHash("sha256").update(buf).digest("hex");
244
288
  }
245
289
 
290
+ // Inject a statusLine block into .claude/settings.json. Idempotent: if the
291
+ // existing statusLine already references the kit's script, leave it; otherwise
292
+ // set it to invoke scripts/statusline.mjs via node. Doesn't clobber a
293
+ // user-customised type:"command" entry that points at a different command.
294
+ //
295
+ // Returns {changed, rawContent} for the lockfile bookkeeping (mirrors the
296
+ // mergeHooksIntoSettings contract).
297
+ export async function mergeStatusLineIntoSettings(cwd) {
298
+ const settingsPath = resolve(cwd, ".claude/settings.json");
299
+ const scriptPath = resolve(cwd, "scripts/statusline.mjs");
300
+ if (!existsSync(scriptPath)) return { changed: false, rawContent: "" };
301
+ let settings = {};
302
+ let raw = "";
303
+ if (existsSync(settingsPath)) {
304
+ raw = await readFile(settingsPath, "utf8");
305
+ try {
306
+ settings = JSON.parse(raw);
307
+ } catch {
308
+ throw new Error(
309
+ `mergeStatusLineIntoSettings: ${settingsPath} is not valid JSON`,
310
+ );
311
+ }
312
+ }
313
+ const desired = {
314
+ type: "command",
315
+ command: "node scripts/statusline.mjs",
316
+ };
317
+ // Preserve a user-customised entry if it already points elsewhere. We only
318
+ // inject when statusLine is absent OR explicitly references our script.
319
+ const cur = settings.statusLine;
320
+ if (
321
+ cur &&
322
+ typeof cur === "object" &&
323
+ cur.type === "command" &&
324
+ typeof cur.command === "string" &&
325
+ !/statusline\.mjs/.test(cur.command)
326
+ ) {
327
+ return { changed: false, rawContent: Buffer.from(raw) };
328
+ }
329
+ if (
330
+ cur &&
331
+ typeof cur === "object" &&
332
+ cur.type === desired.type &&
333
+ cur.command === desired.command
334
+ ) {
335
+ return { changed: false, rawContent: Buffer.from(raw) };
336
+ }
337
+ settings.statusLine = desired;
338
+ const out = JSON.stringify(settings, null, 2) + "\n";
339
+ await writeFile(settingsPath, out);
340
+ return { changed: true, rawContent: Buffer.from(out) };
341
+ }
342
+
246
343
  // Merge .claude/hooks/hooks.json#hooks into .claude/settings.json#hooks.
247
344
  // Claude Code only reads hooks from settings.json — a free-standing
248
345
  // hooks.json is ignored by the runtime. Kept as a file because the plugin
@@ -295,6 +392,7 @@ export async function renderAll({
295
392
  installCi,
296
393
  kitVersion,
297
394
  humanLanguage = "en",
395
+ model,
298
396
  }) {
299
397
  registerHelpers();
300
398
  if (!SUPPORTED_HUMAN_LANGS.has(humanLanguage)) {
@@ -302,7 +400,12 @@ export async function renderAll({
302
400
  `Unsupported humanLanguage "${humanLanguage}". Supported: ${[...SUPPORTED_HUMAN_LANGS].join(", ")}`,
303
401
  );
304
402
  }
305
- const ctx = buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage });
403
+ if (model && !SUPPORTED_MODELS.has(model)) {
404
+ throw new Error(
405
+ `Unsupported model "${model}". Supported: ${[...SUPPORTED_MODELS].join(", ")}`,
406
+ );
407
+ }
408
+ const ctx = buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage, model });
306
409
 
307
410
  const written = [];
308
411
  const skipped = [];
@@ -365,6 +468,17 @@ export async function renderAll({
365
468
  }
366
469
  }
367
470
 
471
+ // v0.8 — statusLine injection. Runs after hook merge so the lockfile
472
+ // captures the final settings.json bytes. Idempotent; never clobbers a
473
+ // user-customised entry that points elsewhere.
474
+ if (existsSync(resolve(cwd, "scripts/statusline.mjs"))) {
475
+ const stat = await mergeStatusLineIntoSettings(cwd);
476
+ if (stat.changed) {
477
+ lockfile.files[".claude/settings.json"] = sha256(stat.rawContent);
478
+ written.push(".claude/settings.json (statusLine)");
479
+ }
480
+ }
481
+
368
482
  // Write the lockfile last (after we know the full set of files).
369
483
  const lockTarget = resolve(cwd, ".harness/installed.json");
370
484
  await mkdir(dirname(lockTarget), { recursive: true });
@@ -7,7 +7,7 @@
7
7
  // 3. Never touch USER_OWNED_FILES (CLAUDE.md, docs/architecture.md, etc.).
8
8
  // 4. Print a concise summary and update the lockfile.
9
9
 
10
- import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
10
+ import { readFile, writeFile, mkdir, readdir, chmod } from "node:fs/promises";
11
11
  import { existsSync } from "node:fs";
12
12
  import { resolve, join, relative, dirname } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
@@ -15,7 +15,15 @@ import { createHash } from "node:crypto";
15
15
  import { confirm } from "@inquirer/prompts";
16
16
  import pc from "picocolors";
17
17
  import Handlebars from "handlebars";
18
- import { registerHelpers } from "./render-templates.mjs";
18
+ import {
19
+ registerHelpers,
20
+ pathForStack,
21
+ buildContext,
22
+ mergeHooksIntoSettings,
23
+ USER_OWNED_FILES as USER_OWNED_FROM_RENDERER,
24
+ EXEC_BITS,
25
+ SUPPORTED_HUMAN_LANGS,
26
+ } from "./render-templates.mjs";
19
27
  import { detectStack } from "./detect-stack.mjs";
20
28
 
21
29
  // Sync the two version-pinned fields in harness.config.json after a kit
@@ -101,16 +109,29 @@ export async function ensureWritePermissions(cwd) {
101
109
  const __dirname = dirname(fileURLToPath(import.meta.url));
102
110
  const TEMPLATES_ROOT = resolve(__dirname, "..", "templates");
103
111
 
104
- const USER_OWNED_FILES = new Set([
105
- "CLAUDE.md",
106
- "AGENTS.md",
107
- "docs/architecture.md",
108
- "docs/core-beliefs.md",
109
- "docs/golden-principles.md",
110
- "docs/tech-debt-tracker.md",
111
- "feature_list.json",
112
- "harness.config.json",
113
- ]);
112
+ // Single source of truth lives in render-templates.mjs. Re-exported here under
113
+ // the legacy local name to avoid a wider rename in this module.
114
+ const USER_OWNED_FILES = USER_OWNED_FROM_RENDERER;
115
+
116
+ // Read user preferences from the existing harness.config.json so the rendered
117
+ // templates pick up the user's chosen model + locale instead of falling back
118
+ // to template defaults that would silently overwrite them. Returns soft
119
+ // defaults when the file is missing or invalid; never throws.
120
+ async function readUserPreferences(cwd) {
121
+ const cfgPath = resolve(cwd, "harness.config.json");
122
+ if (!existsSync(cfgPath)) return { humanLanguage: "en", model: undefined };
123
+ try {
124
+ const cfg = JSON.parse(await readFile(cfgPath, "utf8"));
125
+ const humanLanguage = cfg?.claudeMd?.humanLanguage || "en";
126
+ const model = cfg?.models?.main;
127
+ return {
128
+ humanLanguage: SUPPORTED_HUMAN_LANGS.has(humanLanguage) ? humanLanguage : "en",
129
+ model,
130
+ };
131
+ } catch {
132
+ return { humanLanguage: "en", model: undefined };
133
+ }
134
+ }
114
135
 
115
136
  function sha256(buf) {
116
137
  return createHash("sha256").update(buf).digest("hex");
@@ -171,38 +192,33 @@ export async function upgrade({ cwd, kitVersion, yes }) {
171
192
  );
172
193
 
173
194
  const stack = await detectStack(cwd);
174
- const ctx = {
195
+
196
+ // Pick up user-chosen model + locale from harness.config.json so the
197
+ // rendered templates carry them forward instead of falling back to
198
+ // template defaults. Anything missing falls back to safe defaults inside
199
+ // buildContext.
200
+ const { humanLanguage, model } = await readUserPreferences(cwd);
201
+ registerHelpers();
202
+ const ctx = buildContext({
175
203
  projectName: "your-project",
204
+ preset: "generic",
176
205
  layers: ["types", "config", "repo", "service", "runtime", "ui"],
177
- layersJoined: "types → config → repo → service → runtime → ui",
178
- language: stack.language,
179
- framework: stack.framework,
180
- packageManager: stack.packageManager,
181
- isTypescript: stack.language === "typescript",
182
- isPython: stack.language === "python",
183
- isNextjs: stack.framework === "nextjs",
184
- isFastapi: stack.framework === "fastapi",
206
+ stack,
185
207
  kitVersion,
186
- };
208
+ humanLanguage,
209
+ model,
210
+ });
187
211
 
188
- const updates = []; // { rel, action: 'overwrite'|'sidecar'|'skip', reason }
212
+ // { rel, action, reason, content } — rendered content is captured here so
213
+ // the apply step doesn't have to re-walk the template tree (which was the
214
+ // source of the v0.7 bug where rust/go/swift/kotlin adapter prefixes leaked
215
+ // straight into the user's project as literal directory names).
216
+ const updates = [];
189
217
 
190
218
  for await (const abs of walk(TEMPLATES_ROOT)) {
191
219
  const relFromTemplates = relative(TEMPLATES_ROOT, abs).split("\\").join("/");
192
- if (relFromTemplates.startsWith("_adapter-typescript/") && stack.language !== "typescript")
193
- continue;
194
- if (relFromTemplates.startsWith("_adapter-python/") && stack.language !== "python")
195
- continue;
196
- if (relFromTemplates.startsWith("_preset-nextjs/") && stack.framework !== "nextjs")
197
- continue;
198
- if (relFromTemplates.startsWith("_preset-fastapi/") && stack.framework !== "fastapi")
199
- continue;
200
- const stackRel = relFromTemplates
201
- .replace(/^_adapter-typescript\//, "")
202
- .replace(/^_adapter-python\//, "")
203
- .replace(/^_preset-nextjs\//, "")
204
- .replace(/^_preset-fastapi\//, "")
205
- .replace(/^_ci\//, "");
220
+ const stackRel = pathForStack(relFromTemplates, stack, humanLanguage);
221
+ if (stackRel === null) continue;
206
222
  const targetRel = stackRel.endsWith(".hbs")
207
223
  ? stackRel.slice(0, -".hbs".length)
208
224
  : stackRel;
@@ -215,7 +231,6 @@ export async function upgrade({ cwd, kitVersion, yes }) {
215
231
  let newContent;
216
232
  if (abs.endsWith(".hbs")) {
217
233
  const raw = await readFile(abs, "utf8");
218
- registerHelpers();
219
234
  const tpl = Handlebars.compile(raw, { noEscape: true });
220
235
  newContent = tpl(ctx);
221
236
  } else {
@@ -230,7 +245,7 @@ export async function upgrade({ cwd, kitVersion, yes }) {
230
245
  const targetExists = existsSync(targetAbs);
231
246
 
232
247
  if (!targetExists) {
233
- updates.push({ rel: targetRel, action: "overwrite", reason: "new" });
248
+ updates.push({ rel: targetRel, action: "overwrite", reason: "new", content: newContent });
234
249
  continue;
235
250
  }
236
251
  const currentBuf = await readFile(targetAbs);
@@ -241,9 +256,9 @@ export async function upgrade({ cwd, kitVersion, yes }) {
241
256
  continue;
242
257
  }
243
258
  if (currentSha === previousSha) {
244
- updates.push({ rel: targetRel, action: "overwrite", reason: "user-untouched" });
259
+ updates.push({ rel: targetRel, action: "overwrite", reason: "user-untouched", content: newContent });
245
260
  } else {
246
- updates.push({ rel: targetRel, action: "sidecar", reason: "user-modified" });
261
+ updates.push({ rel: targetRel, action: "sidecar", reason: "user-modified", content: newContent });
247
262
  }
248
263
  }
249
264
 
@@ -266,29 +281,35 @@ export async function upgrade({ cwd, kitVersion, yes }) {
266
281
  }
267
282
  }
268
283
 
269
- // Apply.
284
+ // Apply — content was rendered in the first pass; just write it out.
270
285
  for (const u of [...overwrites, ...sidecars]) {
271
- const sourceTplRel = u.rel; // simplified: regenerate
272
- let abs = resolve(TEMPLATES_ROOT, sourceTplRel + ".hbs");
273
- if (!existsSync(abs)) abs = resolve(TEMPLATES_ROOT, sourceTplRel);
274
- if (stack.language === "typescript" && !existsSync(abs))
275
- abs = resolve(TEMPLATES_ROOT, "_adapter-typescript", sourceTplRel);
276
- if (stack.language === "python" && !existsSync(abs))
277
- abs = resolve(TEMPLATES_ROOT, "_adapter-python", sourceTplRel);
278
- if (!existsSync(abs)) continue; // skip — the kit may have removed this file
279
- let content;
280
- if (abs.endsWith(".hbs")) {
281
- const raw = await readFile(abs, "utf8");
282
- const tpl = Handlebars.compile(raw, { noEscape: true });
283
- content = tpl(ctx);
284
- } else {
285
- content = await readFile(abs);
286
- }
287
286
  const targetAbs = resolve(cwd, u.action === "sidecar" ? u.rel + ".harness-new" : u.rel);
288
287
  await mkdir(dirname(targetAbs), { recursive: true });
289
- await writeFile(targetAbs, content);
288
+ await writeFile(targetAbs, u.content);
289
+ if (EXEC_BITS.has(u.rel) && u.action === "overwrite") {
290
+ try {
291
+ await chmod(targetAbs, 0o755);
292
+ } catch {
293
+ // ignore on platforms where chmod is a no-op
294
+ }
295
+ }
290
296
  if (u.action === "overwrite") {
291
- lockfile.files[u.rel] = sha256(typeof content === "string" ? Buffer.from(content) : content);
297
+ lockfile.files[u.rel] = sha256(
298
+ typeof u.content === "string" ? Buffer.from(u.content) : u.content,
299
+ );
300
+ }
301
+ }
302
+
303
+ // Critical fix (v0.7 + idempotent upgrade): merge .claude/hooks/hooks.json
304
+ // into .claude/settings.json#hooks. Claude Code ONLY reads hooks from
305
+ // settings.json — a stand-alone hooks.json is silently ignored. renderAll
306
+ // does this on init; upgrade has to redo it because hooks.json may have
307
+ // changed across versions (and pre-v0.7 installs never had it merged).
308
+ if (existsSync(resolve(cwd, ".claude/hooks/hooks.json"))) {
309
+ const merged = await mergeHooksIntoSettings(cwd);
310
+ if (merged.changed) {
311
+ lockfile.files[".claude/settings.json"] = sha256(merged.rawContent);
312
+ console.log(pc.dim(` ${pc.green("~")} .claude/settings.json (hooks merged)`));
292
313
  }
293
314
  }
294
315
 
@@ -0,0 +1,37 @@
1
+ <!-- LOCALE_TODO: translate body to vi -->
2
+ <!-- Source: .claude/agents/api-consistency-reviewer.md -->
3
+ <!-- Edit only the markdown body — keep frontmatter verbatim so the kit's renderer + Claude Code parse it identically across locales. -->
4
+
5
+ ---
6
+ name: api-consistency-reviewer
7
+ description: Use this agent after adding or modifying any public API endpoint, exported function, CLI command, or RPC handler. Verifies naming, response shape, error format, and versioning conventions match `docs/api-conventions.md` (or the kit's defaults if that file doesn't exist). Read-only.
8
+ tools: Read, Grep, Glob, Bash(git diff:*)
9
+ model: haiku
10
+ ---
11
+
12
+ Compare changed public surfaces against `docs/api-conventions.md` (if absent,
13
+ fall back to: response shape `{ data, error }`, camelCase keys for JS/TS,
14
+ snake_case for Python). Flag:
15
+
16
+ - response-shape drift (e.g. `{ success, data, error }` vs `{ ok, result }`)
17
+ - naming convention violations (camelCase vs snake_case mixing within one
18
+ payload)
19
+ - missing versioning on breaking changes (no `/v2/` prefix, no `deprecated`
20
+ flag)
21
+ - exported symbols without JSDoc / docstring on a NEW public function
22
+ - error response shape that doesn't match existing handlers
23
+
24
+ ## Output format
25
+
26
+ ```
27
+ PASS — public surfaces are consistent
28
+ ```
29
+
30
+ or a numbered fix list:
31
+
32
+ ```
33
+ 1. <path>:<line> — <convention violated> — <fix>
34
+ 2. ...
35
+ ```
36
+
37
+ Do not modify files. Be terse.
@@ -0,0 +1,45 @@
1
+ <!-- LOCALE_TODO: translate body to vi -->
2
+ <!-- Source: .claude/agents/architecture-reviewer.md.hbs -->
3
+ <!-- Edit only the markdown body — keep frontmatter verbatim so the kit's renderer + Claude Code parse it identically across locales. -->
4
+
5
+ ---
6
+ name: architecture-reviewer
7
+ description: Use this agent when the Stop hook surfaces a `multi-layer-review` flag (changes span ≥2 layers in a single domain — mechanical count, not self-judgment), or when a change adds a new domain / modifies imports across module boundaries. Verifies the {{layersJoined}} rule, provider boundaries, and golden-principles.md compliance. Read-only — never modifies files.
8
+ tools: Read, Grep, Glob, Bash({{#if isPython}}python -m harness.structural_test{{else}}npm run harness:check{{/if}}), Bash(git diff:*)
9
+ model: sonnet
10
+ ---
11
+
12
+ You are a senior software architect reviewing a single PR's diff for
13
+ layered-architecture compliance. You are the **inferential sensor** that
14
+ complements the **computational sensor** (the structural test).
15
+
16
+ When invoked:
17
+
18
+ 1. Run `git diff HEAD~1` (or against the PR base) to see exactly what changed.
19
+ 2. Run {{#if isPython}}`python -m harness.structural_test`{{else}}`npm run harness:check`{{/if}} to see deterministic
20
+ violations first. If it fails, your job is to translate the failure into
21
+ a remediation plan, not duplicate it.
22
+ 3. For each changed file: identify which layer it belongs to from
23
+ `harness.config.json`. Flag any cross-layer import that goes "backward"
24
+ or skips a layer.
25
+ 4. Check that any new cross-cutting concern enters via the `providers/`
26
+ interface, not via direct import.
27
+ 5. Check that any new public type is defined in the `types/` layer, not
28
+ inline in a service.
29
+
30
+ ## Output format (always)
31
+
32
+ ```
33
+ ### Architecture review
34
+ **Verdict:** PASS | FAIL | NEEDS-DISCUSSION
35
+ **Layer-correct:** ✅ / ❌
36
+ **Provider-clean:** ✅ / ❌
37
+ **Findings:**
38
+ 1. <path:line> — <description>
39
+ 2. ...
40
+ **Remediation plan:**
41
+ - <specific edit, no rewrites>
42
+ ```
43
+
44
+ Do not modify any files. Do not run tests beyond the structural test. If
45
+ unsure, return NEEDS-DISCUSSION with concrete questions.
@@ -0,0 +1,39 @@
1
+ <!-- LOCALE_TODO: translate body to vi -->
2
+ <!-- Source: .claude/agents/performance-reviewer.md -->
3
+ <!-- Edit only the markdown body — keep frontmatter verbatim so the kit's renderer + Claude Code parse it identically across locales. -->
4
+
5
+ ---
6
+ name: performance-reviewer
7
+ description: Use this agent after adding loops over large collections, database queries, render paths, or anything in a hot path. Catches N+1 queries, missing memoization, accidental quadratic loops, and unindexed sorts. Read-only. Runs on Haiku for speed.
8
+ tools: Read, Grep, Glob
9
+ model: haiku
10
+ ---
11
+
12
+ You are a performance reviewer. Be brief — this runs on Haiku for speed.
13
+
14
+ Check for, in order:
15
+
16
+ 1. **N+1 queries.** Any `for x in xs: db.get(x.id)`-shaped pattern, or
17
+ `await Promise.all(xs.map(async x => db.findOne(...)))` against a database
18
+ with a way to batch.
19
+ 2. **O(n²) loops.** Nested iteration over the same collection without an
20
+ early break or an index.
21
+ 3. **Missing memoization** on a pure expensive function called in a render
22
+ hot path or per-request.
23
+ 4. **Synchronous IO in an async/await context** (`fs.readFileSync`,
24
+ `db.queryBlocking`).
25
+ 5. **Unbounded list growth.** `accumulator.push(...)` in a loop over an
26
+ external feed without a cap.
27
+
28
+ ## Output format
29
+
30
+ For each finding, one line:
31
+
32
+ ```
33
+ <path>:<line> — <pattern> — <suggested fix in ≤ 1 line>
34
+ ```
35
+
36
+ If clean: `PASS — no obvious hot spots`.
37
+
38
+ Be terse. Do not modify files. If a finding is speculative, mark it `(maybe)`
39
+ and explain in ≤ 5 words.