Phase 3 of the harness series: 'machine over text'. Turn architectural rules from text in docs/architecture.md into deterministic checks — import boundaries, dead code, cycles, strict types. A cross-stack 'role → tool' table (dependency-cruiser / import-linter, knip / ts-prune / vulture, madge, tsc / mypy), a test-integrity tripwire instead of a coverage mandate (Goodhart), pre-commit + CI as the enforcement points, and your own check scripts for whatever isn't covered. With copyable prompts and configs.
The base principle of harness engineering is 'machine over text'. A rule expressed as a check (linter, test, script) is in force. The same rule as a paragraph in the README is not. Phase 3 is where architectural rules from docs/architecture.md stop being text and become deterministic checks.
Distinguish three states of a rule. A barrier is enforced mechanically: pre-commit and CI won't let violating code ship — the agent simply has no path around it. A convention rests only on the agent's discipline: written in AGENTS.md but unchecked — it drifts over distance. A conditional step fires on a trigger (worktree, ExecPlan, escalation) and is often correctly skipped. The blunt takeaway: whatever you can't put a barrier on, you can't rely on for long. Phase 3 maximizes the share of rules turned into barriers.
🟥 Barrier
Enforced mechanically: pre-commit / CI
No path around — the agent hits the wall
Boundaries, types, cycles, dead code
🟦 Convention
Rests on the agent's discipline
Stated in AGENTS.md, but unchecked
Drifts over distance
🟨 Conditional
Fires on a trigger
worktree, ExecPlan, escalation, /wrong
Often — and correctly — skipped
Before writing any config, re-read the Layer model in docs/architecture.md. A barrier catches exactly what you encode into it: a fuzzy rule in text yields a fuzzy (or leaky) check.
2
Cross-stack table: role → tool
The headline rule of this phase is tool-agnosticism. Not 'eslint is the law of the universe', but 'there is a check role, and you pick a tool for that role to fit the project's stack'. First define the roles (what must be checked), then pin the concrete choice in an ADR at docs/decisions/0001-tool-selection.md — every later phase takes it as input.
Below are the roles with example tools per stack. For instance, in JS/TS module boundaries and the public API are often held via eslint-plugin-boundaries or an FSD architecture, but that's just one stack — the same role exists in Python (import-linter) and Go (depguard). Whatever an off-the-shelf tool doesn't cover, you close with your own check script (last row). Don't hunt for 'one tool for everything': each role is a separate barrier.
Role (what we check)
JS / TS
Python
Other stacks
Import / layer boundaries
dependency-cruiser, eslint-plugin-boundaries
import-linter
depguard (Go), ArchUnit (JVM)
Dead code
knip, ts-prune
vulture
deadcode (Go), dead_code lint (Rust)
Dependency cycles
madge
import-linter (contracts)
compiler (Go), ArchUnit (JVM)
Nesting & file size
eslint (max-depth, max-lines)
flake8, ruff (C901, PLR)
golangci-lint, clippy
Strict types
tsc --strict
mypy --strict, pyright
sorbet (Ruby), the compiler (Go/Rust)
Anything uncovered above
Your own check script
Your own check script
Your own check script
Pin every row of this table in an ADR with the rationale. Otherwise the next agent installs a second tool into the same role a month later — and you get two diverging barriers.
3
The spine: strict types + import boundaries
Two barriers carry the load — this is the spine of Phase 3. First: strict types. For statically typed languages, turn on strict mode and the strictest compiler options (tsc --strict, mypy --strict). The migration strategy from prompt 3.2: if turning on strictness yields < 50 errors, enable and fix them now; if more, open an ADR with a phased plan and a dated TODO, and don't block work.
Second: import boundaries from the Layer model. The config must reflect ALL layer rules — allowed dependency directions, no cycles, no orphan modules (dead code imported from nowhere), no imports bypassing a module's public API. Which of these applies depends on the stack. A key nuance from prompt 3.1: do NOT silently fix the violations you find. Record them in docs/quality.md as prioritized tech debt; fix on the spot only what takes < 30 minutes and isn't blocking.
Phase 3 spine — what must be checked
Strict type mode on at the max reasonable for the project
Allowed dependency directions between layers
No cycles between modules
No orphan modules (dead code)
No imports bypassing the public API
Violations logged in docs/quality.md, not "silently fixed"
Bulk auto-fixing violations without logging them
Strict types and boundaries give the agent legibility of the environment: the tighter the signatures and the clearer the layers, the less the agent improvises context — and the rarer it breaks for no reason.
4
A test-integrity tripwire, not "100% coverage"
The most common mistake at this phase is introducing coverage as a mandate. That's Goodhart: the moment the percentage becomes the target, the agent writes fake tests for the number and the signal dies. So don't present a percentage as a goal.
What to put in place of a mandate is a test-integrity barrier (tripwire). It's a diff-based check in CI: the agent is forbidden to weaken, delete, skip, or loosen existing tests just to make them pass. Drop an assert, add a .skip, loosen a matcher, remove a case — CI fails. Here tests are a health signal, not a target; coverage is a side effect of honest work, not a goal. If you really want a percentage gate, apply it only to new/changed code (≈80% is a reasonable default, but that's your choice, not a mandate), plus mutation testing for signal quality.
✅ Test-integrity tripwire
Can't delete/skip existing tests
Can't loosen asserts and matchers
Checked by diff in CI
Tests = a health signal
❌ Coverage mandate (Goodhart)
"100% everywhere" as a global rule
The percentage becomes the target
The agent writes fake tests for the number
The signal dies, the dashboard is green
Coverage is a side effect of honest work, not a target. A single "don't weaken the tests" tripwire protects the signal better than any percentage: a number can be gamed, an integrity diff cannot.
5
Enforcement points: pre-commit + CI (copyable config)
A barrier only works where it can't be bypassed. Two enforcement points: pre-commit (fast, diff-scoped, catches before the commit) and CI (full, lets nothing through on merge). On pre-commit hang the formatter, main linter, boundary linter, and type-check — but only on touched files, not the whole codebase, or the hook becomes unbearably slow and people start bypassing it with --no-verify. CI runs the whole thing and makes bypassing impossible.
Definition of Done for Phase 3: pre-commit fails on a boundary violation, CI fails on a boundary violation, docs/quality.md holds a prioritized registry of known violations. Below is the copyable prompt 3.1 for the agent and a generic pre-commit config. The concrete orchestrator (pre-commit framework, husky, lefthook) is chosen for the stack at Phase 0 and pinned in an ADR.
# ── Промпт 3.1 для агента — границы модулей / Prompt 3.1 — module boundaries ──
Phase 3. Прочитай docs/architecture.md (Layer model) и
docs/decisions/0001-tool-selection.md (роль "Линтер границ модулей").
Задача: превратить layer model в машинно-проверяемые правила,
используя инструмент, выбранный на Phase 0.
1. Сконфигурируй инструмент. Конфиг отражает ВСЕ правила Layer model:
- разрешённые направления зависимостей между слоями
- запрет циклов между модулями
- запрет orphan-модулей (мёртвый код, не импортируемый ниоткуда)
- запрет импортов в обход публичного API
(что применимо — зависит от стека).
2. Запусти на текущей кодовой базе. Покажи отчёт.
3. НЕ правь нарушения молча. Запиши их в docs/quality.md как
техдолг с приоритетами. Чини только то, что < 30 минут и не блокирует.
4. Подключи запуск как обязательный шаг на коммит/пуш (pre-commit
hook / husky / lefthook — уместный под стек) и в CI.
Обнови Progress, Surprises & Discoveries, Decision Log.
# ── Генерик-конфиг pre-commit (только diff!) / generic pre-commit (diff only!) ──
repos:
- repo: local
hooks:
- id: format # форматтер / formatter
entry: <project-formatter>
- id: lint # основной линтер / main linter
entry: <project-linter>
- id: boundaries # границы импортов / import boundaries
entry: <boundary-linter> # dep-cruiser / import-linter
- id: typecheck # строгие типы по затронутым файлам
entry: <type-checker --changed> # tsc / mypy
# CI повторяет всё это на ПОЛНОЙ базе — обход через --no-verify не спасёт.
pre-commit is diff-scoped, CI is full. If you hang everything on pre-commit as a full run, the agent (and human) start pressing --no-verify, and the barrier quietly degrades into a convention.
6
Your own scripts for the uncovered + what comes next
Off-the-shelf tools cover the typical roles, but every project has its own rules with no linter: 'no imports from infra in the domain layer', 'no console.log in this folder', 'every public handler has a contract schema'. That's the last row of the table: whatever isn't covered, you write as your own check script and hang it in the same pre-commit + CI combo. A script is just as much a barrier if it exits non-zero.
The boundary of harness engineering is honest: these barriers give architectural integrity and maintainability but do NOT validate functional correctness — that the code does what the user needs is still checked by a human and product tests. Phase 3 is closed when the DoD is met. Next: Phase 4 (quality.md as a living document where coverage is recorded as a signal, not a goal) and Phase 5 — harness garbage collection: scheduled automated drift checks so the barriers don't rust.
#!/usr/bin/env bash
# check-boundaries-custom.sh — барьер для правила, на которое нет готового линтера
# Пример: в domain-слое нет импортов из infra / no infra imports in the domain layer.
set -euo pipefail
# Адаптируй grep/AST-инструмент под свой стек — суть в exit code.
VIOLATIONS=$(grep -rEn "from ['\"].*infra" src/domain/ || true)
if [ -n "$VIOLATIONS" ]; then
echo "❌ domain-слой импортирует infra (нарушение Layer model):"
echo "$VIOLATIONS"
exit 1 # ненулевой код → pre-commit и CI падают / pre-commit & CI fail
fi
echo "✅ boundaries: domain чист от infra-импортов"
# Что НЕ ловит ни один из этих барьеров:
# функциональная корректность — «делает ли код то, что нужно пользователю».
# Это остаётся на человеке и продуктовых тестах. Открытая граница harness.
A custom script is the #1 promotion candidate from the lessons-ledger: a recurring correction ("an across-layer import again") hardens into a check and stops recurring. That's how the agent fixes its own environment.
Result
Architectural rules stopped being text and became barriers: strict types and import boundaries form the spine, a test-integrity tripwire protects the tests instead of a coverage mandate (no Goodhart), pre-commit + CI make bypassing impossible, and custom scripts close whatever isn't covered. Tools are chosen by role to fit the stack and pinned in an ADR. The Phase 3 DoD is met; next come quality.md (Phase 4) and harness garbage collection (Phase 5).