agent-harness-kit 0.3.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/.claude-plugin/marketplace.json +27 -0
- package/.claude-plugin/plugin.json +25 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/bin/cli.mjs +261 -0
- package/package.json +64 -0
- package/src/core/detect-stack.mjs +181 -0
- package/src/core/doctor.mjs +106 -0
- package/src/core/patch-package-json.mjs +53 -0
- package/src/core/render-templates.mjs +277 -0
- package/src/core/upgrade.mjs +274 -0
- package/src/templates/.claude/agents/api-consistency-reviewer.md +33 -0
- package/src/templates/.claude/agents/architecture-reviewer.md.hbs +41 -0
- package/src/templates/.claude/agents/performance-reviewer.md +35 -0
- package/src/templates/.claude/agents/reliability-reviewer.md +38 -0
- package/src/templates/.claude/agents/security-reviewer.md +39 -0
- package/src/templates/.claude/hooks/hooks.json.hbs +39 -0
- package/src/templates/.claude/settings.json.hbs +25 -0
- package/src/templates/.claude/skills/add-adr/SKILL.md +60 -0
- package/src/templates/.claude/skills/add-feature/SKILL.md.hbs +50 -0
- package/src/templates/.claude/skills/debug-flow/SKILL.md.hbs +38 -0
- package/src/templates/.claude/skills/doc-drift-scan/SKILL.md +43 -0
- package/src/templates/.claude/skills/eval-runner/SKILL.md +55 -0
- package/src/templates/.claude/skills/garbage-collection/SKILL.md.hbs +49 -0
- package/src/templates/.claude/skills/inspect-app/SKILL.md +57 -0
- package/src/templates/.claude/skills/inspect-module/SKILL.md.hbs +53 -0
- package/src/templates/.claude/skills/propose-harness-improvement/SKILL.md +43 -0
- package/src/templates/.claude/skills/structural-test-author/SKILL.md.hbs +46 -0
- package/src/templates/.claude/skills/write-skill/SKILL.md +39 -0
- package/src/templates/CLAUDE.md.hbs +70 -0
- package/src/templates/_adapter-python/.importlinter +14 -0
- package/src/templates/_adapter-python/harness/__init__.py +0 -0
- package/src/templates/_adapter-python/harness/eval_runner.py +281 -0
- package/src/templates/_adapter-python/harness/structural_test.py +195 -0
- package/src/templates/_adapter-typescript/.dependency-cruiser.cjs +27 -0
- package/src/templates/_adapter-typescript/eslint.config.mjs +38 -0
- package/src/templates/_adapter-typescript/harness/eval-runner.mjs +322 -0
- package/src/templates/_adapter-typescript/harness/structural-test.mjs +125 -0
- package/src/templates/_ci/.github/workflows/eval-nightly.yml +59 -0
- package/src/templates/_ci/.github/workflows/harness.yml +55 -0
- package/src/templates/docs/adr/0001-use-agent-harness-kit.md.hbs +56 -0
- package/src/templates/docs/agent-failures.md +25 -0
- package/src/templates/docs/architecture.md.hbs +47 -0
- package/src/templates/docs/core-beliefs.md.hbs +41 -0
- package/src/templates/docs/golden-principles.md.hbs +80 -0
- package/src/templates/docs/tech-debt-tracker.md +30 -0
- package/src/templates/feature_list.json.hbs +29 -0
- package/src/templates/harness.config.json.hbs +40 -0
- package/src/templates/scripts/dev-up.sh.hbs +51 -0
- package/src/templates/scripts/harness-report.mjs +189 -0
- package/src/templates/scripts/install-git-hooks.sh +18 -0
- package/src/templates/scripts/pre-push.sh +21 -0
- package/src/templates/scripts/precompletion-checklist.sh.hbs +99 -0
- package/src/templates/scripts/structural-test-on-edit.sh.hbs +53 -0
- package/src/templates/scripts/telemetry-on-skill.sh +26 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// detect-stack.mjs — auto-detect the user's stack from on-disk signals.
|
|
2
|
+
// Priority order matters: monorepo signals > framework signals > language signals.
|
|
3
|
+
|
|
4
|
+
import { readFile, access, readdir } from "node:fs/promises";
|
|
5
|
+
import { resolve, basename } from "node:path";
|
|
6
|
+
|
|
7
|
+
async function exists(p) {
|
|
8
|
+
try {
|
|
9
|
+
await access(p);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function readJsonSafe(p) {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(p, "utf8");
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readTextSafe(p) {
|
|
26
|
+
try {
|
|
27
|
+
return await readFile(p, "utf8");
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function detectStack(cwd) {
|
|
34
|
+
const result = {
|
|
35
|
+
language: "generic",
|
|
36
|
+
framework: null,
|
|
37
|
+
packageManager: "npm",
|
|
38
|
+
monorepo: false,
|
|
39
|
+
polyglotSignals: [], // list of detected language signals on a polyglot monorepo
|
|
40
|
+
appCandidates: [], // discovered subdirectories that look like individual apps
|
|
41
|
+
suggestedName: basename(cwd),
|
|
42
|
+
suggestedPreset: "generic",
|
|
43
|
+
availablePresets: ["generic"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Monorepo signals.
|
|
47
|
+
if (await exists(resolve(cwd, "pnpm-workspace.yaml"))) {
|
|
48
|
+
result.packageManager = "pnpm";
|
|
49
|
+
result.monorepo = true;
|
|
50
|
+
}
|
|
51
|
+
if (await exists(resolve(cwd, "turbo.json"))) {
|
|
52
|
+
result.monorepo = true;
|
|
53
|
+
}
|
|
54
|
+
if (await exists(resolve(cwd, "lerna.json"))) {
|
|
55
|
+
result.monorepo = true;
|
|
56
|
+
}
|
|
57
|
+
if (await exists(resolve(cwd, "yarn.lock"))) {
|
|
58
|
+
result.packageManager = "yarn";
|
|
59
|
+
} else if (await exists(resolve(cwd, "pnpm-lock.yaml"))) {
|
|
60
|
+
result.packageManager = "pnpm";
|
|
61
|
+
} else if (await exists(resolve(cwd, "bun.lockb"))) {
|
|
62
|
+
result.packageManager = "bun";
|
|
63
|
+
} else if (await exists(resolve(cwd, "package-lock.json"))) {
|
|
64
|
+
result.packageManager = "npm";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Polyglot probe — only relevant when no root package.json picks a clear
|
|
68
|
+
// primary language. Walks 1 level deep into common monorepo dirs.
|
|
69
|
+
await probePolyglot(cwd, result);
|
|
70
|
+
|
|
71
|
+
// JavaScript/TypeScript.
|
|
72
|
+
const pkg = await readJsonSafe(resolve(cwd, "package.json"));
|
|
73
|
+
if (pkg) {
|
|
74
|
+
result.language = "typescript";
|
|
75
|
+
if (pkg.name) result.suggestedName = pkg.name.replace(/^@[^/]+\//, "");
|
|
76
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
77
|
+
if (deps.next) {
|
|
78
|
+
result.framework = "nextjs";
|
|
79
|
+
result.suggestedPreset = "nextjs";
|
|
80
|
+
result.availablePresets = ["nextjs", "generic"];
|
|
81
|
+
} else if (deps["@nestjs/core"]) {
|
|
82
|
+
result.framework = "nestjs";
|
|
83
|
+
result.suggestedPreset = "node-api";
|
|
84
|
+
result.availablePresets = ["node-api", "generic"];
|
|
85
|
+
} else if (deps.fastify) {
|
|
86
|
+
result.framework = "fastify";
|
|
87
|
+
result.suggestedPreset = "node-api";
|
|
88
|
+
result.availablePresets = ["node-api", "generic"];
|
|
89
|
+
} else if (deps.express) {
|
|
90
|
+
result.framework = "express";
|
|
91
|
+
result.suggestedPreset = "node-api";
|
|
92
|
+
result.availablePresets = ["node-api", "generic"];
|
|
93
|
+
} else {
|
|
94
|
+
result.framework = "node";
|
|
95
|
+
result.suggestedPreset = "generic";
|
|
96
|
+
result.availablePresets = ["generic"];
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Python.
|
|
102
|
+
const pyproject = await readTextSafe(resolve(cwd, "pyproject.toml"));
|
|
103
|
+
if (pyproject) {
|
|
104
|
+
result.language = "python";
|
|
105
|
+
result.packageManager = "pip";
|
|
106
|
+
const nameMatch = pyproject.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
|
|
107
|
+
if (nameMatch) result.suggestedName = nameMatch[1];
|
|
108
|
+
if (/fastapi/i.test(pyproject)) {
|
|
109
|
+
result.framework = "fastapi";
|
|
110
|
+
result.suggestedPreset = "fastapi";
|
|
111
|
+
result.availablePresets = ["fastapi", "generic"];
|
|
112
|
+
} else if (/django/i.test(pyproject)) {
|
|
113
|
+
result.framework = "django";
|
|
114
|
+
result.suggestedPreset = "django";
|
|
115
|
+
result.availablePresets = ["django", "generic"];
|
|
116
|
+
} else if (/flask/i.test(pyproject)) {
|
|
117
|
+
result.framework = "flask";
|
|
118
|
+
result.suggestedPreset = "flask";
|
|
119
|
+
result.availablePresets = ["flask", "generic"];
|
|
120
|
+
} else {
|
|
121
|
+
result.framework = "python";
|
|
122
|
+
result.suggestedPreset = "generic";
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
if (await exists(resolve(cwd, "requirements.txt"))) {
|
|
127
|
+
result.language = "python";
|
|
128
|
+
result.packageManager = "pip";
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Go / Rust — core only, no preset.
|
|
133
|
+
if (await exists(resolve(cwd, "go.mod"))) {
|
|
134
|
+
result.language = "go";
|
|
135
|
+
result.packageManager = "go";
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
if (await exists(resolve(cwd, "Cargo.toml"))) {
|
|
139
|
+
result.language = "rust";
|
|
140
|
+
result.packageManager = "cargo";
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Walk apps/ packages/ services/ and one extra level looking for nested
|
|
148
|
+
// language signals. Used to flag polyglot monorepos and recommend per-app
|
|
149
|
+
// init instead of root init.
|
|
150
|
+
async function probePolyglot(cwd, result) {
|
|
151
|
+
const candidates = ["apps", "packages", "services", "ios", "android", "web", "api"];
|
|
152
|
+
const signals = new Set();
|
|
153
|
+
for (const sub of candidates) {
|
|
154
|
+
const subAbs = resolve(cwd, sub);
|
|
155
|
+
if (!(await exists(subAbs))) continue;
|
|
156
|
+
let entries;
|
|
157
|
+
try {
|
|
158
|
+
entries = await readdir(subAbs, { withFileTypes: true });
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
for (const e of entries) {
|
|
163
|
+
if (!e.isDirectory()) continue;
|
|
164
|
+
const appAbs = resolve(subAbs, e.name);
|
|
165
|
+
const sigs = [];
|
|
166
|
+
if (await exists(resolve(appAbs, "package.json"))) sigs.push("typescript");
|
|
167
|
+
if (await exists(resolve(appAbs, "pyproject.toml"))) sigs.push("python");
|
|
168
|
+
if (await exists(resolve(appAbs, "go.mod"))) sigs.push("go");
|
|
169
|
+
if (await exists(resolve(appAbs, "Cargo.toml"))) sigs.push("rust");
|
|
170
|
+
if (await exists(resolve(appAbs, "Package.swift"))) sigs.push("swift");
|
|
171
|
+
if (await exists(resolve(appAbs, "build.gradle.kts"))) sigs.push("kotlin");
|
|
172
|
+
if (sigs.length === 0) continue;
|
|
173
|
+
result.appCandidates.push({ path: `${sub}/${e.name}`, languages: sigs });
|
|
174
|
+
for (const s of sigs) signals.add(s);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
result.polyglotSignals = [...signals];
|
|
178
|
+
if (signals.size >= 2) {
|
|
179
|
+
result.monorepo = true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// doctor.mjs — diagnose installed kit + Claude Code environment.
|
|
2
|
+
// Prints a checklist; non-zero exit means at least one CRITICAL check failed.
|
|
3
|
+
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import pc from "picocolors";
|
|
9
|
+
|
|
10
|
+
function check(name, ok, info = "") {
|
|
11
|
+
const mark = ok ? pc.green("✓") : pc.red("✗");
|
|
12
|
+
console.log(` ${mark} ${name}${info ? pc.dim(" — " + info) : ""}`);
|
|
13
|
+
return ok;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function which(cmd) {
|
|
17
|
+
try {
|
|
18
|
+
const r = execSync(`command -v ${cmd}`, { stdio: ["ignore", "pipe", "ignore"] })
|
|
19
|
+
.toString()
|
|
20
|
+
.trim();
|
|
21
|
+
return r || null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function doctor({ cwd, kitVersion }) {
|
|
28
|
+
console.log(pc.bold(`\nagent-harness-kit doctor (v${kitVersion})\n`));
|
|
29
|
+
let allOk = true;
|
|
30
|
+
|
|
31
|
+
// 1. Lockfile
|
|
32
|
+
const lockPath = resolve(cwd, ".harness/installed.json");
|
|
33
|
+
const hasLock = existsSync(lockPath);
|
|
34
|
+
allOk = check("kit installed (.harness/installed.json present)", hasLock) && allOk;
|
|
35
|
+
if (hasLock) {
|
|
36
|
+
const lock = JSON.parse(await readFile(lockPath, "utf8"));
|
|
37
|
+
check(`installed version`, true, `v${lock.version}`);
|
|
38
|
+
if (lock.version !== kitVersion) {
|
|
39
|
+
console.log(
|
|
40
|
+
pc.yellow(` ↳ kit CLI is v${kitVersion}; run \`agent-harness-kit upgrade\` to sync.`),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
// Cross-check: harness.config.json carries its own version field that
|
|
44
|
+
// upgrade is supposed to keep in step. Catch the drift so users notice
|
|
45
|
+
// before it bites them in CI.
|
|
46
|
+
const cfgPath = resolve(cwd, "harness.config.json");
|
|
47
|
+
if (existsSync(cfgPath)) {
|
|
48
|
+
try {
|
|
49
|
+
const cfg = JSON.parse(await readFile(cfgPath, "utf8"));
|
|
50
|
+
if (cfg.version && cfg.version !== lock.version) {
|
|
51
|
+
allOk = false;
|
|
52
|
+
console.log(
|
|
53
|
+
pc.red(
|
|
54
|
+
` ✗ harness.config.json version drift — config says v${cfg.version}, lockfile says v${lock.version} (run \`agent-harness-kit upgrade\` to sync).`,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// ignored — invalid JSON is caught by the must-files check below
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Core files (required for any install).
|
|
65
|
+
const must = [
|
|
66
|
+
"CLAUDE.md",
|
|
67
|
+
"harness.config.json",
|
|
68
|
+
".claude/skills/inspect-module/SKILL.md",
|
|
69
|
+
".claude/agents/architecture-reviewer.md",
|
|
70
|
+
"scripts/structural-test-on-edit.sh",
|
|
71
|
+
"scripts/precompletion-checklist.sh",
|
|
72
|
+
];
|
|
73
|
+
for (const f of must) {
|
|
74
|
+
allOk = check(f, existsSync(resolve(cwd, f))) && allOk;
|
|
75
|
+
}
|
|
76
|
+
// Optional: hooks.json — present if `init` was run without --no-hooks.
|
|
77
|
+
const hasHooks = existsSync(resolve(cwd, ".claude/hooks/hooks.json"));
|
|
78
|
+
console.log(
|
|
79
|
+
` ${hasHooks ? pc.green("✓") : pc.yellow("•")} .claude/hooks/hooks.json` +
|
|
80
|
+
(hasHooks ? "" : pc.dim(" — not installed (PostToolUse + Stop hooks disabled)")),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// 3. Tooling.
|
|
84
|
+
console.log(pc.bold("\nTooling:"));
|
|
85
|
+
const node = which("node");
|
|
86
|
+
check("node", !!node, node ?? "not installed");
|
|
87
|
+
check("git", !!which("git"), which("git") ?? "not installed");
|
|
88
|
+
check("jq (optional, used by hooks)", !!which("jq"), which("jq") ?? "not installed — hooks will exit 0 without blocking");
|
|
89
|
+
check("claude (Claude Code CLI)", !!which("claude"), which("claude") ?? "install via `npm i -g @anthropic-ai/claude-code`");
|
|
90
|
+
|
|
91
|
+
// 4. Adapter health.
|
|
92
|
+
if (existsSync(resolve(cwd, "harness.config.json"))) {
|
|
93
|
+
const cfg = JSON.parse(await readFile(resolve(cwd, "harness.config.json"), "utf8"));
|
|
94
|
+
if (cfg.language === "typescript") {
|
|
95
|
+
check("ts-morph available", existsSync(resolve(cwd, "node_modules/ts-morph")), "run `npm install`");
|
|
96
|
+
} else if (cfg.language === "python") {
|
|
97
|
+
// libcst is harder to detect generically — just print a hint.
|
|
98
|
+
check("libcst (python adapter)", true, "verify with `python -c 'import libcst'`");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log("");
|
|
103
|
+
if (!allOk) {
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// patch-package-json.mjs — non-destructively add npm scripts + devDependencies
|
|
2
|
+
// to an existing package.json. Called from the TypeScript adapter path of
|
|
3
|
+
// renderAll(). If package.json doesn't exist, this is a no-op.
|
|
4
|
+
|
|
5
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
const NEEDED_SCRIPTS = {
|
|
10
|
+
"harness:check": "node harness/structural-test.mjs",
|
|
11
|
+
"harness:gc": "echo 'run /garbage-collection in Claude Code'",
|
|
12
|
+
"harness:eval": "node harness/eval-runner.mjs",
|
|
13
|
+
"harness:report": "node scripts/harness-report.mjs",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const NEEDED_DEV_DEPS = {
|
|
17
|
+
"ts-morph": "^25.0.0",
|
|
18
|
+
"eslint-plugin-boundaries": "^5.0.0",
|
|
19
|
+
"dependency-cruiser": "^16.0.0",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function patchPackageJson(cwd) {
|
|
23
|
+
const path = resolve(cwd, "package.json");
|
|
24
|
+
if (!existsSync(path)) return { patched: false };
|
|
25
|
+
const raw = await readFile(path, "utf8");
|
|
26
|
+
let pkg;
|
|
27
|
+
try {
|
|
28
|
+
pkg = JSON.parse(raw);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return { patched: false, error: "package.json is not valid JSON" };
|
|
31
|
+
}
|
|
32
|
+
pkg.scripts = pkg.scripts ?? {};
|
|
33
|
+
pkg.devDependencies = pkg.devDependencies ?? {};
|
|
34
|
+
let changed = false;
|
|
35
|
+
for (const [k, v] of Object.entries(NEEDED_SCRIPTS)) {
|
|
36
|
+
if (pkg.scripts[k] === undefined) {
|
|
37
|
+
pkg.scripts[k] = v;
|
|
38
|
+
changed = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const [k, v] of Object.entries(NEEDED_DEV_DEPS)) {
|
|
42
|
+
if (pkg.devDependencies[k] === undefined && (pkg.dependencies?.[k] === undefined)) {
|
|
43
|
+
pkg.devDependencies[k] = v;
|
|
44
|
+
changed = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (changed) {
|
|
48
|
+
// Preserve trailing newline.
|
|
49
|
+
const trailing = raw.endsWith("\n") ? "\n" : "";
|
|
50
|
+
await writeFile(path, JSON.stringify(pkg, null, 2) + trailing);
|
|
51
|
+
}
|
|
52
|
+
return { patched: changed };
|
|
53
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// render-templates.mjs — copy + Handlebars-render the template tree into the user's repo.
|
|
2
|
+
//
|
|
3
|
+
// Strategy:
|
|
4
|
+
// - Walk src/templates recursively.
|
|
5
|
+
// - For files ending in `.hbs`, render via Handlebars (strip the .hbs suffix).
|
|
6
|
+
// - For everything else, copy verbatim.
|
|
7
|
+
// - Files that already exist in the target are skipped (we never clobber user
|
|
8
|
+
// content). Use `agent-harness-kit upgrade` for the version-aware merge flow.
|
|
9
|
+
// - After the walk, write `.harness/installed.json` (kit lockfile) with sha256
|
|
10
|
+
// of every kit-written file.
|
|
11
|
+
|
|
12
|
+
import { readFile, writeFile, mkdir, readdir, stat, chmod } from "node:fs/promises";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { resolve, join, relative, dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import Handlebars from "handlebars";
|
|
18
|
+
import { patchPackageJson } from "./patch-package-json.mjs";
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const TEMPLATES_ROOT = resolve(__dirname, "..", "templates");
|
|
22
|
+
|
|
23
|
+
// Files that the user is expected to edit — they win every time, even on
|
|
24
|
+
// fresh init we won't overwrite if they exist. This list is hard-coded
|
|
25
|
+
// because it's tiny and security-sensitive.
|
|
26
|
+
const USER_OWNED_FILES = new Set([
|
|
27
|
+
"CLAUDE.md",
|
|
28
|
+
"AGENTS.md",
|
|
29
|
+
"docs/architecture.md",
|
|
30
|
+
"docs/core-beliefs.md",
|
|
31
|
+
"docs/golden-principles.md",
|
|
32
|
+
"docs/tech-debt-tracker.md",
|
|
33
|
+
"feature_list.json",
|
|
34
|
+
"harness.config.json",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Paths that should be made executable after rendering.
|
|
38
|
+
const EXEC_BITS = new Set([
|
|
39
|
+
"scripts/dev-up.sh",
|
|
40
|
+
"scripts/pre-push.sh",
|
|
41
|
+
"scripts/install-git-hooks.sh",
|
|
42
|
+
"scripts/structural-test-on-edit.sh",
|
|
43
|
+
"scripts/precompletion-checklist.sh",
|
|
44
|
+
"scripts/telemetry-on-skill.sh",
|
|
45
|
+
"scripts/harness-report.mjs",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export function registerHelpers() {
|
|
49
|
+
Handlebars.registerHelper("kebabCase", (s) =>
|
|
50
|
+
String(s ?? "")
|
|
51
|
+
.replace(/[_\s]+/g, "-")
|
|
52
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
53
|
+
.toLowerCase(),
|
|
54
|
+
);
|
|
55
|
+
Handlebars.registerHelper("pascalCase", (s) =>
|
|
56
|
+
String(s ?? "")
|
|
57
|
+
.replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""))
|
|
58
|
+
.replace(/^./, (c) => c.toUpperCase()),
|
|
59
|
+
);
|
|
60
|
+
Handlebars.registerHelper("now", (fmt) => {
|
|
61
|
+
const d = new Date();
|
|
62
|
+
if (fmt === "yyyy-MM-dd") {
|
|
63
|
+
return d.toISOString().slice(0, 10);
|
|
64
|
+
}
|
|
65
|
+
return d.toISOString();
|
|
66
|
+
});
|
|
67
|
+
Handlebars.registerHelper("eq", (a, b) => a === b);
|
|
68
|
+
Handlebars.registerHelper("includes", (arr, v) =>
|
|
69
|
+
Array.isArray(arr) && arr.includes(v),
|
|
70
|
+
);
|
|
71
|
+
Handlebars.registerHelper("layerJoin", (layers) =>
|
|
72
|
+
Array.isArray(layers) ? layers.join(" → ") : "",
|
|
73
|
+
);
|
|
74
|
+
Handlebars.registerHelper("upper", (s) => String(s ?? "").toUpperCase());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function* walk(dir) {
|
|
78
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
const full = join(dir, e.name);
|
|
81
|
+
if (e.isDirectory()) {
|
|
82
|
+
yield* walk(full);
|
|
83
|
+
} else {
|
|
84
|
+
yield full;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
90
|
+
const installCmd =
|
|
91
|
+
stack.language === "python"
|
|
92
|
+
? "pip install -e '.[dev]'"
|
|
93
|
+
: stack.packageManager === "pnpm"
|
|
94
|
+
? "pnpm install"
|
|
95
|
+
: stack.packageManager === "yarn"
|
|
96
|
+
? "yarn"
|
|
97
|
+
: stack.packageManager === "bun"
|
|
98
|
+
? "bun install"
|
|
99
|
+
: "npm install";
|
|
100
|
+
const devCmd = (() => {
|
|
101
|
+
switch (stack.framework) {
|
|
102
|
+
case "nextjs": return "npm run dev";
|
|
103
|
+
case "express": return "node ./src/server.js";
|
|
104
|
+
case "fastify": return "node ./src/server.js";
|
|
105
|
+
case "nestjs": return "npm run start:dev";
|
|
106
|
+
case "fastapi": return "uvicorn app.main:app --reload";
|
|
107
|
+
case "django": return "python manage.py runserver";
|
|
108
|
+
case "flask": return "flask --app app run --debug";
|
|
109
|
+
default:
|
|
110
|
+
return stack.language === "python" ? "python -m app" : "npm run dev";
|
|
111
|
+
}
|
|
112
|
+
})();
|
|
113
|
+
const testCmd = (() => {
|
|
114
|
+
switch (stack.framework) {
|
|
115
|
+
case "django": return "python manage.py test";
|
|
116
|
+
default:
|
|
117
|
+
return stack.language === "python" ? "pytest -x" : "npm test";
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
const lintCmd =
|
|
121
|
+
stack.language === "python" ? "ruff check ." : "npm run lint";
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
projectName,
|
|
125
|
+
description: `${projectName} — solo-dev project on the agent-harness-kit harness.`,
|
|
126
|
+
language: stack.language,
|
|
127
|
+
framework: stack.framework ?? stack.language,
|
|
128
|
+
packageManager: stack.packageManager,
|
|
129
|
+
preset,
|
|
130
|
+
layers,
|
|
131
|
+
layersJoined: layers.join(" → "),
|
|
132
|
+
providers: ["auth", "telemetry", "feature-flags"],
|
|
133
|
+
installCmd,
|
|
134
|
+
devCmd,
|
|
135
|
+
testCmd,
|
|
136
|
+
lintCmd,
|
|
137
|
+
kitVersion,
|
|
138
|
+
isTypescript: stack.language === "typescript",
|
|
139
|
+
isPython: stack.language === "python",
|
|
140
|
+
isNextjs: stack.framework === "nextjs",
|
|
141
|
+
isFastapi: stack.framework === "fastapi",
|
|
142
|
+
isExpress: stack.framework === "express",
|
|
143
|
+
isFastify: stack.framework === "fastify",
|
|
144
|
+
isNestjs: stack.framework === "nestjs",
|
|
145
|
+
isDjango: stack.framework === "django",
|
|
146
|
+
isFlask: stack.framework === "flask",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Decide whether a template path should be rendered for this stack/preset.
|
|
151
|
+
// Adapter-specific files live under templates/_adapter-<id>/ and are merged
|
|
152
|
+
// into the root.
|
|
153
|
+
function pathForStack(rel, stack) {
|
|
154
|
+
if (rel.startsWith("_adapter-typescript/")) {
|
|
155
|
+
return stack.language === "typescript" ? rel.slice("_adapter-typescript/".length) : null;
|
|
156
|
+
}
|
|
157
|
+
if (rel.startsWith("_adapter-python/")) {
|
|
158
|
+
return stack.language === "python" ? rel.slice("_adapter-python/".length) : null;
|
|
159
|
+
}
|
|
160
|
+
if (rel.startsWith("_preset-nextjs/")) {
|
|
161
|
+
return stack.framework === "nextjs" ? rel.slice("_preset-nextjs/".length) : null;
|
|
162
|
+
}
|
|
163
|
+
if (rel.startsWith("_preset-fastapi/")) {
|
|
164
|
+
return stack.framework === "fastapi" ? rel.slice("_preset-fastapi/".length) : null;
|
|
165
|
+
}
|
|
166
|
+
if (rel.startsWith("_ci/")) {
|
|
167
|
+
return rel.slice("_ci/".length); // handled separately by installCi flag
|
|
168
|
+
}
|
|
169
|
+
return rel;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sha256(buf) {
|
|
173
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function renderAll({
|
|
177
|
+
cwd,
|
|
178
|
+
projectName,
|
|
179
|
+
preset,
|
|
180
|
+
layers,
|
|
181
|
+
stack,
|
|
182
|
+
installHooks,
|
|
183
|
+
installCi,
|
|
184
|
+
kitVersion,
|
|
185
|
+
}) {
|
|
186
|
+
registerHelpers();
|
|
187
|
+
const ctx = buildContext({ projectName, preset, layers, stack, kitVersion });
|
|
188
|
+
|
|
189
|
+
const written = [];
|
|
190
|
+
const skipped = [];
|
|
191
|
+
const lockfile = { version: kitVersion, files: {} };
|
|
192
|
+
|
|
193
|
+
for await (const abs of walk(TEMPLATES_ROOT)) {
|
|
194
|
+
const relFromTemplates = relative(TEMPLATES_ROOT, abs).split("\\").join("/");
|
|
195
|
+
const stackRel = pathForStack(relFromTemplates, stack);
|
|
196
|
+
if (stackRel === null) continue;
|
|
197
|
+
if (relFromTemplates.startsWith("_ci/") && !installCi) continue;
|
|
198
|
+
if (relFromTemplates.includes("hooks.json") && !installHooks) continue;
|
|
199
|
+
|
|
200
|
+
const targetRel = stackRel.endsWith(".hbs")
|
|
201
|
+
? stackRel.slice(0, -".hbs".length)
|
|
202
|
+
: stackRel;
|
|
203
|
+
const targetAbs = resolve(cwd, targetRel);
|
|
204
|
+
|
|
205
|
+
if (USER_OWNED_FILES.has(targetRel) && existsSync(targetAbs)) {
|
|
206
|
+
skipped.push(targetRel);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let content;
|
|
211
|
+
if (abs.endsWith(".hbs")) {
|
|
212
|
+
const raw = await readFile(abs, "utf8");
|
|
213
|
+
const tpl = Handlebars.compile(raw, { noEscape: true });
|
|
214
|
+
content = tpl(ctx);
|
|
215
|
+
} else {
|
|
216
|
+
content = await readFile(abs);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await mkdir(dirname(targetAbs), { recursive: true });
|
|
220
|
+
await writeFile(targetAbs, content);
|
|
221
|
+
if (EXEC_BITS.has(targetRel)) {
|
|
222
|
+
try {
|
|
223
|
+
await chmod(targetAbs, 0o755);
|
|
224
|
+
} catch {
|
|
225
|
+
// ignore on platforms where chmod is a no-op
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
lockfile.files[targetRel] = sha256(
|
|
229
|
+
typeof content === "string" ? Buffer.from(content) : content,
|
|
230
|
+
);
|
|
231
|
+
written.push(targetRel);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Write the lockfile last (after we know the full set of files).
|
|
235
|
+
const lockTarget = resolve(cwd, ".harness/installed.json");
|
|
236
|
+
await mkdir(dirname(lockTarget), { recursive: true });
|
|
237
|
+
await writeFile(lockTarget, JSON.stringify(lockfile, null, 2) + "\n");
|
|
238
|
+
written.push(".harness/installed.json");
|
|
239
|
+
|
|
240
|
+
// Try to symlink AGENTS.md → CLAUDE.md if not already present.
|
|
241
|
+
// On platforms without symlink support this silently falls back to a copy.
|
|
242
|
+
const agentsMd = resolve(cwd, "AGENTS.md");
|
|
243
|
+
if (!existsSync(agentsMd) && existsSync(resolve(cwd, "CLAUDE.md"))) {
|
|
244
|
+
const claudeBody = await readFile(resolve(cwd, "CLAUDE.md"), "utf8");
|
|
245
|
+
await writeFile(agentsMd, claudeBody);
|
|
246
|
+
written.push("AGENTS.md");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Empty PROGRESS.md placeholder — the user / agent appends to this every session.
|
|
250
|
+
const progress = resolve(cwd, ".harness/PROGRESS.md");
|
|
251
|
+
if (!existsSync(progress)) {
|
|
252
|
+
await writeFile(
|
|
253
|
+
progress,
|
|
254
|
+
"# Session progress\n\n" +
|
|
255
|
+
"_Append a one-line entry per completed feature. Format: `YYYY-MM-DD HH:MM | <feature_id> | done`._\n",
|
|
256
|
+
);
|
|
257
|
+
written.push(".harness/PROGRESS.md");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Empty structural baseline — populated on first `harness:check` run.
|
|
261
|
+
// Note: we only write the file if it doesn't exist; first run of
|
|
262
|
+
// `harness:check` writes the actual baseline of existing violations.
|
|
263
|
+
const baseline = resolve(cwd, ".harness/structural-baseline.json");
|
|
264
|
+
if (!existsSync(baseline)) {
|
|
265
|
+
await writeFile(baseline, "[]\n");
|
|
266
|
+
written.push(".harness/structural-baseline.json");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// For TypeScript stacks, non-destructively patch package.json with the
|
|
270
|
+
// harness:* npm scripts and the peer-dep dev dependencies.
|
|
271
|
+
if (stack.language === "typescript") {
|
|
272
|
+
const patch = await patchPackageJson(cwd);
|
|
273
|
+
if (patch.patched) written.push("package.json (scripts + devDependencies)");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { written, skipped };
|
|
277
|
+
}
|