Skip to content

Commit acac672

Browse files
feat(test-deletion-guardrail): block unjustified test removal in tdd-cycle + verify (#13)
* feat(scripts): check-test-count guardrail script + fixtures Implements the comparison + gate portion of specs/test-deletion-guardrail.md. scripts/check-test-count.py takes RED/GREEN test-surface snapshots, classifies deltas as added/removed/renamed/replaced, and exits non-zero when tests were removed without an [ADD-TEST-DELETE: ...] commit trailer or an overrides.json record. One-shot --baseline <ref> form is the entry point invoked by /add:verify Gate 3.5. Ships: - scripts/check-test-count.py (stdlib-only; snapshot / compare / gate subcmds) - core/lib/impact-hint.sh (pure shell + jq + grep; AC-018..AC-024) - core/knowledge/test-discovery-patterns.json (Python/TS/JS/Go/Ruby/Rust catalog) - tests/test-deletion-guardrail/ — 15 fixture tests covering the gate matrix - scripts/compile.py now copies core/lib/ and core/knowledge/ into both runtimes Acceptance criteria covered: AC-001..AC-024, AC-028. AC-025..AC-027 (telemetry) are deferred to the companion telemetry-jsonl spec. Part of M3 pre-GA hardening. Spec: specs/test-deletion-guardrail.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skills): wire test-deletion guardrail into tdd-cycle + verify Adds snapshot + gate hooks to the TDD sub-agent chain so the guardrail shipped in the previous commit actually runs during a /add:tdd-cycle: - /add:test-writer: capture RED snapshot + commit with standard message (AC-005); fail if zero tests added (AC-006) - /add:tdd-cycle: emit files-likely-affected hint before GREEN; capture GREEN snapshot after GREEN; new --allow-test-rewrite flag for approved rewrites - /add:implementer: consume hint in pre-flight (AC-022); explicit NEVER-delete directive (AC-028); capture GREEN snapshot (AC-007) - /add:verify: new Gate 3.5 "Test Surface Integrity" between Gate 3 and Gate 4; skips cleanly when no cycle dir exists Part of M3 pre-GA hardening. Spec: specs/test-deletion-guardrail.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(rules): tdd-enforcement test-deletion invariant Adds the Test-Deletion Invariant section to the auto-loaded TDD rule (AC-028): "Tests added during RED MUST exist (passing) at the end of GREEN. Test deletion during the cycle is forbidden without --allow-test-rewrite and explicit human approval. Test renames are permitted; test replacements require approval." Documents both accepted justification-marker formats: 1. Commit trailer: [ADD-TEST-DELETE: <AC-id or reason>] 2. Per-cycle file: .add/cycles/cycle-{N}/overrides.json with kind:test-rewrite Both land in telemetry for retro review. Cross-references the enforcement points in /add:test-writer, /add:implementer, and /add:verify Gate 3.5. Part of M3 pre-GA hardening. Spec: specs/test-deletion-guardrail.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5270dd4 commit acac672

48 files changed

Lines changed: 4213 additions & 7 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ For commit-level detail see `git log`.
1111
- **`/add:agents-md` skill** — generates a tool-portable `AGENTS.md` at project root from `.add/` state. Maturity-aware verbosity (POC bullets → Alpha sectioned → Beta full → GA full + team conventions). ADD-managed content wrapped in `<!-- ADD:MANAGED:START … -->` markers so user-authored sections survive regeneration. Modes: `--write` (default), `--check` (CI drift gate, exit 1 on drift), `--merge` / `--import` (absorb hand-curated files). Implemented as `scripts/generate-agents-md.py` plus `core/skills/agents-md/SKILL.md`; fixture tests cover POC/Alpha/Beta render, drift detection, merge flow, idempotency, and staleness-marker clearing. Integrated into `/add:init` (initial generation) and `/add:spec` (active-spec pointer update). Opt-in `agentsMd.gateOnVerify` in `.add/config.json` enables Gate 4.5 in `/add:verify`.
1212
- **PostToolUse staleness hook for AGENTS.md**`runtimes/claude/hooks/post-write.sh` now writes `.add/agents-md.stale` when `.add/config.json`, `core/rules/*.md`, or `core/skills/*/SKILL.md` changes and an `AGENTS.md` exists at root. The hook never auto-rewrites AGENTS.md — the human triggers regen.
1313
- **Prompt-injection defense** (spec `prompt-injection-defense`, M3 Cycle 2) — three-layer GA security story. New auto-loaded rule `core/rules/injection-defense.md` teaches the agent to treat untrusted content (PR comments, web fetches, foreign repos, `node_modules`) as data, never as instructions. New PostToolUse scan hook `runtimes/claude/hooks/posttooluse-scan.sh` pattern-matches tool output (Read, WebFetch, WebSearch, Bash) against `core/security/patterns.json` — eight named patterns covering OWASP Top 10 Agentic 2026, Snyk ToxicSkills, and the January 2026 Comment-and-Control attack. Audit events append to `.add/security/injection-events.jsonl`. New Tier-1 knowledge file `core/knowledge/threat-model.md` documents trust boundaries, defended attacks (T1-T5), out-of-scope threats, and warn-only posture for v0.9. Users can extend without forking via `.add/security/patterns.json` (project) or `~/.claude/add/security/patterns.json` (workstation).
14+
- **Test-deletion guardrail** ([`specs/test-deletion-guardrail.md`](specs/test-deletion-guardrail.md), M3 Cycle 3). Defends ADD's signature TDD claim against the Kent Beck / TDAD-paper failure mode ("the genie doesn't want to do TDD — it deletes the failing test"). New `scripts/check-test-count.py` snapshot/compare/gate CLI plus `core/lib/impact-hint.sh` files-likely-affected helper. New Gate 3.5 in `/add:verify` fails the cycle if `tests_removed > 0` without a recorded override. Renames (same body, new name) allowed; replacements (same name, rewritten body) require `--allow-test-rewrite` + human approval persisted in `.add/cycles/cycle-{N}/overrides.json`. Justification marker `[ADD-TEST-DELETE: <reason>]` also accepted as commit trailer. New `core/knowledge/test-discovery-patterns.json` catalog covers Python, TS/JS, Go, Ruby, Rust — extensible per project.
1415

1516
### Changed
1617

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
{
2+
"_meta": {
3+
"version": "0.1.0",
4+
"added": "v0.9.0",
5+
"spec": "specs/test-deletion-guardrail.md",
6+
"purpose": "Per-language regex catalog for discovering test functions inside test files. Used by test-surface-snapshot.sh and scripts/check-test-count.py. Users may extend for additional frameworks (bun:test, vitest variants, pytest plugins) by copying this file into their project's knowledge directory and PRing back.",
7+
"notes": [
8+
"Patterns match the *declaration line* of a test function; the body is then scanned until a dedent/close-brace.",
9+
"`function_name_group` indexes the regex capture group that contains the function / describe / it name. 1-based.",
10+
"`file_globs` filters files the catalog is considered for — a first-pass optimization.",
11+
"Body normalization strips whitespace, line-comments (`#` or `//`), and the function-name token itself before SHA-1 hashing."
12+
]
13+
},
14+
"languages": {
15+
"python": {
16+
"file_globs": ["**/test_*.py", "**/*_test.py", "**/tests/**/*.py"],
17+
"patterns": [
18+
{
19+
"regex": "^\\s*def\\s+(test_\\w+)\\s*\\(",
20+
"function_name_group": 1,
21+
"description": "pytest / unittest function"
22+
},
23+
{
24+
"regex": "^\\s*async\\s+def\\s+(test_\\w+)\\s*\\(",
25+
"function_name_group": 1,
26+
"description": "pytest async function"
27+
}
28+
],
29+
"body_terminator": "dedent_to_def_level"
30+
},
31+
"typescript": {
32+
"file_globs": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/__tests__/**/*.ts", "**/__tests__/**/*.tsx"],
33+
"patterns": [
34+
{
35+
"regex": "\\bit\\s*\\(\\s*['\"`]([^'\"`]+)['\"`]",
36+
"function_name_group": 1,
37+
"description": "jest/vitest/mocha it()"
38+
},
39+
{
40+
"regex": "\\btest\\s*\\(\\s*['\"`]([^'\"`]+)['\"`]",
41+
"function_name_group": 1,
42+
"description": "jest/vitest test()"
43+
},
44+
{
45+
"regex": "\\bdescribe\\s*\\(\\s*['\"`]([^'\"`]+)['\"`]",
46+
"function_name_group": 1,
47+
"description": "describe() block (counted for grouping, not as a leaf test)",
48+
"is_group": true
49+
}
50+
],
51+
"body_terminator": "balanced_braces"
52+
},
53+
"javascript": {
54+
"file_globs": ["**/*.test.js", "**/*.test.jsx", "**/*.spec.js", "**/*.spec.jsx", "**/__tests__/**/*.js", "**/__tests__/**/*.jsx"],
55+
"patterns": [
56+
{
57+
"regex": "\\bit\\s*\\(\\s*['\"`]([^'\"`]+)['\"`]",
58+
"function_name_group": 1,
59+
"description": "jest/vitest/mocha it()"
60+
},
61+
{
62+
"regex": "\\btest\\s*\\(\\s*['\"`]([^'\"`]+)['\"`]",
63+
"function_name_group": 1,
64+
"description": "jest/vitest test()"
65+
},
66+
{
67+
"regex": "\\bdescribe\\s*\\(\\s*['\"`]([^'\"`]+)['\"`]",
68+
"function_name_group": 1,
69+
"description": "describe() block",
70+
"is_group": true
71+
}
72+
],
73+
"body_terminator": "balanced_braces"
74+
},
75+
"go": {
76+
"file_globs": ["**/*_test.go"],
77+
"patterns": [
78+
{
79+
"regex": "^func\\s+(Test\\w+)\\s*\\(",
80+
"function_name_group": 1,
81+
"description": "standard Go test function"
82+
},
83+
{
84+
"regex": "^func\\s+(Benchmark\\w+)\\s*\\(",
85+
"function_name_group": 1,
86+
"description": "Go benchmark function"
87+
}
88+
],
89+
"body_terminator": "balanced_braces"
90+
},
91+
"ruby": {
92+
"file_globs": ["**/test_*.rb", "**/*_test.rb", "**/*_spec.rb", "**/spec/**/*.rb", "**/test/**/*.rb"],
93+
"patterns": [
94+
{
95+
"regex": "^\\s*def\\s+(test_\\w+)",
96+
"function_name_group": 1,
97+
"description": "Minitest / Test::Unit function"
98+
},
99+
{
100+
"regex": "\\bit\\s+['\"]([^'\"]+)['\"]",
101+
"function_name_group": 1,
102+
"description": "RSpec it block"
103+
}
104+
],
105+
"body_terminator": "end_keyword"
106+
},
107+
"rust": {
108+
"file_globs": ["**/*.rs"],
109+
"patterns": [
110+
{
111+
"regex": "#\\[test\\]\\s*(?:\\n\\s*#\\[[^\\]]+\\])*\\s*\\n\\s*(?:pub\\s+)?(?:async\\s+)?fn\\s+(\\w+)",
112+
"function_name_group": 1,
113+
"description": "Rust #[test] attribute"
114+
},
115+
{
116+
"regex": "#\\[tokio::test\\]\\s*\\n\\s*(?:pub\\s+)?async\\s+fn\\s+(\\w+)",
117+
"function_name_group": 1,
118+
"description": "Tokio async test attribute"
119+
}
120+
],
121+
"body_terminator": "balanced_braces"
122+
}
123+
},
124+
"import_patterns": {
125+
"python": [
126+
{"regex": "^\\s*from\\s+([\\w\\.]+)\\s+import\\s+", "target_group": 1, "resolution": "dots_to_slashes_plus_py"},
127+
{"regex": "^\\s*import\\s+([\\w\\.]+)", "target_group": 1, "resolution": "dots_to_slashes_plus_py"}
128+
],
129+
"typescript": [
130+
{"regex": "from\\s+['\"]([\\./][^'\"]+)['\"]", "target_group": 1, "resolution": "relative_path"},
131+
{"regex": "require\\s*\\(\\s*['\"]([\\./][^'\"]+)['\"]\\s*\\)", "target_group": 1, "resolution": "relative_path"}
132+
],
133+
"javascript": [
134+
{"regex": "from\\s+['\"]([\\./][^'\"]+)['\"]", "target_group": 1, "resolution": "relative_path"},
135+
{"regex": "require\\s*\\(\\s*['\"]([\\./][^'\"]+)['\"]\\s*\\)", "target_group": 1, "resolution": "relative_path"}
136+
],
137+
"go": [
138+
{"regex": "import\\s+['\"]([^'\"]+)['\"]", "target_group": 1, "resolution": "go_module"}
139+
],
140+
"ruby": [
141+
{"regex": "require(?:_relative)?\\s+['\"]([^'\"]+)['\"]", "target_group": 1, "resolution": "rb_require"}
142+
],
143+
"rust": [
144+
{"regex": "^\\s*use\\s+(crate::[\\w:]+)", "target_group": 1, "resolution": "rust_crate_path"}
145+
]
146+
}
147+
}

core/lib/impact-hint.sh

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env bash
2+
# impact-hint.sh — Files-likely-affected hint for the /add:implementer skill.
3+
#
4+
# Implements section D of specs/test-deletion-guardrail.md (AC-018..AC-024).
5+
#
6+
# Pure shell + jq + grep. No graph libraries, no AST parsers. The hint is
7+
# intentionally lightweight and lossy — false positives are preferred over
8+
# false negatives; the implementer uses this as a starting point, not a
9+
# ground truth.
10+
#
11+
# Usage:
12+
# core/lib/impact-hint.sh <base-sha> <spec-path> [project-root]
13+
#
14+
# Emits to stdout a structured prompt block:
15+
#
16+
# Files likely to need changes:
17+
# - path/a.py
18+
# - path/b.ts
19+
# Files to be careful around (recent anti-pattern learnings exist):
20+
# - path/c.py [L-042]
21+
#
22+
# Exit codes:
23+
# 0 — hint produced (even if lists are empty)
24+
# 1 — invocation error
25+
26+
set -euo pipefail
27+
28+
BASE_SHA="${1:-}"
29+
SPEC_PATH="${2:-}"
30+
PROJECT_ROOT="${3:-$(pwd)}"
31+
32+
if [ -z "$BASE_SHA" ] || [ -z "$SPEC_PATH" ]; then
33+
echo "Usage: impact-hint.sh <base-sha> <spec-path> [project-root]" >&2
34+
exit 1
35+
fi
36+
37+
cd "$PROJECT_ROOT"
38+
39+
# -- AC-018: diff between base and HEAD to find test files changed --
40+
CHANGED_FILES=$(git diff --name-only "$BASE_SHA"..HEAD 2>/dev/null || true)
41+
42+
# -- Filter to test files (heuristic: path includes "test" or "spec", or suffix matches) --
43+
TEST_FILES=$(printf '%s\n' "$CHANGED_FILES" | grep -E \
44+
'(^test_|_test\.|\.test\.|\.spec\.|/tests?/|/__tests__/|/spec/)' || true)
45+
46+
# -- AC-019: regex-extract imports and resolve to local paths --
47+
IMPORT_PATHS=$(mktemp)
48+
trap 'rm -f "$IMPORT_PATHS" "$SPEC_PATHS" "$LEARNING_PATHS" "$CANDIDATES"' EXIT
49+
: > "$IMPORT_PATHS"
50+
51+
if [ -n "$TEST_FILES" ]; then
52+
while IFS= read -r tf; do
53+
[ -z "$tf" ] && continue
54+
[ ! -f "$tf" ] && continue
55+
ext="${tf##*.}"
56+
case "$ext" in
57+
py)
58+
# from X.Y import Z --> X/Y.py
59+
grep -E "^\s*from\s+[\w\.]+\s+import\s+" "$tf" 2>/dev/null | \
60+
sed -E 's/^\s*from\s+([\w\.]+)\s+import\s+.*/\1/' | \
61+
tr '.' '/' | sed 's/$/.py/' >> "$IMPORT_PATHS" || true
62+
# import X.Y --> X/Y.py
63+
grep -E "^\s*import\s+[\w\.]+" "$tf" 2>/dev/null | \
64+
sed -E 's/^\s*import\s+([\w\.]+).*/\1/' | \
65+
tr '.' '/' | sed 's/$/.py/' >> "$IMPORT_PATHS" || true
66+
;;
67+
ts|tsx|js|jsx|mjs|cjs)
68+
# from './x/y' or require('./x/y') — capture the relative path
69+
grep -E "from\s+['\"][\./][^'\"]+['\"]" "$tf" 2>/dev/null | \
70+
sed -E "s/.*from\s+['\"]([\./][^'\"]+)['\"].*/\1/" >> "$IMPORT_PATHS" || true
71+
grep -E "require\s*\(\s*['\"][\./][^'\"]+['\"]\s*\)" "$tf" 2>/dev/null | \
72+
sed -E "s/.*require\s*\(\s*['\"]([\./][^'\"]+)['\"]\s*\).*/\1/" >> "$IMPORT_PATHS" || true
73+
;;
74+
go)
75+
grep -E "^\s*import\s+\"[^\"]+\"" "$tf" 2>/dev/null | \
76+
sed -E 's/^\s*import\s+"([^"]+)".*/\1/' >> "$IMPORT_PATHS" || true
77+
;;
78+
rb)
79+
grep -E "^\s*require(_relative)?\s+['\"][^'\"]+['\"]" "$tf" 2>/dev/null | \
80+
sed -E "s/.*require(_relative)?\s+['\"]([^'\"]+)['\"].*/\2/" >> "$IMPORT_PATHS" || true
81+
;;
82+
rs)
83+
grep -E "^\s*use\s+crate::" "$tf" 2>/dev/null | \
84+
sed -E 's|^\s*use\s+crate::([a-zA-Z_0-9:]+).*|\1|' | \
85+
sed 's|::|/|g' | sed 's/$/.rs/' >> "$IMPORT_PATHS" || true
86+
;;
87+
esac
88+
done <<< "$TEST_FILES"
89+
fi
90+
91+
# Normalize: strip leading ./, drop duplicates, keep only paths that exist in repo
92+
RESOLVED_IMPORTS=$(sort -u "$IMPORT_PATHS" | while IFS= read -r p; do
93+
[ -z "$p" ] && continue
94+
p="${p#./}"
95+
# Try the path as-is, plus common extension variants
96+
for candidate in "$p" "${p%.py}.py" "${p%.ts}.ts" "${p%.js}.js" "$p/index.ts" "$p/index.js" "$p/__init__.py"; do
97+
if [ -f "$candidate" ]; then
98+
echo "$candidate"
99+
break
100+
fi
101+
done
102+
done | sort -u)
103+
104+
# -- AC-020: cross-reference literal path mentions in the spec --
105+
SPEC_PATHS=$(mktemp)
106+
: > "$SPEC_PATHS"
107+
if [ -f "$SPEC_PATH" ]; then
108+
# Extract any token matching filename.ext where ext is a known source extension
109+
grep -oE '[A-Za-z0-9_./-]+\.(py|ts|tsx|js|jsx|go|rs|rb|mjs|cjs)' "$SPEC_PATH" 2>/dev/null | \
110+
sort -u > "$SPEC_PATHS" || true
111+
fi
112+
113+
SPEC_PATHS_EXISTING=$(while IFS= read -r p; do
114+
[ -z "$p" ] && continue
115+
# Only surface paths that exist AND are not themselves test files
116+
if [ -f "$p" ]; then
117+
echo "$p" | grep -vE '(^test_|_test\.|\.test\.|\.spec\.|/tests?/|/__tests__/|/spec/)' || true
118+
fi
119+
done < "$SPEC_PATHS" | sort -u)
120+
121+
# -- Union: candidate source files --
122+
CANDIDATES=$(mktemp)
123+
{
124+
echo "$RESOLVED_IMPORTS"
125+
echo "$SPEC_PATHS_EXISTING"
126+
} | sort -u | grep -v '^$' > "$CANDIDATES" || true
127+
128+
# -- AC-021: anti-pattern learnings lookup --
129+
LEARNINGS_FILE="$PROJECT_ROOT/.add/learnings.json"
130+
LEARNING_PATHS=$(mktemp)
131+
: > "$LEARNING_PATHS"
132+
133+
if [ -f "$LEARNINGS_FILE" ] && command -v jq >/dev/null 2>&1; then
134+
# Build a { "path": "L-###" } list by scanning anti-pattern bodies
135+
while IFS= read -r candidate; do
136+
[ -z "$candidate" ] && continue
137+
# Find anti-pattern entries whose body mentions this path
138+
match=$(jq -r --arg p "$candidate" '
139+
.entries[]? // empty |
140+
select(.category == "anti-pattern") |
141+
select(.body | test($p; "i")) |
142+
.id
143+
' "$LEARNINGS_FILE" 2>/dev/null | head -1 || true)
144+
if [ -n "$match" ]; then
145+
echo "$candidate|$match" >> "$LEARNING_PATHS"
146+
fi
147+
done < "$CANDIDATES"
148+
fi
149+
150+
# -- Emit the hint --
151+
echo "## Files likely to need changes"
152+
if [ ! -s "$CANDIDATES" ]; then
153+
# AC-024: explicit no-source-files message
154+
echo " (No source files implied by RED diff. Check spec acceptance criteria"
155+
echo " for implementation targets.)"
156+
else
157+
# Exclude anti-pattern paths from this list (they get their own section)
158+
FLAGGED_PATHS=$(cut -d'|' -f1 < "$LEARNING_PATHS" 2>/dev/null | sort -u)
159+
while IFS= read -r p; do
160+
[ -z "$p" ] && continue
161+
if ! printf '%s\n' "$FLAGGED_PATHS" | grep -qxF "$p" 2>/dev/null; then
162+
echo " - $p"
163+
fi
164+
done < "$CANDIDATES"
165+
fi
166+
167+
if [ -s "$LEARNING_PATHS" ]; then
168+
echo ""
169+
echo "## Files to be careful around (recent anti-pattern learnings exist)"
170+
while IFS='|' read -r path lid; do
171+
echo " - $path [$lid]"
172+
done < "$LEARNING_PATHS"
173+
fi

core/rules/tdd-enforcement.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,63 @@ All implementation follows strict TDD. The cycle is RED → GREEN → REFACTOR
4646
- When a sub-agent implements code, the orchestrator MUST run tests independently
4747
- Each TDD cycle should be a single, atomic commit
4848

49+
## Test-Deletion Invariant (v0.9.0 / M3)
50+
51+
**Tests added during RED MUST exist (passing) at the end of GREEN.** Test deletion during
52+
the cycle is forbidden without `--allow-test-rewrite` **and** explicit human approval.
53+
Test renames are permitted (same normalized body, different function name); test
54+
replacements (same name, rewritten body beyond the similarity threshold) require
55+
approval.
56+
57+
This invariant is enforced at three points:
58+
59+
1. **End of RED**`/add:test-writer` writes a snapshot at
60+
`.add/cycles/cycle-{N}/tdd-{slug}-red.json` capturing every test function's path,
61+
name, and normalized body hash. The snapshot is committed (`test(red): snapshot N
62+
tests for {slug}`). Failure mode: if RED produces zero new tests, the cycle halts —
63+
RED with no new tests is itself a TDD violation.
64+
2. **End of GREEN**`/add:implementer` re-runs discovery against the same files and
65+
writes `.add/cycles/cycle-{N}/tdd-{slug}-green.json` with identical schema.
66+
3. **Gate 3.5 in `/add:verify`** — runs `scripts/check-test-count.py gate --red ... --green ...`.
67+
If `tests_removed > 0` without an override, or `tests_replaced > 0` without
68+
`--allow-test-rewrite`, the gate fails with a structured error listing each removed
69+
or replaced test. The cycle does not advance to Gate 4.
70+
71+
### Justification markers
72+
73+
A test deletion or replacement is authorized by **either**:
74+
75+
- A commit trailer in the range `base..HEAD` of the form
76+
`[ADD-TEST-DELETE: <AC-id or reason>]`. Used for out-of-cycle rewrites or small
77+
maintenance changes.
78+
- A file at `.add/cycles/cycle-{N}/overrides.json` of shape:
79+
80+
```json
81+
{
82+
"kind": "test-rewrite",
83+
"approved_by": "human",
84+
"timestamp": "2026-04-22T14:32:00Z",
85+
"affected_tests": ["tests/path.py::function_name"]
86+
}
87+
```
88+
89+
Either form is recorded in telemetry and surfaced in `/add:retro` for review.
90+
91+
### Rationale
92+
93+
The genie doesn't want to do TDD (Kent Beck, 2026) — the path of least resistance for a
94+
coding agent is to remove the failing test rather than satisfy it. The TDAD paper
95+
(arXiv 2603.17973) observed naive TDD-prompting *increased* regression rate to 9.94%
96+
because agents silently deleted tests they couldn't satisfy. ADD's separation of concerns
97+
(test-writer vs implementer) only matters if the tests written in RED survive GREEN.
98+
This invariant enforces that.
99+
100+
### Why both markers are accepted
101+
102+
Some TDD-cycle runs are fully scripted (`--allow-test-rewrite` with an `overrides.json`
103+
approval); others are ad-hoc developer work where a commit trailer is the lighter-weight
104+
signal. Both land in the same telemetry channel so retros can review legitimacy.
105+
49106
## Test Naming
50107

51108
Tests must reference the spec:

0 commit comments

Comments
 (0)