agent-harness-kit 0.5.1 → 0.7.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.
@@ -11,9 +11,9 @@
11
11
  "source": {
12
12
  "source": "github",
13
13
  "repo": "tuanle96/agent-harness-kit",
14
- "ref": "v0.5.1"
14
+ "ref": "v0.7.0"
15
15
  },
16
- "version": "0.5.1",
16
+ "version": "0.7.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.3.0",
3
+ "version": "0.7.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/README.md CHANGED
@@ -191,6 +191,35 @@ developer. The `eval-runner` skill enforces a per-run budget set in
191
191
  | Flask | python | `flask` | `flask --app app run --debug` | v0.1 |
192
192
  | Go / Rust | core only | none | (manual) | v0.4 |
193
193
 
194
+ ## CI: real-claude E2E test (v0.7+)
195
+
196
+ The kit ships a CI job that spawns the real `claude` binary against a fresh
197
+ init of itself and asserts that the SessionStart hook actually fires (with
198
+ the expected `additionalContext` payload). This catches the class of bug
199
+ that v0.6's silent-no-op hooks fell into — every synthetic test passed for
200
+ seven releases while not a single hook ever triggered inside a real Claude
201
+ Code session.
202
+
203
+ **Behavior:**
204
+
205
+ - Locally: `npm test` skips the E2E case cleanly when no `ANTHROPIC_API_KEY`
206
+ is set (1 skip, 0 fail).
207
+ - CI with secret: the `e2e-claude` job runs the driver, installs
208
+ `@anthropic-ai/claude-code` globally, and exercises one claude turn
209
+ (~$0.01–0.05). Failure means a hook system regression.
210
+ - CI without secret (fork PRs): emits a GitHub Actions warning and exits 0
211
+ so the PR can still merge — but you lose the v0.6-class bug guard for
212
+ that run.
213
+
214
+ To enable on your own fork: configure the `ANTHROPIC_API_KEY` repository
215
+ secret in GitHub Actions settings.
216
+
217
+ Local run (uses whatever auth the `claude` binary already has):
218
+
219
+ ```bash
220
+ AHK_E2E_USE_LOCAL_AUTH=1 node scripts/e2e-claude-cli.mjs
221
+ ```
222
+
194
223
  ## License
195
224
 
196
225
  MIT.
package/bin/cli.mjs CHANGED
@@ -43,6 +43,11 @@ program
43
43
  "force init for languages without a structural-test adapter yet (go, rust, swift, kotlin)",
44
44
  false,
45
45
  )
46
+ .option(
47
+ "--lang <code>",
48
+ "human language for the CLAUDE.md template (en, vi)",
49
+ "en",
50
+ )
46
51
  .action(async (opts) => {
47
52
  const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
48
53
  console.log(pc.bold(pc.cyan(`\nagent-harness-kit v${pkg.version}\n`)));
@@ -104,7 +109,10 @@ program
104
109
  // initing at the root of a Go/Rust/Swift/Kotlin project produces a
105
110
  // half-broken harness (structural test missing, ts-morph engine pinned).
106
111
  // Skills + reviewers still work, but the user should opt in explicitly.
107
- const UNSUPPORTED = new Set(["go", "rust", "swift", "kotlin"]);
112
+ // Swift + Kotlin shipped regex-based adapters in v0.7; Go + Rust earlier.
113
+ // No language is "unsupported" today — the set is empty, kept as a hook
114
+ // for future languages that don't yet have an adapter.
115
+ const UNSUPPORTED = new Set([]);
108
116
  if (UNSUPPORTED.has(stack.language) && !opts.allowUnsupported) {
109
117
  console.log(
110
118
  pc.yellow(
@@ -187,6 +195,7 @@ program
187
195
  installHooks,
188
196
  installCi,
189
197
  kitVersion: pkg.version,
198
+ humanLanguage: opts.lang || "en",
190
199
  });
191
200
 
192
201
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-harness-kit",
3
- "version": "0.5.1",
3
+ "version": "0.7.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": {
@@ -68,6 +68,22 @@ export async function detectStack(cwd) {
68
68
  // primary language. Walks 1 level deep into common monorepo dirs.
69
69
  await probePolyglot(cwd, result);
70
70
 
71
+ // Rust workspace — must be checked BEFORE package.json because a polyglot
72
+ // repo (Rust backend + Next.js frontend) typically has BOTH at the root,
73
+ // and we want the Rust adapter installed by default since structural-test
74
+ // enforcement matters more for the workspace than for the marketing site.
75
+ // Single-crate Cargo.toml falls through to the legacy check at the bottom.
76
+ const rootCargo = await readTextSafe(resolve(cwd, "Cargo.toml"));
77
+ if (rootCargo && /^\s*\[workspace\]/m.test(rootCargo)) {
78
+ result.language = "rust";
79
+ result.framework = "rust-workspace";
80
+ result.packageManager = "cargo";
81
+ result.monorepo = true;
82
+ result.suggestedPreset = "generic";
83
+ result.availablePresets = ["generic"];
84
+ return result;
85
+ }
86
+
71
87
  // JavaScript/TypeScript.
72
88
  const pkg = await readJsonSafe(resolve(cwd, "package.json"));
73
89
  if (pkg) {
@@ -141,6 +157,22 @@ export async function detectStack(cwd) {
141
157
  return result;
142
158
  }
143
159
 
160
+ // Swift / Kotlin — regex-based adapters (added in v0.7).
161
+ if (await exists(resolve(cwd, "Package.swift"))) {
162
+ result.language = "swift";
163
+ result.packageManager = "swift";
164
+ return result;
165
+ }
166
+ if (
167
+ (await exists(resolve(cwd, "build.gradle.kts"))) ||
168
+ (await exists(resolve(cwd, "settings.gradle.kts"))) ||
169
+ (await exists(resolve(cwd, "build.gradle")))
170
+ ) {
171
+ result.language = "kotlin";
172
+ result.packageManager = "gradle";
173
+ return result;
174
+ }
175
+
144
176
  return result;
145
177
  }
146
178
 
@@ -43,6 +43,11 @@ const EXEC_BITS = new Set([
43
43
  "scripts/precompletion-checklist.sh",
44
44
  "scripts/telemetry-on-skill.sh",
45
45
  "scripts/harness-report.mjs",
46
+ // v0.7 hook expansion — SessionStart / PreToolUse / PreCompact / SessionEnd.
47
+ "scripts/session-start.sh",
48
+ "scripts/pretooluse-bash-guard.sh",
49
+ "scripts/pre-compact.sh",
50
+ "scripts/session-end.sh",
46
51
  ]);
47
52
 
48
53
  export function registerHelpers() {
@@ -86,11 +91,19 @@ async function* walk(dir) {
86
91
  }
87
92
  }
88
93
 
89
- function buildContext({ projectName, preset, layers, stack, kitVersion }) {
94
+ // SUPPORTED_LANGS human-language variants the kit ships for CLAUDE.md.
95
+ // Adding a new locale: drop `CLAUDE.md.<code>.hbs` alongside the English
96
+ // CLAUDE.md.hbs, add the code here, and the picker in pathForStack()
97
+ // will route it. Default is "en" → uses the suffix-less CLAUDE.md.hbs.
98
+ export const SUPPORTED_HUMAN_LANGS = new Set(["en", "vi"]);
99
+
100
+ function buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage }) {
90
101
  const installCmd = (() => {
91
102
  if (stack.language === "python") return "pip install -e '.[dev]'";
92
103
  if (stack.language === "go") return "go mod download";
93
104
  if (stack.language === "rust") return "cargo build";
105
+ if (stack.language === "swift") return "swift package resolve";
106
+ if (stack.language === "kotlin") return "./gradlew build --refresh-dependencies";
94
107
  if (stack.packageManager === "pnpm") return "pnpm install";
95
108
  if (stack.packageManager === "yarn") return "yarn";
96
109
  if (stack.packageManager === "bun") return "bun install";
@@ -109,6 +122,8 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
109
122
  if (stack.language === "python") return "python -m app";
110
123
  if (stack.language === "go") return "go run ./cmd/...";
111
124
  if (stack.language === "rust") return "cargo run";
125
+ if (stack.language === "swift") return "swift run";
126
+ if (stack.language === "kotlin") return "./gradlew run";
112
127
  return "npm run dev";
113
128
  }
114
129
  })();
@@ -119,6 +134,8 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
119
134
  if (stack.language === "python") return "pytest -x";
120
135
  if (stack.language === "go") return "go test ./...";
121
136
  if (stack.language === "rust") return "cargo test";
137
+ if (stack.language === "swift") return "swift test";
138
+ if (stack.language === "kotlin") return "./gradlew test";
122
139
  return "npm test";
123
140
  }
124
141
  })();
@@ -126,6 +143,8 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
126
143
  if (stack.language === "python") return "ruff check .";
127
144
  if (stack.language === "go") return "go vet ./...";
128
145
  if (stack.language === "rust") return "cargo clippy --all-targets -- -D warnings";
146
+ if (stack.language === "swift") return "swift build --warnings-as-errors";
147
+ if (stack.language === "kotlin") return "./gradlew detekt || ./gradlew lint";
129
148
  return "npm run lint";
130
149
  })();
131
150
 
@@ -144,10 +163,13 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
144
163
  testCmd,
145
164
  lintCmd,
146
165
  kitVersion,
166
+ humanLanguage: humanLanguage || "en",
147
167
  isTypescript: stack.language === "typescript",
148
168
  isPython: stack.language === "python",
149
169
  isGo: stack.language === "go",
150
170
  isRust: stack.language === "rust",
171
+ isSwift: stack.language === "swift",
172
+ isKotlin: stack.language === "kotlin",
151
173
  isNextjs: stack.framework === "nextjs",
152
174
  isFastapi: stack.framework === "fastapi",
153
175
  isExpress: stack.framework === "express",
@@ -161,7 +183,18 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
161
183
  // Decide whether a template path should be rendered for this stack/preset.
162
184
  // Adapter-specific files live under templates/_adapter-<id>/ and are merged
163
185
  // into the root.
164
- function pathForStack(rel, stack) {
186
+ function pathForStack(rel, stack, humanLanguage = "en") {
187
+ // CLAUDE.md locale routing. `CLAUDE.md.hbs` (no language suffix) is the
188
+ // English default. `CLAUDE.md.<lang>.hbs` is rendered into the same
189
+ // target path (`CLAUDE.md`) when the user picks that locale. The
190
+ // suffixed file is skipped otherwise — and so is the unsuffixed file
191
+ // when a non-default locale is active, so only one variant lands.
192
+ if (/^CLAUDE\.md(?:\.[a-z]{2,5})?\.hbs$/.test(rel)) {
193
+ const m = rel.match(/^CLAUDE\.md(?:\.([a-z]{2,5}))?\.hbs$/);
194
+ const fileLang = m[1] || "en";
195
+ if (fileLang !== humanLanguage) return null;
196
+ return "CLAUDE.md.hbs"; // canonical target — strip locale suffix
197
+ }
165
198
  if (rel.startsWith("_adapter-typescript/")) {
166
199
  const stripped = rel.slice("_adapter-typescript/".length);
167
200
  if (stack.language === "typescript") return stripped;
@@ -184,6 +217,16 @@ function pathForStack(rel, stack) {
184
217
  if (rel.startsWith("_adapter-rust/")) {
185
218
  return stack.language === "rust" ? rel.slice("_adapter-rust/".length) : null;
186
219
  }
220
+ if (rel.startsWith("_adapter-swift/")) {
221
+ const stripped = rel.slice("_adapter-swift/".length);
222
+ if (stack.language === "swift") return stripped;
223
+ return null;
224
+ }
225
+ if (rel.startsWith("_adapter-kotlin/")) {
226
+ const stripped = rel.slice("_adapter-kotlin/".length);
227
+ if (stack.language === "kotlin") return stripped;
228
+ return null;
229
+ }
187
230
  if (rel.startsWith("_preset-nextjs/")) {
188
231
  return stack.framework === "nextjs" ? rel.slice("_preset-nextjs/".length) : null;
189
232
  }
@@ -200,6 +243,48 @@ function sha256(buf) {
200
243
  return createHash("sha256").update(buf).digest("hex");
201
244
  }
202
245
 
246
+ // Merge .claude/hooks/hooks.json#hooks into .claude/settings.json#hooks.
247
+ // Claude Code only reads hooks from settings.json — a free-standing
248
+ // hooks.json is ignored by the runtime. Kept as a file because the plugin
249
+ // install path references it via .claude-plugin/plugin.json. Idempotent:
250
+ // re-running produces the same settings.json bytes when source hasn't
251
+ // changed. Returns {changed, rawContent} for the lockfile.
252
+ export async function mergeHooksIntoSettings(cwd) {
253
+ const settingsPath = resolve(cwd, ".claude/settings.json");
254
+ const hooksPath = resolve(cwd, ".claude/hooks/hooks.json");
255
+ const hooksRaw = await readFile(hooksPath, "utf8");
256
+ let hooksJson;
257
+ try {
258
+ hooksJson = JSON.parse(hooksRaw);
259
+ } catch (e) {
260
+ throw new Error(`mergeHooksIntoSettings: invalid JSON in ${hooksPath}: ${e.message}`);
261
+ }
262
+ if (!hooksJson.hooks || typeof hooksJson.hooks !== "object") {
263
+ return { changed: false, rawContent: "" };
264
+ }
265
+ let settings = {};
266
+ if (existsSync(settingsPath)) {
267
+ const raw = await readFile(settingsPath, "utf8");
268
+ try {
269
+ settings = JSON.parse(raw);
270
+ } catch {
271
+ // Corrupt settings.json — refuse to clobber user edits. Surface clearly.
272
+ throw new Error(`mergeHooksIntoSettings: ${settingsPath} is not valid JSON`);
273
+ }
274
+ // Idempotency: if hooks key already matches source verbatim, nothing to do.
275
+ if (
276
+ settings.hooks &&
277
+ JSON.stringify(settings.hooks) === JSON.stringify(hooksJson.hooks)
278
+ ) {
279
+ return { changed: false, rawContent: Buffer.from(raw) };
280
+ }
281
+ }
282
+ settings.hooks = hooksJson.hooks;
283
+ const out = JSON.stringify(settings, null, 2) + "\n";
284
+ await writeFile(settingsPath, out);
285
+ return { changed: true, rawContent: Buffer.from(out) };
286
+ }
287
+
203
288
  export async function renderAll({
204
289
  cwd,
205
290
  projectName,
@@ -209,9 +294,15 @@ export async function renderAll({
209
294
  installHooks,
210
295
  installCi,
211
296
  kitVersion,
297
+ humanLanguage = "en",
212
298
  }) {
213
299
  registerHelpers();
214
- const ctx = buildContext({ projectName, preset, layers, stack, kitVersion });
300
+ if (!SUPPORTED_HUMAN_LANGS.has(humanLanguage)) {
301
+ throw new Error(
302
+ `Unsupported humanLanguage "${humanLanguage}". Supported: ${[...SUPPORTED_HUMAN_LANGS].join(", ")}`,
303
+ );
304
+ }
305
+ const ctx = buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage });
215
306
 
216
307
  const written = [];
217
308
  const skipped = [];
@@ -219,7 +310,7 @@ export async function renderAll({
219
310
 
220
311
  for await (const abs of walk(TEMPLATES_ROOT)) {
221
312
  const relFromTemplates = relative(TEMPLATES_ROOT, abs).split("\\").join("/");
222
- const stackRel = pathForStack(relFromTemplates, stack);
313
+ const stackRel = pathForStack(relFromTemplates, stack, humanLanguage);
223
314
  if (stackRel === null) continue;
224
315
  if (relFromTemplates.startsWith("_ci/") && !installCi) continue;
225
316
  if (relFromTemplates.includes("hooks.json") && !installHooks) continue;
@@ -258,6 +349,22 @@ export async function renderAll({
258
349
  written.push(targetRel);
259
350
  }
260
351
 
352
+ // Critical fix (v0.7): merge .claude/hooks/hooks.json into
353
+ // .claude/settings.json#hooks. Claude Code ONLY reads hooks from
354
+ // settings.json — a free-standing .claude/hooks/hooks.json is ignored.
355
+ // Pre-v0.7 the kit silently no-op'd every hook on the scaffold path
356
+ // because it shipped only the free-standing file. Plugin install path
357
+ // continues to read .claude/hooks/hooks.json (per plugin.json manifest)
358
+ // so the file is kept; we just additionally inject it where Claude Code
359
+ // actually looks.
360
+ if (installHooks && existsSync(resolve(cwd, ".claude/hooks/hooks.json"))) {
361
+ const merged = await mergeHooksIntoSettings(cwd);
362
+ if (merged.changed) {
363
+ lockfile.files[".claude/settings.json"] = sha256(merged.rawContent);
364
+ written.push(".claude/settings.json (merged hooks)");
365
+ }
366
+ }
367
+
261
368
  // Write the lockfile last (after we know the full set of files).
262
369
  const lockTarget = resolve(cwd, ".harness/installed.json");
263
370
  await mkdir(dirname(lockTarget), { recursive: true });
@@ -0,0 +1,87 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-hooks.json",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "matcher": "startup|resume|compact",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "bash scripts/session-start.sh",
11
+ "timeout": 10
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "PreToolUse": [
17
+ {
18
+ "matcher": "Bash",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "bash scripts/pretooluse-bash-guard.sh",
23
+ "timeout": 5
24
+ }
25
+ ]
26
+ }
27
+ ],
28
+ "PostToolUse": [
29
+ {
30
+ "matcher": "Write|Edit|MultiEdit",
31
+ "hooks": [
32
+ {
33
+ "type": "command",
34
+ "command": "bash scripts/structural-test-on-edit.sh",
35
+ "timeout": 30
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ "matcher": "Skill",
41
+ "hooks": [
42
+ {
43
+ "type": "command",
44
+ "command": "bash scripts/telemetry-on-skill.sh",
45
+ "timeout": 5
46
+ }
47
+ ]
48
+ }
49
+ ],
50
+ "PreCompact": [
51
+ {
52
+ "matcher": "",
53
+ "hooks": [
54
+ {
55
+ "type": "command",
56
+ "command": "bash scripts/pre-compact.sh",
57
+ "timeout": 5
58
+ }
59
+ ]
60
+ }
61
+ ],
62
+ "Stop": [
63
+ {
64
+ "matcher": "",
65
+ "hooks": [
66
+ {
67
+ "type": "command",
68
+ "command": "bash scripts/precompletion-checklist.sh",
69
+ "timeout": 20
70
+ }
71
+ ]
72
+ }
73
+ ],
74
+ "SessionEnd": [
75
+ {
76
+ "matcher": "",
77
+ "hooks": [
78
+ {
79
+ "type": "command",
80
+ "command": "bash scripts/session-end.sh",
81
+ "timeout": 5
82
+ }
83
+ ]
84
+ }
85
+ ]
86
+ }
87
+ }
@@ -10,7 +10,7 @@ an encyclopedia.
10
10
  - Dev: `{{devCmd}}`
11
11
  - Test: `{{testCmd}}`
12
12
  - Lint: `{{lintCmd}}`
13
- - Structural: `{{#if isPython}}python -m harness.structural_test{{else}}{{#if isGo}}go run harness/structural_check.go{{else}}{{#if isRust}}node harness/structural-check.mjs{{else}}npm run harness:check{{/if}}{{/if}}{{/if}}` (must pass before any PR)
13
+ - Structural: `{{#if isPython}}python -m harness.structural_test{{else}}{{#if isGo}}go run harness/structural_check.go{{else}}{{#if isRust}}node harness/structural-check.mjs{{else}}{{#if isSwift}}node harness/structural-check.mjs{{else}}{{#if isKotlin}}node harness/structural-check.mjs{{else}}npm run harness:check{{/if}}{{/if}}{{/if}}{{/if}}{{/if}}` (must pass before any PR)
14
14
 
15
15
  ## Architecture (brief)
16
16
 
@@ -0,0 +1,70 @@
1
+ # {{projectName}} — Ghi chú làm việc cho Agent
2
+
3
+ {{description}} Dự án {{language}}/{{framework}}. Solo-dev hobby. File này
4
+ **cố ý ngắn** — nó là **Mục lục**, không phải Bách khoa toàn thư.
5
+
6
+ ## Build & Run
7
+
8
+ - Cài đặt: `{{installCmd}}`
9
+ - Dev: `{{devCmd}}`
10
+ - Test: `{{testCmd}}`
11
+ - Lint: `{{lintCmd}}`
12
+ - Structural: `{{#if isPython}}python -m harness.structural_test{{else}}{{#if isGo}}go run harness/structural_check.go{{else}}{{#if isRust}}node harness/structural-check.mjs{{else}}{{#if isSwift}}node harness/structural-check.mjs{{else}}{{#if isKotlin}}node harness/structural-check.mjs{{else}}npm run harness:check{{/if}}{{/if}}{{/if}}{{/if}}{{/if}}` (phải pass trước mọi PR)
13
+
14
+ ## Kiến trúc (tóm tắt)
15
+
16
+ Thứ tự layer, ép buộc bằng test cơ học:
17
+
18
+ **{{layersJoined}}** — code chỉ được phụ thuộc theo chiều tiến. Các mối
19
+ quan tâm cắt ngang (cross-cutting) vào qua `providers/`.
20
+
21
+ Sơ đồ đầy đủ và lý do: `docs/architecture.md` (chỉ tiếng Anh).
22
+
23
+ ## Nguyên tắc vàng (phải giữ)
24
+
25
+ 1. Ưu tiên utility chung trong `src/shared/` thay vì viết helper mới.
26
+ 2. Validate ở biên hệ thống; không bao giờ "đoán mò" hình dạng dữ liệu.
27
+ 3. Mỗi test là end-to-end qua một feature trong `feature_list.json`.
28
+
29
+ Danh sách đầy đủ: `docs/golden-principles.md`.
30
+
31
+ ## Đọc khi cần (read on demand)
32
+
33
+ - `docs/architecture.md` — đọc khi thêm module hoặc dời code.
34
+ - `docs/adr/` — đọc khi đổi public API.
35
+ - `docs/golden-principles.md` — đọc trước mọi refactor.
36
+ - `feature_list.json` — đọc trước khi tuyên bố một feature đã xong.
37
+ - `.harness/PROGRESS.md` — đọc đầu session; ghi cuối session.
38
+
39
+ ## Skills nên dùng
40
+
41
+ - `/inspect-module <path>` khi cần hiểu code đã có.
42
+ - `/add-feature <description>` khi thêm khả năng mới — không freestyle.
43
+ - `/structural-test-author <layer>` khi thêm rule kiến trúc mới.
44
+ - `/garbage-collection` mỗi thứ Sáu hoặc trước khi tag release.
45
+ - `/eval-runner` trước khi merge bất kỳ thay đổi nào ở skill / agent file.
46
+
47
+ ## Subagents nên ủy thác (KHÔNG inline review)
48
+
49
+ - `architecture-reviewer` — cho mọi thay đổi cross-layer.
50
+ - `security-reviewer` — cho mọi thay đổi liên quan auth, input, secret.
51
+ - `reliability-reviewer` — cho mọi error path mới, retry loop, async boundary.
52
+
53
+ ## Workflow contract
54
+
55
+ 1. Bắt đầu session: chạy `/inspect-module .` và đọc `.harness/PROGRESS.md`.
56
+ 2. Chọn MỘT feature trong `feature_list.json` có `passes: false`.
57
+ 3. Triển khai. Chạy structural test. Nếu fail thì FIX trước khi tiếp tục.
58
+ 4. Tự verify bằng subagent reviewer phù hợp.
59
+ 5. Commit với message mô tả. Append một dòng vào `.harness/PROGRESS.md`.
60
+ 6. Update `feature_list.json` (`passes: true`) **chỉ khi** end-to-end test pass.
61
+
62
+ ## Cấm
63
+
64
+ - Không thêm layer mới mà không có ADR.
65
+ - Không disable structural test để PR pass.
66
+ - Không viết code mà structural test không thể phân tích (no dynamic
67
+ imports across layers).
68
+ - Không update CLAUDE.md mà không qua `/propose-harness-improvement`.
69
+ - Không cho CLAUDE.md vượt 200 instruction (hoặc maxTokens nếu đã set)
70
+ — Stop hook sẽ block. Phần thừa cho vào `docs/` hoặc @-import.