@ngockhoale/ukit 3.4.17 → 3.4.18

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.18 - 2026-10-06
6
+
7
+ **Stops the token-burning respawn loop at its source.**
8
+ - New `block-breaker.mjs`: the hook chain counts identical PreToolUse refusals per session
9
+ (paths/ids normalized); from the 3rd, the refusal text gains a "STOP — report verbatim, do not
10
+ retry or respawn" directive. Advisory + fail-open; off via `blockBreaker.enabled=false` or
11
+ `UKIT_BLOCK_BREAKER=0`. Loaded dynamically so a partially-updated runtime cannot crash the chain.
12
+ - Create-scope guard: read-only inline `node -e` / `python3 -c` / `jq` / heredoc analysis is no
13
+ longer misread as a shell write (arrow functions, `n>1750`, identifiers like `ln`/`cp`);
14
+ scripts that use write APIs (`writeFile`, `os.system`, `open(..,'w')`, ...) still block.
15
+ - `handoff-planner` / `handoff-create`: write PLAN.md (with `PLANNER_MODEL` footer) first, report a
16
+ refused write verbatim instead of retrying, never run in `isolation: "worktree"`.
17
+
5
18
  ## 3.4.17 - 2026-10-06
6
19
 
7
20
  **Planners can revise an unimplemented task that still carries the skeleton Executor Report.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.17",
3
+ "version": "3.4.18",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -115,6 +115,23 @@ Append this footer to `PLAN.md` — mandatory, checked by a hook before the writ
115
115
  PLANNER_MODEL: <your exact model ID — e.g. claude-opus-5>
116
116
  ```
117
117
 
118
+ **Write PLAN.md FIRST, footer included.** Your first `PLAN.md` write is a complete or skeleton
119
+ file that already ends with the footer above; refine it with `Edit` afterwards. The tier guard
120
+ validates `PLANNER_MODEL` on that write, so a refusal surfaces before you spend a long drafting
121
+ pass — never draft the whole plan in your head/scratch and attempt the first write last.
122
+
123
+ ## Blocked write = report, never retry or respawn
124
+
125
+ If a `Write`/`Edit`/`Bash` call is refused (`BLOCKED …`, `hook error`, "denied by your
126
+ permission settings"), do NOT re-attempt the same call, route around the guard with a different
127
+ tool or path, or ask the caller to spawn another planner. Stop and return, as your entire
128
+ reply: the refused tool, the target path, and the refusal text copied **verbatim**. Also stop
129
+ immediately and report if your working directory is under `.claude/worktrees/` or
130
+ `.worktrees/` (an isolated worktree is discarded when you finish, so `docs/AI_HANDOFF/**`
131
+ written there never reaches the project) — the caller must re-run you without
132
+ `isolation: "worktree"`. A repeated identical refusal is a UKit circuit-breaker signal, not
133
+ something another attempt can fix.
134
+
118
135
  Your output does not go straight to implementation: an independent `code-reviewer` pass (`REVIEW_TARGET_TYPE=plan`) reviews `PLAN.md` next. If it returns `Issues Found`, you'll be re-invoked to revise and resubmit — write §1-§6 tight enough to pass on the first pass.
119
136
 
120
137
  ## Phase 2 — Split into TASK-xxx.md
@@ -155,6 +155,8 @@ After the batched call, run Steps 1–2.5 to completion without further question
155
155
 
156
156
  **Claude Code — MANDATORY, do this before anything else:** call the Agent tool with `subagent_type: "handoff-planner"` (omp: the `task` tool with `agent: "handoff-planner"`), passing it the Step 1 summary, the problem/feature description, and the **planning-only output root** (`docs/AI_HANDOFF/` when no run is live, else `docs/AI_HANDOFF/queued/<slug>/`) decided in the live-run guard below. Do NOT write PLAN.md or task files yourself in the current session — this step is contracted to the strong tier (opus/unic-smart), which only the spawned agent's frontmatter model guarantees. The planner never launches an executor.
157
157
 
158
+ Spawn it **without** `isolation: "worktree"` — the planner writes `docs/AI_HANDOFF/**` into this checkout, and an isolated worktree is discarded (its docs never arrive; the write guard also refuses worktree paths). If the planner replies with a verbatim `BLOCKED`/hook refusal, do NOT respawn it (or any other agent) for the same task: show that refusal to the user and stop — repeating it burns tokens and hits the identical wall (UKit's block circuit breaker flags the 3rd repeat).
159
+
158
160
  The planner agent does the following (use Step 1 summary — do NOT re-read files):
159
161
 
160
162
  0. **Live-run guard — decide OUTPUT_ROOT first (see "A live fullstack run is never
@@ -388,7 +388,103 @@ const SHELL_WRITE_PATTERNS = [
388
388
  new RegExp(String.raw`${CMD_POS}curl\s(?:[^;&|\n]*\s)?(?:-[sSfLvkiI#]*[oO]\b|--(?:output|remote-name|output-dir)\b)`),
389
389
  new RegExp(String.raw`${CMD_POS}[gm]?awk\s(?:[^;&|\n]*\s)?-i\s*inplace\b`),
390
390
  ];
391
- function shellWriteHit(command) {
391
+ // Read-only inline interpreters (`node -e '…'`, `python3 -c '…'`, `python3 - <<'PY'`, `jq '…'`):
392
+ // their quoted script/program body is code, not shell. Scanning it with the shell write
393
+ // patterns misfires on arrow functions (`l=>{…}`), comparisons (`n>1750`), and words such as
394
+ // `ln`/`cp`/`install` used as identifiers — which blocked read-only transcript analysis in a
395
+ // planner subagent and drove respawn loops. The body is masked out UNLESS it carries a write
396
+ // API (then the command is a write). Redirects/pipes outside the quotes are still scanned.
397
+ const INTERPRETERS = /^(?:node|nodejs|python[0-9.]*|jq)$/;
398
+ const SCRIPT_WRITE_API = new RegExp([
399
+ String.raw`\b(?:writeFile|appendFile|createWriteStream|writeSync|copyFile|truncate|symlink|mkdtemp)\w*\b`,
400
+ String.raw`\bfs\.(?:rm|rmdir|unlink|rename|mkdir|cp|chmod|chown)\w*\b`,
401
+ String.raw`(?<!stdout)(?<!stderr)\.write\s*\(`,
402
+ String.raw`\b(?:child_process|subprocess|shutil)\b|\b(?:spawn|execSync|execFile)\w*\s*\(|\bos\.(?:system|popen|exec\w*)\b`,
403
+ String.raw`\bos\.(?:remove|rename|unlink|rmdir|mkdir|makedirs|replace)\b`,
404
+ String.raw`\b(?:write_text|write_bytes|unlink|rmtree|touch)\s*\(`,
405
+ String.raw`\bopen\s*\([^)]*,\s*['"][^'"]*[wax+]`,
406
+ ].join('|'));
407
+ function firstWord(text) {
408
+ const words = text.trim().split(/\s+/);
409
+ let i = 0;
410
+ while (i < words.length && (/^[A-Za-z_]\w*=/.test(words[i]) || /^(?:sudo|time|nohup|command|exec)$/.test(words[i]))) i += 1;
411
+ return (words[i] || '').replace(/^.*\//, '');
412
+ }
413
+ function splitShellSegments(command) {
414
+ const segs = [];
415
+ let cur = { text: '', sep: '', heredoc: '' };
416
+ const pending = [];
417
+ let q = null;
418
+ for (let i = 0; i < command.length; i += 1) {
419
+ const ch = command[i];
420
+ if (q) {
421
+ cur.text += ch;
422
+ if (q === '"' && ch === '\\' && i + 1 < command.length) { cur.text += command[i + 1]; i += 1; } else if (ch === q) q = null;
423
+ continue;
424
+ }
425
+ if (ch === "'" || ch === '"') { q = ch; cur.text += ch; continue; }
426
+ if (ch === '\\' && i + 1 < command.length) { cur.text += ch + command[i + 1]; i += 1; continue; }
427
+ if (ch === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
428
+ const m = /^<<-?[ \t]*(['"]?)([A-Za-z_]\w*)\1/.exec(command.slice(i));
429
+ if (m) { pending.push({ tag: m[2], seg: cur }); cur.text += m[0]; i += m[0].length - 1; continue; }
430
+ }
431
+ if (ch === '\n' && pending.length) {
432
+ let pos = i + 1;
433
+ for (const { tag, seg } of pending) {
434
+ const start = pos;
435
+ while (pos < command.length) {
436
+ let end = command.indexOf('\n', pos);
437
+ if (end === -1) end = command.length;
438
+ const line = command.slice(pos, end);
439
+ pos = Math.min(end + 1, command.length);
440
+ if (line.trim() === tag) break;
441
+ }
442
+ seg.heredoc += command.slice(start, pos);
443
+ }
444
+ pending.length = 0;
445
+ cur.sep = '\n';
446
+ segs.push(cur);
447
+ cur = { text: '', sep: '', heredoc: '' };
448
+ i = pos - 1;
449
+ continue;
450
+ }
451
+ if (ch === '|' || ch === ';' || ch === '&' || ch === '\n') {
452
+ cur.sep = ch;
453
+ segs.push(cur);
454
+ cur = { text: '', sep: '', heredoc: '' };
455
+ continue;
456
+ }
457
+ cur.text += ch;
458
+ }
459
+ segs.push(cur);
460
+ return segs;
461
+ }
462
+ // -> { command: string with read-only interpreter script bodies removed, writes: boolean }
463
+ function maskReadOnlyScripts(command) {
464
+ let writes = false;
465
+ let out = '';
466
+ for (const seg of splitShellSegments(command)) {
467
+ let text = seg.text;
468
+ let heredoc = seg.heredoc;
469
+ if (INTERPRETERS.test(firstWord(text))) {
470
+ text = text.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (body) => {
471
+ if (SCRIPT_WRITE_API.test(body)) writes = true;
472
+ return "''";
473
+ });
474
+ if (SCRIPT_WRITE_API.test(heredoc)) writes = true;
475
+ heredoc = '';
476
+ }
477
+ out += text + seg.sep + heredoc;
478
+ }
479
+ return { command: out, writes };
480
+ }
481
+ function shellWriteHit(rawCommand) {
482
+ let command = rawCommand;
483
+ try {
484
+ const masked = maskReadOnlyScripts(rawCommand);
485
+ if (masked.writes) return true;
486
+ command = masked.command;
487
+ } catch { /* fall back to the unmasked scan (fail closed) */ }
392
488
  // Redirect (optionally fd-numbered: `1>f`, `3>f`, `&>f`, `>&f`) to anything but /dev/null.
393
489
  // fd duplication (`2>&1`, `>&2`, `>&-`) is excluded by the `(?!&)` target shape.
394
490
  for (const m of command.matchAll(/(^|[^>])&?>{1,2}\s*(?:&(?![0-9-]))?((?!&)\S+)/g)) {
@@ -0,0 +1,118 @@
1
+ // block-breaker.mjs — respawn/retry circuit breaker for repeated hook BLOCKS.
2
+ //
3
+ // Field evidence (4vn, 2026-10-05): one handoff-planner subagent was respawned ~140 times
4
+ // after the SAME PreToolUse refusal (~22M tokens re-read) because every refusal only said
5
+ // "BLOCKED"; the model kept trying a new agent instead of reporting the wall to the user.
6
+ // The chain runner calls `adviseOnBlock()` whenever it is about to emit a block. The Nth
7
+ // identical block (same tool + same normalized reason, within a window) gets a directive
8
+ // appended telling the model to STOP, not retry, and report the reason verbatim.
9
+ //
10
+ // Contract: advisory only (never changes allow/deny), fail-open (any error → '' or a short
11
+ // visible note), off-switch `.ukit/storage/config.json` → blockBreaker.enabled=false or
12
+ // env UKIT_BLOCK_BREAKER=0. State: `.ukit/storage/cache/block-breaker.json` (bounded).
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import crypto from 'node:crypto';
17
+
18
+ export const BLOCK_BREAKER_DEFAULTS = Object.freeze({ enabled: true, threshold: 3, windowMinutes: 30 });
19
+ const MAX_SESSIONS = 20;
20
+ const MAX_SIGS_PER_SESSION = 50;
21
+
22
+ function readConfig(projectRoot) {
23
+ try {
24
+ const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, '.ukit/storage/config.json'), 'utf8'));
25
+ const b = cfg && typeof cfg.blockBreaker === 'object' && cfg.blockBreaker ? cfg.blockBreaker : {};
26
+ const threshold = Number.isInteger(b.threshold) && b.threshold >= 2 ? b.threshold : BLOCK_BREAKER_DEFAULTS.threshold;
27
+ const windowMinutes = Number.isFinite(b.windowMinutes) && b.windowMinutes > 0 ? b.windowMinutes : BLOCK_BREAKER_DEFAULTS.windowMinutes;
28
+ return { enabled: b.enabled !== false, threshold, windowMinutes };
29
+ } catch {
30
+ return { ...BLOCK_BREAKER_DEFAULTS };
31
+ }
32
+ }
33
+
34
+ // Same rule/refusal with a different path/id/number must hash identically.
35
+ export function normalizeBlockReason(reason) {
36
+ return String(reason || '')
37
+ .replace(/\s+/g, ' ')
38
+ .replace(/'[^']*'|"[^"]*"/g, '<Q>')
39
+ .replace(/(?:[A-Za-z]:)?[\\/][\w.\-@\\/]+/g, '<P>')
40
+ .replace(/agent-[0-9a-f]+/gi, 'agent-<A>')
41
+ .replace(/[0-9a-f]{8}-[0-9a-f-]{27}/gi, '<ID>')
42
+ .replace(/\d+/g, 'N')
43
+ .trim()
44
+ .slice(0, 200);
45
+ }
46
+
47
+ function readState(file) {
48
+ try {
49
+ const s = JSON.parse(fs.readFileSync(file, 'utf8'));
50
+ return s && typeof s === 'object' && s.sessions && typeof s.sessions === 'object' ? s : { sessions: {} };
51
+ } catch {
52
+ return { sessions: {} };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Record one block and return the advisory text to append (or '' when under threshold / off).
58
+ * @param {{projectRoot:string, payload?:object, reason:string, now?:number}} args
59
+ */
60
+ export function adviseOnBlock({ projectRoot, payload = {}, reason, now = Date.now() }) {
61
+ try {
62
+ if (process.env.UKIT_BLOCK_BREAKER === '0' || !projectRoot || !String(reason || '').trim()) return '';
63
+ // No session id = cannot scope a retry loop to one session (real Claude payloads always carry it).
64
+ if (!payload || !payload.session_id) return '';
65
+ // Only inside an installed UKit workspace: never create `.ukit/` in an arbitrary directory.
66
+ if (!fs.existsSync(path.join(projectRoot, '.ukit/storage'))) return '';
67
+ const cfg = readConfig(projectRoot);
68
+ if (!cfg.enabled) return '';
69
+ const sid = String(payload.session_id).slice(0, 80);
70
+ const tool = String(payload.tool_name || payload.hook_event_name || 'unknown');
71
+ const normalized = normalizeBlockReason(reason);
72
+ if (!normalized) return '';
73
+ const sig = crypto.createHash('sha1').update(`${tool}|${normalized}`).digest('hex').slice(0, 16);
74
+ const windowMs = cfg.windowMinutes * 60_000;
75
+ const file = path.join(projectRoot, '.ukit/storage/cache/block-breaker.json');
76
+ const state = readState(file);
77
+
78
+ for (const [id, sigs] of Object.entries(state.sessions)) {
79
+ for (const [k, v] of Object.entries(sigs || {})) if (!v || now - (v.last || 0) > windowMs) delete sigs[k];
80
+ if (!sigs || Object.keys(sigs).length === 0) delete state.sessions[id];
81
+ }
82
+ const sessions = state.sessions[sid] || (state.sessions[sid] = {});
83
+ const entry = sessions[sig] || (sessions[sig] = { count: 0, first: now, last: now, agents: [], tool });
84
+ entry.count += 1;
85
+ entry.last = now;
86
+ const agent = payload.agent_id || payload.agent_type;
87
+ if (agent && !entry.agents.includes(String(agent)) && entry.agents.length < 50) entry.agents.push(String(agent));
88
+
89
+ const sessionIds = Object.keys(state.sessions);
90
+ if (sessionIds.length > MAX_SESSIONS) {
91
+ sessionIds.sort((a, b) => Math.max(0, ...Object.values(state.sessions[b]).map((v) => v.last)) - Math.max(0, ...Object.values(state.sessions[a]).map((v) => v.last)));
92
+ for (const id of sessionIds.slice(MAX_SESSIONS)) delete state.sessions[id];
93
+ }
94
+ const keys = Object.keys(sessions);
95
+ if (keys.length > MAX_SIGS_PER_SESSION) {
96
+ for (const k of keys.sort((a, b) => sessions[a].last - sessions[b].last).slice(0, keys.length - MAX_SIGS_PER_SESSION)) delete sessions[k];
97
+ }
98
+ fs.mkdirSync(path.dirname(file), { recursive: true });
99
+ const tmp = `${file}.${process.pid}.tmp`;
100
+ fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`);
101
+ fs.renameSync(tmp, file);
102
+
103
+ if (entry.count < cfg.threshold) return '';
104
+ const minutes = Math.max(1, Math.round((now - entry.first) / 60_000));
105
+ const agents = entry.agents.length > 1 ? ` across ${entry.agents.length} distinct agents` : '';
106
+ return [
107
+ '',
108
+ `UKIT CIRCUIT BREAKER: this same ${tool} block has fired ${entry.count} times in ~${minutes} min${agents}.`,
109
+ 'Retrying it, or respawning/re-delegating to another agent, will hit the identical refusal and only burn tokens.',
110
+ 'STOP now: do not retry this action and do not spawn another agent for it. Report the block reason above to the user VERBATIM and wait for their decision.',
111
+ '(Off-switch: .ukit/storage/config.json -> blockBreaker.enabled=false, or env UKIT_BLOCK_BREAKER=0.)',
112
+ '',
113
+ ].join('\n');
114
+ } catch (error) {
115
+ // Fail open, but visibly: a silently dead breaker is how a loop goes unnoticed.
116
+ return `\n[ukit block-breaker unavailable: ${String(error?.message || error).slice(0, 120)} — block verdict unchanged]\n`;
117
+ }
118
+ }
@@ -7,6 +7,12 @@ import { pathToFileURL } from 'node:url';
7
7
  // spawnSync — a timed-out child is TERM→KILLed as a whole POSIX process group,
8
8
  // so a bash wrapper can no longer orphan the Node grandchildren it spawned.
9
9
  import { runHookProcess } from './hook-process.mjs';
10
+ // Repeated identical blocks (a model respawning an agent into the same refusal) get a
11
+ // "stop and report verbatim" directive appended. Advisory + fail-open; see block-breaker.mjs.
12
+ // Loaded dynamically so a partially-updated runtime (module missing) degrades to "no advice"
13
+ // instead of crashing the whole fail-closed chain on a static-import error.
14
+ let adviseOnBlock = () => '';
15
+ try { ({ adviseOnBlock } = await import('./block-breaker.mjs')); } catch { /* advisory only */ }
10
16
  // TASK-019: chain rows and direct-hook rows share one versioned schema and one
11
17
  // append API (hook-telemetry.mjs) — including safeName for session files.
12
18
  import { appendTelemetryRow, recordHookTiming, TELEMETRY_VERSION } from './hook-telemetry.mjs';
@@ -490,6 +496,19 @@ try {
490
496
  const emitVerdict = argv[0] === '--emit-verdict';
491
497
  if (emitVerdict) argv = argv.slice(1);
492
498
  const [payloadArg = '{}', ...scriptPaths] = argv;
499
+ // Only PreToolUse refusals feed the breaker (a Stop/PostToolUse "block" is not a retry loop
500
+ // of the same tool call). Never throws; returns '' when off / under threshold.
501
+ const breakerAdvice = (reason) => {
502
+ try {
503
+ const payload = JSON.parse(payloadText || '{}');
504
+ if (payload.hook_event_name && payload.hook_event_name !== 'PreToolUse') return '';
505
+ const first = scriptPaths[0] ? parseScriptArg(scriptPaths[0]).scriptPath : '';
506
+ const root = first ? path.resolve(path.dirname(first), '../..') : (payload.cwd || process.cwd());
507
+ return adviseOnBlock({ projectRoot: root, payload, reason });
508
+ } catch {
509
+ return '';
510
+ }
511
+ };
493
512
  // The bridge passes the payload as a temp file (`@path`) when it can: argv is capped
494
513
  // (~256KB per arg on macOS) and PostToolUse Bash payloads embed whole tool outputs.
495
514
  // A leading '@' cannot occur in raw JSON, so the two forms are unambiguous.
@@ -584,7 +603,19 @@ try {
584
603
  .map((r) => r.stdout)
585
604
  .join('');
586
605
  if (decisionOwnsVerdict) {
587
- process.stdout.write(decisionResult.stdout);
606
+ let verdictOut = decisionResult.stdout;
607
+ try {
608
+ const doc = JSON.parse(verdictOut);
609
+ const hso = doc?.hookSpecificOutput;
610
+ if (hso?.permissionDecision === 'deny') {
611
+ const advice = breakerAdvice(hso.permissionDecisionReason);
612
+ if (advice) {
613
+ hso.permissionDecisionReason = `${hso.permissionDecisionReason}\n${advice}`;
614
+ verdictOut = JSON.stringify(doc);
615
+ }
616
+ }
617
+ } catch { /* unparseable verdict: replay it untouched */ }
618
+ process.stdout.write(verdictOut);
588
619
  if (decisionResult.stderr) process.stderr.write(decisionResult.stderr);
589
620
  process.exitCode = 0;
590
621
  } else if (chain.skippedFailClosed) {
@@ -597,7 +628,8 @@ try {
597
628
  .map((p) => failClosedName(p))
598
629
  .filter((name) => FAIL_CLOSED_SCRIPTS.has(name))
599
630
  .join(', ');
600
- process.stderr.write(`UKit hook chain broke before fail-closed gate(s) ran: ${skipped}\n`);
631
+ const brokeMsg = `UKit hook chain broke before fail-closed gate(s) ran: ${skipped}\n`;
632
+ process.stderr.write(brokeMsg + breakerAdvice(brokeMsg));
601
633
  process.exitCode = 2;
602
634
  } else if (!last) {
603
635
  // No script ran at all (empty chain or budget spent before the first
@@ -618,13 +650,14 @@ try {
618
650
  // the reorder can't misroute a legitimate block.
619
651
  if (lastIsFailClosed && lastFailedToVerdict) {
620
652
  if (contextStdout) process.stdout.write(contextStdout);
621
- if (last.stderr) process.stderr.write(last.stderr);
622
- else process.stderr.write(`UKit fail-closed hook ${last.scriptName} could not produce a verdict (${last.failureKind})\n`);
653
+ const gateMsg = last.stderr || `UKit fail-closed hook ${last.scriptName} could not produce a verdict (${last.failureKind})\n`;
654
+ process.stderr.write(gateMsg + breakerAdvice(gateMsg));
623
655
  process.exitCode = 2;
624
656
  } else if (last.code === 2) {
625
657
  if (contextStdout) process.stdout.write(contextStdout);
626
658
  if (last.stdout) process.stdout.write(last.stdout);
627
- if (last.stderr) process.stderr.write(last.stderr);
659
+ if (last.stderr) process.stderr.write(last.stderr + breakerAdvice(last.stderr));
660
+ else process.stderr.write(breakerAdvice(`${last.scriptName} blocked (exit 2) with no reason`));
628
661
  process.exitCode = 2;
629
662
  } else {
630
663
  if (contextStdout) process.stdout.write(contextStdout);
@@ -117,6 +117,23 @@ Append this footer to `PLAN.md` — mandatory, checked by a hook before the writ
117
117
  PLANNER_MODEL: <your exact model ID — e.g. claude-opus-5>
118
118
  ```
119
119
 
120
+ **Write PLAN.md FIRST, footer included.** Your first `PLAN.md` write is a complete or skeleton
121
+ file that already ends with the footer above; refine it with `Edit` afterwards. The tier guard
122
+ validates `PLANNER_MODEL` on that write, so a refusal surfaces before you spend a long drafting
123
+ pass — never draft the whole plan in your head/scratch and attempt the first write last.
124
+
125
+ ## Blocked write = report, never retry or respawn
126
+
127
+ If a `Write`/`Edit`/`Bash` call is refused (`BLOCKED …`, `hook error`, "denied by your
128
+ permission settings"), do NOT re-attempt the same call, route around the guard with a different
129
+ tool or path, or ask the caller to spawn another planner. Stop and return, as your entire
130
+ reply: the refused tool, the target path, and the refusal text copied **verbatim**. Also stop
131
+ immediately and report if your working directory is under `.claude/worktrees/` or
132
+ `.worktrees/` (an isolated worktree is discarded when you finish, so `docs/AI_HANDOFF/**`
133
+ written there never reaches the project) — the caller must re-run you without
134
+ `isolation: "worktree"`. A repeated identical refusal is a UKit circuit-breaker signal, not
135
+ something another attempt can fix.
136
+
120
137
  Your output does not go straight to implementation: an independent `code-reviewer` pass (`REVIEW_TARGET_TYPE=plan`) reviews `PLAN.md` next. If it returns `Issues Found`, you'll be re-invoked to revise and resubmit — write §1-§7 tight enough to pass on the first pass.
121
138
 
122
139
  ## Phase 2 — Split into TASK-xxx.md