tdd-enforcer 0.2.2 → 0.2.3

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.
@@ -4,7 +4,7 @@ import { exec } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
 
6
6
  const asyncExec = promisify(exec);
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import {
9
9
  savePhaseState,
10
10
  nextPhase,
@@ -21,6 +21,320 @@ import { getNudgePrompt } from "./prompts.js";
21
21
  import { loadTddState } from "./helpers.js";
22
22
  import { tddLog } from "./log.js";
23
23
 
24
+ // ── De dependency types ─────────────────────────────────────────────────────
25
+
26
+ export interface NextPhaseDeps {
27
+ loadTddState: typeof loadTddState;
28
+ nextPhase: typeof nextPhase;
29
+ getDisallowedChanges: typeof getDisallowedChanges;
30
+ checkGate: typeof checkGate;
31
+ snapshot: typeof snapshot;
32
+ savePhaseState: typeof savePhaseState;
33
+ getNudgePrompt: typeof getNudgePrompt;
34
+ asyncExec: (command: string, options?: { cwd?: string; timeout?: number }) => Promise<{ stdout: string; stderr: string }>;
35
+ tddLog: typeof tddLog;
36
+ }
37
+
38
+ export interface PreviousPhaseDeps {
39
+ loadTddState: typeof loadTddState;
40
+ hasParent: typeof hasParent;
41
+ headMessage: typeof headMessage;
42
+ resetHard: typeof resetHard;
43
+ undoLastCommit: typeof undoLastCommit;
44
+ savePhaseState: typeof savePhaseState;
45
+ tddLog: typeof tddLog;
46
+ }
47
+
48
+ export interface TddStatusDeps {
49
+ loadTddState: typeof loadTddState;
50
+ tddLog: typeof tddLog;
51
+ }
52
+
53
+ // ── Default deps ────────────────────────────────────────────────────────────
54
+
55
+ const defaultNextPhaseDeps: NextPhaseDeps = {
56
+ loadTddState,
57
+ nextPhase,
58
+ getDisallowedChanges,
59
+ checkGate,
60
+ snapshot,
61
+ savePhaseState,
62
+ getNudgePrompt,
63
+ asyncExec,
64
+ tddLog,
65
+ };
66
+
67
+ const defaultPreviousPhaseDeps: PreviousPhaseDeps = {
68
+ loadTddState,
69
+ hasParent,
70
+ headMessage,
71
+ resetHard,
72
+ undoLastCommit,
73
+ savePhaseState,
74
+ tddLog,
75
+ };
76
+
77
+ const defaultTddStatusDeps: TddStatusDeps = {
78
+ loadTddState,
79
+ tddLog,
80
+ };
81
+
82
+ // ── executeNextPhase ────────────────────────────────────────────────────────
83
+
84
+ export async function executeNextPhase(
85
+ ctx: ExtensionContext,
86
+ deps: NextPhaseDeps = defaultNextPhaseDeps,
87
+ ): Promise<{ content: Array<{ type: string; text: string }>; details?: Record<string, unknown> }> {
88
+ const root = ctx.cwd;
89
+ const tddDir = join(root, ".pi", "tdd");
90
+ const tdd = deps.loadTddState(root);
91
+ if (!tdd.ok) {
92
+ deps.tddLog(tddDir, "WARN", "next_tdd_phase: TDD not active", { reason: tdd.reason });
93
+ return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
94
+ }
95
+ if (!tdd.state.enabled) {
96
+ deps.tddLog(tddDir, "WARN", "next_tdd_phase: TDD disabled");
97
+ return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
98
+ }
99
+
100
+ const { state, config } = tdd;
101
+ const from = state.current;
102
+ const to = deps.nextPhase(from) as Phase;
103
+
104
+ deps.tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
105
+
106
+ // 1. Allowlist check
107
+ const violations = deps.getDisallowedChanges(root, from, config);
108
+ if (violations.length > 0) {
109
+ deps.tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
110
+ from,
111
+ violations,
112
+ });
113
+ return {
114
+ content: [
115
+ {
116
+ type: "text",
117
+ text:
118
+ `BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
119
+ violations.map((f) => ` - ${f}`).join("\n") +
120
+ `\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
121
+ },
122
+ ],
123
+ details: {},
124
+ };
125
+ }
126
+
127
+ // 2. Gate check
128
+ const testRunner: TestRunner = async (commands, timeout) => {
129
+ const results = await Promise.all(
130
+ commands.map(async (cmd) => {
131
+ try {
132
+ await deps.asyncExec(cmd, { cwd: root, timeout: timeout * 1000 });
133
+ return { command: cmd, passed: true };
134
+ } catch {
135
+ return { command: cmd, passed: false };
136
+ }
137
+ }),
138
+ );
139
+
140
+ const failed = results.filter((r) => !r.passed);
141
+ if (failed.length > 0) {
142
+ return {
143
+ passed: false,
144
+ message: "Tests failed:\n" + failed.map((f) => ` - ${f.command}`).join("\n"),
145
+ };
146
+ }
147
+ return { passed: true, message: "All tests passed." };
148
+ };
149
+
150
+ const gate = await deps.checkGate(from, to, testRunner, config);
151
+ deps.tddLog(tddDir, "DEBUG", "next_tdd_phase: gate result", {
152
+ from,
153
+ to,
154
+ passed: gate.passed,
155
+ message: gate.message,
156
+ });
157
+
158
+ if (!gate.passed) {
159
+ return { content: [{ type: "text", text: gate.message }], details: {} };
160
+ }
161
+
162
+ // 3. Snapshot — label with the phase the work was done in
163
+ const hash = deps.snapshot(root, from);
164
+ deps.tddLog(tddDir, "INFO", "next_tdd_phase: snapshot created", {
165
+ from,
166
+ to,
167
+ hash,
168
+ });
169
+
170
+ // 4. Save state
171
+ state.current = to;
172
+ deps.savePhaseState(root, state);
173
+ deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
174
+
175
+ return {
176
+ content: [{ type: "text", text: deps.getNudgePrompt(to, config) }],
177
+ details: {},
178
+ };
179
+ }
180
+
181
+ // ── executePreviousPhase ────────────────────────────────────────────────────
182
+
183
+ export async function executePreviousPhase(
184
+ ctx: ExtensionContext,
185
+ deps: PreviousPhaseDeps = defaultPreviousPhaseDeps,
186
+ ): Promise<{ content: Array<{ type: string; text: string }>; details?: Record<string, unknown> }> {
187
+ const root = ctx.cwd;
188
+ const tddDir = join(root, ".pi", "tdd");
189
+ const tdd = deps.loadTddState(root);
190
+ if (!tdd.ok) {
191
+ deps.tddLog(tddDir, "WARN", "previous_tdd_phase: TDD not active", {
192
+ reason: tdd.reason,
193
+ });
194
+ return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
195
+ }
196
+ if (!tdd.state.enabled) {
197
+ deps.tddLog(tddDir, "WARN", "previous_tdd_phase: TDD disabled");
198
+ return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
199
+ }
200
+
201
+ const { state } = tdd;
202
+
203
+ if (!deps.hasParent(root)) {
204
+ deps.tddLog(tddDir, "WARN", "previous_tdd_phase: no parent commit", {
205
+ phase: state.current,
206
+ });
207
+ return {
208
+ content: [{ type: "text", text: "No previous phase to revert to." }],
209
+ details: {},
210
+ };
211
+ }
212
+
213
+ // Read phase from HEAD snapshot commit message (source of truth).
214
+ // Snapshot is labeled with the phase the work was done in, so we use
215
+ // it directly — no hardcoded phase map needed.
216
+ const headMsg = deps.headMessage(root);
217
+ const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
218
+ if (!phaseMatch) {
219
+ deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
220
+ headMsg,
221
+ });
222
+ return {
223
+ content: [
224
+ {
225
+ type: "text",
226
+ text: `HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
227
+ `The private git repo at .pi/tdd must not be manually modified. ` +
228
+ `Tampering with it will cause TDD state corruption.`,
229
+ },
230
+ ],
231
+ details: {},
232
+ };
233
+ }
234
+ const label = phaseMatch[1];
235
+ if (label !== "red" && label !== "green" && label !== "refactor") {
236
+ deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid phase label", {
237
+ headMsg,
238
+ label,
239
+ });
240
+ return {
241
+ content: [
242
+ {
243
+ type: "text",
244
+ text: `HEAD commit "${headMsg}" has unexpected label. Cannot determine previous phase.`,
245
+ },
246
+ ],
247
+ details: {},
248
+ };
249
+ }
250
+ const prevPhase: Phase = label;
251
+ deps.tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
252
+ from: state.current,
253
+ to: prevPhase,
254
+ headMsg,
255
+ });
256
+
257
+ // 1. Nuke any uncommitted changes, WT matches HEAD
258
+ deps.resetHard(root);
259
+ deps.tddLog(tddDir, "DEBUG", "previous_tdd_phase: resetHard done");
260
+
261
+ // 2. Pop last snapshot commit, keep its content as unstaged
262
+ deps.undoLastCommit(root);
263
+ deps.tddLog(tddDir, "DEBUG", "previous_tdd_phase: undoLastCommit done");
264
+
265
+ // 3. Update phase label from the snapshot's own label
266
+ state.current = prevPhase;
267
+ deps.savePhaseState(root, state);
268
+ deps.tddLog(tddDir, "INFO", "previous_tdd_phase: complete", {
269
+ to: prevPhase,
270
+ });
271
+
272
+ return {
273
+ content: [
274
+ {
275
+ type: "text",
276
+ text: `Reverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
277
+ },
278
+ ],
279
+ details: {},
280
+ };
281
+ }
282
+
283
+ // ── executeTddStatus ────────────────────────────────────────────────────────
284
+
285
+ export async function executeTddStatus(
286
+ ctx: ExtensionContext,
287
+ deps: TddStatusDeps = defaultTddStatusDeps,
288
+ ): Promise<{ content: Array<{ type: string; text: string }>; details?: Record<string, unknown> }> {
289
+ const root = ctx.cwd;
290
+ const tddDir = join(root, ".pi", "tdd");
291
+ const result = deps.loadTddState(root);
292
+
293
+ if (!result.ok) {
294
+ deps.tddLog(tddDir, "WARN", "tdd_status: TDD not active", {
295
+ reason: result.reason,
296
+ });
297
+ return { content: [{ type: "text", text: `TDD: ${result.reason}` }], details: {} };
298
+ }
299
+ if (!result.state.enabled) {
300
+ deps.tddLog(tddDir, "WARN", "tdd_status: TDD disabled");
301
+ return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
302
+ }
303
+
304
+ const { state, config } = result;
305
+ const phaseStr = state.current.toUpperCase();
306
+ const redGlobs = config.allowedRedPhaseFiles.join(", ") || "(none)";
307
+ const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
308
+ const commands = config.testCommands.join(", ") || "(none)";
309
+
310
+ deps.tddLog(tddDir, "INFO", "tdd_status: queried", {
311
+ phase: state.current,
312
+ });
313
+
314
+ return {
315
+ content: [
316
+ {
317
+ type: "text",
318
+ text:
319
+ `TDD enforcer enabled\n` +
320
+ `Current phase: ${phaseStr}\n` +
321
+ `Test files: ${redGlobs}\n` +
322
+ `Impl files: ${greenGlobs}\n` +
323
+ `Test commands: ${commands}`,
324
+ },
325
+ ],
326
+ details: {
327
+ enabled: true,
328
+ phase: state.current,
329
+ allowedRedPhaseFiles: config.allowedRedPhaseFiles,
330
+ allowedGreenPhaseFiles: config.allowedGreenPhaseFiles,
331
+ testCommands: config.testCommands,
332
+ },
333
+ };
334
+ }
335
+
336
+ // ── registerTools ───────────────────────────────────────────────────────────
337
+
24
338
  export function registerTools(pi: ExtensionAPI): void {
25
339
  pi.registerTool({
26
340
  name: "next_tdd_phase",
@@ -29,98 +343,8 @@ export function registerTools(pi: ExtensionAPI): void {
29
343
  "Advance to the next TDD phase. Runs transition gates (test pass/fail checks) " +
30
344
  "and allowlist validation (no forbidden files modified).",
31
345
  parameters: Type.Object({}),
32
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
33
- const root = ctx.cwd;
34
- const tddDir = join(root, ".pi", "tdd");
35
- const tdd = loadTddState(root);
36
- if (!tdd.ok) {
37
- tddLog(tddDir, "WARN", "next_tdd_phase: TDD not active", { reason: tdd.reason });
38
- return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
39
- }
40
- if (!tdd.state.enabled) {
41
- tddLog(tddDir, "WARN", "next_tdd_phase: TDD disabled");
42
- return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
43
- }
44
-
45
- const { state, config } = tdd;
46
- const from = state.current;
47
- const to = nextPhase(from);
48
-
49
- tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
50
-
51
- // 1. Allowlist check
52
- const violations = getDisallowedChanges(root, from, config);
53
- if (violations.length > 0) {
54
- tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
55
- from,
56
- violations,
57
- });
58
- return {
59
- content: [
60
- {
61
- type: "text",
62
- text:
63
- `BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
64
- violations.map((f) => ` - ${f}`).join("\n") +
65
- `\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
66
- },
67
- ],
68
- details: {},
69
- };
70
- }
71
-
72
- // 2. Gate check
73
- const testRunner: TestRunner = async (commands, timeout) => {
74
- const results = await Promise.all(
75
- commands.map(async (cmd) => {
76
- try {
77
- await asyncExec(cmd, { cwd: root, timeout: timeout * 1000 });
78
- return { command: cmd, passed: true };
79
- } catch {
80
- return { command: cmd, passed: false };
81
- }
82
- }),
83
- );
84
-
85
- const failed = results.filter((r) => !r.passed);
86
- if (failed.length > 0) {
87
- return {
88
- passed: false,
89
- message: "Tests failed:\n" + failed.map((f) => ` - ${f.command}`).join("\n"),
90
- };
91
- }
92
- return { passed: true, message: "All tests passed." };
93
- };
94
-
95
- const gate = await checkGate(from, to, testRunner, config);
96
- tddLog(tddDir, "DEBUG", "next_tdd_phase: gate result", {
97
- from,
98
- to,
99
- passed: gate.passed,
100
- message: gate.message,
101
- });
102
-
103
- if (!gate.passed) {
104
- return { content: [{ type: "text", text: gate.message }], details: {} };
105
- }
106
-
107
- // 3. Snapshot — label with the phase the work was done in
108
- const hash = snapshot(root, from);
109
- tddLog(tddDir, "INFO", "next_tdd_phase: snapshot created", {
110
- from,
111
- to,
112
- hash,
113
- });
114
-
115
- // 4. Save state
116
- state.current = to;
117
- savePhaseState(root, state);
118
- tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
119
-
120
- return {
121
- content: [{ type: "text", text: getNudgePrompt(to, config) }],
122
- details: {},
123
- };
346
+ execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
347
+ return executeNextPhase(ctx, defaultNextPhaseDeps);
124
348
  },
125
349
  });
126
350
 
@@ -132,85 +356,8 @@ export function registerTools(pi: ExtensionAPI): void {
132
356
  "to what it was when the last phase ended. Use when the previous phase's work was wrong " +
133
357
  "and this phase cannot proceed.",
134
358
  parameters: Type.Object({}),
135
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
136
- const root = ctx.cwd;
137
- const tddDir = join(root, ".pi", "tdd");
138
- const tdd = loadTddState(root);
139
- if (!tdd.ok) {
140
- tddLog(tddDir, "WARN", "previous_tdd_phase: TDD not active", {
141
- reason: tdd.reason,
142
- });
143
- return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
144
- }
145
- if (!tdd.state.enabled) {
146
- tddLog(tddDir, "WARN", "previous_tdd_phase: TDD disabled");
147
- return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
148
- }
149
-
150
- const { state } = tdd;
151
-
152
- if (!hasParent(root)) {
153
- tddLog(tddDir, "WARN", "previous_tdd_phase: no parent commit", {
154
- phase: state.current,
155
- });
156
- return {
157
- content: [{ type: "text", text: "No previous phase to revert to." }],
158
- details: {},
159
- };
160
- }
161
-
162
- // Read phase from HEAD snapshot commit message (source of truth).
163
- // Snapshot is labeled with the phase the work was done in, so we use
164
- // it directly — no hardcoded phase map needed.
165
- const headMsg = headMessage(root);
166
- const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
167
- if (!phaseMatch) {
168
- tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
169
- headMsg,
170
- });
171
- return {
172
- content: [
173
- {
174
- type: "text",
175
- text: `HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
176
- `The private git repo at .pi/tdd must not be manually modified. ` +
177
- `Tampering with it will cause TDD state corruption.`,
178
- },
179
- ],
180
- details: {},
181
- };
182
- }
183
- const prevPhase = phaseMatch[1] as Phase;
184
- tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
185
- from: state.current,
186
- to: prevPhase,
187
- headMsg,
188
- });
189
-
190
- // 1. Nuke any uncommitted changes, WT matches HEAD
191
- resetHard(root);
192
- tddLog(tddDir, "DEBUG", "previous_tdd_phase: resetHard done");
193
-
194
- // 2. Pop last snapshot commit, keep its content as unstaged
195
- undoLastCommit(root);
196
- tddLog(tddDir, "DEBUG", "previous_tdd_phase: undoLastCommit done");
197
-
198
- // 3. Update phase label from the snapshot's own label
199
- state.current = prevPhase;
200
- savePhaseState(root, state);
201
- tddLog(tddDir, "INFO", "previous_tdd_phase: complete", {
202
- to: prevPhase,
203
- });
204
-
205
- return {
206
- content: [
207
- {
208
- type: "text",
209
- text: `Reverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
210
- },
211
- ],
212
- details: {},
213
- };
359
+ execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
360
+ return executePreviousPhase(ctx, defaultPreviousPhaseDeps);
214
361
  },
215
362
  });
216
363
 
@@ -221,52 +368,8 @@ export function registerTools(pi: ExtensionAPI): void {
221
368
  "Show the current TDD enforcement status: enabled/disabled, current phase, " +
222
369
  "allowed file globs, and test commands.",
223
370
  parameters: Type.Object({}),
224
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
225
- const root = ctx.cwd;
226
- const tddDir = join(root, ".pi", "tdd");
227
- const result = loadTddState(root);
228
-
229
- if (!result.ok) {
230
- tddLog(tddDir, "WARN", "tdd_status: TDD not active", {
231
- reason: result.reason,
232
- });
233
- return { content: [{ type: "text", text: `TDD: ${result.reason}` }], details: {} };
234
- }
235
- if (!result.state.enabled) {
236
- tddLog(tddDir, "WARN", "tdd_status: TDD disabled");
237
- return { content: [{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." }], details: {} };
238
- }
239
-
240
- const { state, config } = result;
241
- const phaseStr = state.current.toUpperCase();
242
- const redGlobs = config.allowedRedPhaseFiles.join(", ") || "(none)";
243
- const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
244
- const commands = config.testCommands.join(", ") || "(none)";
245
-
246
- tddLog(tddDir, "INFO", "tdd_status: queried", {
247
- phase: state.current,
248
- });
249
-
250
- return {
251
- content: [
252
- {
253
- type: "text",
254
- text:
255
- `TDD enforcer enabled\n` +
256
- `Current phase: ${phaseStr}\n` +
257
- `Test files: ${redGlobs}\n` +
258
- `Impl files: ${greenGlobs}\n` +
259
- `Test commands: ${commands}`,
260
- },
261
- ],
262
- details: {
263
- enabled: true,
264
- phase: state.current,
265
- allowedRedPhaseFiles: config.allowedRedPhaseFiles,
266
- allowedGreenPhaseFiles: config.allowedGreenPhaseFiles,
267
- testCommands: config.testCommands,
268
- },
269
- };
371
+ execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
372
+ return executeTddStatus(ctx, defaultTddStatusDeps);
270
373
  },
271
374
  });
272
375
  }
package/behaviour.md CHANGED
@@ -1,20 +1,35 @@
1
1
  # TDD Enforcer — Behaviour Spec
2
2
 
3
- ## Entry Gate (all surfaces)
3
+ ## Entry Gate — `loadTddState(root)` (all surfaces)
4
4
 
5
5
  Every surface (commands, hooks, tools) hits this first:
6
6
 
7
7
  ```
8
- 1. .pi/tdd/ exists?
9
- NO ──► error: "Missing .pi/tdd/. See the tdd-enforcer skill."
10
-
11
- 2. rules.json exists and parses?
12
- NO ──► error: "Missing/invalid rules.json. See the tdd-enforcer skill."
13
-
14
- 3. state.json exists and parses?
15
- YES ──► use it
16
- NO ──► auto-create from git HEAD commit message.
17
- If no git repo: create { enabled: false, current: "red" }.
8
+ loadTddState(root):
9
+
10
+ ├── .pi/tdd/ exists? NO → "Missing .pi/tdd/. See the tdd-enforcer skill."
11
+
12
+ ├── rules.json exists? NO "Missing .pi/tdd/rules.json. See the tdd-enforcer skill."
13
+
14
+ ├── rules.json valid? NO → "Invalid .pi/tdd/rules.json. See the tdd-enforcer skill."
15
+
16
+ ├── .pi/tdd/.git/ exists? NO initGit(root)
17
+ │ FAIL "Failed to initialise private git repo."
18
+
19
+ ├── state.json exists? NO → recoverState()
20
+ │ │
21
+ │ └── recoverState():
22
+ │ ├── .git exists? + headMessage readable?
23
+ │ │ ├── "tdd: init" → { enabled: false, current: "red" }
24
+ │ │ ├── "tdd: red" → { enabled: true, current: "green" }
25
+ │ │ ├── "tdd: green" → { enabled: true, current: "refactor" }
26
+ │ │ ├── "tdd: refactor" → { enabled: true, current: "red" }
27
+ │ │ └── no match / error → { enabled: false, current: "red" }
28
+ │ └── no .git / no commits → { enabled: false, current: "red" }
29
+
30
+ ├── state.json valid? NO → recoverState() (same as above)
31
+
32
+ └── return { ok: true, state, config }
18
33
  ```
19
34
 
20
35
  After this gate, every surface has `state` + `config`. Then branches on `state.enabled`.