@ngockhoale/ukit 2.3.14 → 2.3.17

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.
@@ -0,0 +1,157 @@
1
+ // Pure checker for the executor milestone protocol.
2
+ //
3
+ // A TASK-xxx.md that carries an `## Executor Report` must also carry a `## Progress` section.
4
+ // Each `## Progress` entry uses a fixed format:
5
+ //
6
+ // - <ISO-8601 timestamp> · milestone: <name> · last-green: <what passed> · files: <paths> · drift: none|<reason>
7
+ //
8
+ // Rules implemented here (canonical wording for the planner/executor contract):
9
+ //
10
+ // 1. A task carrying an Executor Report must have ≥1 `## Progress` entry.
11
+ // Violation type: 'no-progress'.
12
+ //
13
+ // 2. The newest entry (max parsed timestamp, not last line) must be within
14
+ // `2 × milestoneIntervalMin` of `now`. The boundary is INCLUSIVE at exactly
15
+ // `2 × milestoneIntervalMin` old (still OK) and EXCLUSIVE one minute past
16
+ // (stale). "Strictly beyond" the boundary is stale.
17
+ // Violation type: 'stale-milestone'.
18
+ //
19
+ // 3. Every `files:` path in an entry must appear under `## Target Files`
20
+ // unless that entry's `drift:` field carries a non-`none` reason.
21
+ // Violation type: 'undeclared-drift'.
22
+ //
23
+ // 4. Entries with unparseable timestamps are reported as 'malformed-entry'
24
+ // and never throw.
25
+ //
26
+ // The module is intentionally tiny and pure: no fs, no Date.now() side effects.
27
+ // `now` defaults to Date.now() so call sites that want determinism (tests,
28
+ // future watchdog integration) can inject a fixed timestamp.
29
+
30
+ const PROGRESS_HEADING = /^##\s+Progress\s*$/m;
31
+ const REPORT_HEADING = /^##\s+Executor Report\s*$/m;
32
+ const TARGET_HEADING = /^##\s+Target Files\s*$/m;
33
+ const ENTRY_RE = /^-\s+(\S+)\s+·\s+milestone:\s*([^·]*)·\s+last-green:\s*([^·]*)·\s+files:\s*([^·]*)·\s+drift:\s*(.*)$/;
34
+
35
+ function parseEntry(line) {
36
+ const m = ENTRY_RE.exec(line);
37
+ if (!m) return null;
38
+ const [, ts, milestone, lastGreen, filesField, drift] = m;
39
+ const files = filesField
40
+ .split(',')
41
+ .map((s) => s.trim())
42
+ .filter(Boolean);
43
+ const tsNum = Date.parse(ts);
44
+ if (Number.isNaN(tsNum)) {
45
+ return { ok: false, raw: line };
46
+ }
47
+ return {
48
+ ok: true,
49
+ timestamp: tsNum,
50
+ milestone: milestone.trim(),
51
+ lastGreen: lastGreen.trim(),
52
+ files,
53
+ drift: drift.trim(),
54
+ };
55
+ }
56
+
57
+ function sliceSection(markdown, headingRe) {
58
+ const match = headingRe.exec(markdown);
59
+ if (!match) return null;
60
+ const start = match.index + match[0].length;
61
+ const rest = markdown.slice(start);
62
+ // Section ends at the next `## ` heading or EOF.
63
+ const nextHeading = /\n##\s+/.exec(rest);
64
+ const end = nextHeading ? start + nextHeading.index : markdown.length;
65
+ return markdown.slice(start, end);
66
+ }
67
+
68
+ function collectTargetFiles(markdown) {
69
+ const section = sliceSection(markdown, TARGET_HEADING);
70
+ if (!section) return new Set();
71
+ const files = new Set();
72
+ for (const line of section.split('\n')) {
73
+ // Lines look like "- `path/to/file.js` — what changes"
74
+ const m = /^\s*-\s+`?([^`\s]+(?:\.[^`\s]+)?)`?/.exec(line);
75
+ if (m) files.add(m[1]);
76
+ }
77
+ return files;
78
+ }
79
+
80
+ function collectProgressEntries(progressSection) {
81
+ const entries = [];
82
+ for (const line of progressSection.split('\n')) {
83
+ if (!line.trim().startsWith('- ')) continue;
84
+ const parsed = parseEntry(line.trim());
85
+ if (parsed) entries.push(parsed);
86
+ }
87
+ return entries;
88
+ }
89
+
90
+ export function checkMilestoneProgress({
91
+ markdown,
92
+ now = Date.now(),
93
+ milestoneIntervalMin = 5,
94
+ } = {}) {
95
+ const violations = [];
96
+
97
+ if (typeof markdown !== 'string' || markdown.length === 0) {
98
+ return { ok: false, violations: [{ type: 'no-progress', detail: 'markdown is empty' }] };
99
+ }
100
+
101
+ if (!REPORT_HEADING.test(markdown)) {
102
+ // No Executor Report — nothing to check.
103
+ return { ok: true, violations: [] };
104
+ }
105
+
106
+ if (!PROGRESS_HEADING.test(markdown)) {
107
+ return {
108
+ ok: false,
109
+ violations: [{ type: 'no-progress', detail: '## Progress section missing' }],
110
+ };
111
+ }
112
+
113
+ const progressSection = sliceSection(markdown, PROGRESS_HEADING);
114
+ const entries = collectProgressEntries(progressSection || '');
115
+
116
+ if (entries.length === 0) {
117
+ violations.push({ type: 'no-progress', detail: '## Progress section has no entries' });
118
+ return { ok: false, violations };
119
+ }
120
+
121
+ let newestTs = -Infinity;
122
+ for (const entry of entries) {
123
+ if (!entry.ok) {
124
+ violations.push({ type: 'malformed-entry', detail: `unparseable timestamp: ${entry.raw}` });
125
+ continue;
126
+ }
127
+ if (entry.timestamp > newestTs) newestTs = entry.timestamp;
128
+ }
129
+
130
+ if (Number.isFinite(newestTs)) {
131
+ const limitMs = 2 * milestoneIntervalMin * 60 * 1000;
132
+ const ageMs = now - newestTs;
133
+ // Boundary: ageMs === limitMs is still OK; ageMs > limitMs is stale.
134
+ if (ageMs > limitMs) {
135
+ violations.push({
136
+ type: 'stale-milestone',
137
+ detail: `newest entry is ${Math.round(ageMs / 60000)} min old; threshold is ${2 * milestoneIntervalMin} min`,
138
+ });
139
+ }
140
+ }
141
+
142
+ const targetFiles = collectTargetFiles(markdown);
143
+ for (const entry of entries) {
144
+ if (!entry.ok) continue;
145
+ const driftReason = entry.drift && entry.drift !== 'none' ? entry.drift : null;
146
+ for (const file of entry.files) {
147
+ if (!targetFiles.has(file) && !driftReason) {
148
+ violations.push({
149
+ type: 'undeclared-drift',
150
+ detail: `file '${file}' not in ## Target Files and entry has no drift reason`,
151
+ });
152
+ }
153
+ }
154
+ }
155
+
156
+ return { ok: violations.length === 0, violations };
157
+ }
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  const VALID_ITEM_TYPES = new Set(['command', 'skill', 'agent', 'hook', 'config', 'link']);
4
- const VALID_MERGE_STRATEGIES = new Set(['overwrite_with_backup', 'skip', 'append', 'prepend']);
4
+ const VALID_MERGE_STRATEGIES = new Set(['overwrite_with_backup', 'skip', 'append', 'prepend', 'merge_env_overwrite_with_backup']);
5
5
 
6
6
  function isPlainObject(value) {
7
7
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -117,6 +117,36 @@ what lets the cycle reach the end.
117
117
  - The next AI session (any tool, model from `handoff.reviewer.model`, MUST differ from executor) will pick `pending_review` task and run review.
118
118
  - Do NOT dispatch reviewer in-process unless your host explicitly supports it AND can guarantee a different model — file-based handoff is the default.
119
119
 
120
+ ## Milestone landing (mandatory — Handoff mode)
121
+
122
+ Long runs must land work to disk continuously, so a killed turn loses at most one milestone
123
+ and a respawn picks up exactly where the previous executor stopped. This applies in Handoff
124
+ mode only; daily flow does not get a Progress section.
125
+
126
+ - **Cadence.** Every `handoff.milestoneIntervalMin` (default 5) wall-clock minutes OR at
127
+ every RED → GREEN → verify milestone (whichever comes first), append a Progress entry to
128
+ the task file and make a milestone commit on YOUR worktree branch.
129
+ - **Entry format (fixed — `src/core/taskProgressGuard.js` parses it).**
130
+
131
+ `- <ISO-8601 local timestamp> · milestone: <name> · last-green: <what passed> · files: <comma-separated paths> · drift: none|<one line why>`
132
+
133
+ Re-state every file changed since the previous entry. If a file is not under
134
+ `## Target Files`, the `drift:` field MUST carry a one-line reason; otherwise the guard
135
+ reports `undeclared-drift`.
136
+ - **Milestone commit.** The commit lives on your worktree branch only (e.g.
137
+ `handoff/task-xxx`). Use a `-m "milestone: <name>"` message. Never commit outside your
138
+ worktree, never push. The orchestrator's copy-back step (`git diff --name-only` against
139
+ base) diffs the working tree, so intermediate commits on the worktree branch are free —
140
+ **copy-back is unaffected** by milestone commits.
141
+ - **Resume.** When a previous run aborted, you will be respawned. Read `## Progress`,
142
+ jump straight to the last entry whose `last-green:` is not `none`, and continue from
143
+ there. Do NOT re-plan, do NOT restart from RED, do NOT re-write tests that already
144
+ passed. The progress section is the resume contract.
145
+ - **Staleness.** If your newest entry is older than `2 × handoff.milestoneIntervalMin`,
146
+ the guard reports `stale-milestone`; the boundary is inclusive at exactly `2 ×` and
147
+ exclusive one minute beyond. Keep entries fresh — the cadence rule above is what
148
+ guarantees that.
149
+
120
150
  ## Rules
121
151
 
122
152
  - **Iron law (Handoff mode):** no `DONE` without fresh PASS output in the current turn.
@@ -116,6 +116,18 @@ Missing any field → `needs_breakdown`. Never mark incomplete tasks `ready`.
116
116
  - Chain: A → B → C runs as 3 sequential waves (1 task each, no parallelism)
117
117
  - Independent: A, B, C (all `none`) runs as 1 wave, all parallel
118
118
 
119
+ ### Task-budget validator — gate before any task becomes `ready`
120
+
121
+ Before marking a task `ready`, run the task-budget validator on the file you just wrote:
122
+
123
+ ```
124
+ node .claude/ukit/index/task-budget-validator.mjs docs/AI_HANDOFF/tasks/TASK-xxx.md
125
+ ```
126
+
127
+ The validator's first stdout line is `VERDICT: ok` or `VERDICT: needs_breakdown`, followed by `REASON:` lines. It always exits 0 (advisory). When the verdict is `needs_breakdown`, either split the task into smaller TASK files until each one passes, or record a one-line dismissal in that task's `## Discussion` thread explaining why the violation is acceptable. A `ready` task whose validator verdict is `needs_breakdown` is not actually ready — Phase 3 (state file write) must follow a `VERDICT: ok` (or a documented dismissal).
128
+
129
+ Numeric thresholds and the minute table live in `src/core/taskBudgetValidator.js` and the shipped CLI twin. Do not restate them here — `tests/consistency/configDocsSync.test.js` greps this file for stale numbers and will fail if a threshold is hard-coded. Reference the validator / PLAN §3 instead.
130
+
119
131
  ### Maximize wave width — dependencies are expensive
120
132
 
121
133
  Wave width is the single biggest lever on how long a cycle takes: a wave of 6 finishes in
@@ -27,6 +27,12 @@ demonstrated, never assumed from a name:
27
27
  actually give you the image (error, empty result, or you can only see text you were told about),
28
28
  you are not vision-capable right now: emit `STATUS: WRONG_MODEL` and stop immediately. Do not
29
29
  describe, summarise, or guess at any image content.
30
+ - Decide ONLY by the probe result — never by the lane's name. A mapping that sounds non-visual
31
+ (e.g. your environment says you are a lite lane) does NOT license refusal when the probe
32
+ actually shows you the image: if you can see it, you are vision-capable right now — report the
33
+ mapping you actually ran on in `MODEL:` and continue. Conversely, a vision-sounding name with a
34
+ failed probe is still `STATUS: WRONG_MODEL`. Refusing by name without probing was the exact
35
+ failure that left images unread in the field.
30
36
  - Never guess at image contents. A non-visual "analysis" is worse than no analysis at all, because
31
37
  it looks authoritative while being fabricated. Refusing loudly is always safer than guessing
32
38
  quietly.
@@ -262,7 +262,10 @@ TDD — mandatory:
262
262
  cd .worktrees/task-xxx && <each verification command>
263
263
  Paste full output.
264
264
 
265
- DO NOT run: git add, git commit, git push — leave files as-is in worktree.
265
+ Milestone commits INSIDE your own worktree (e.g. `handoff/task-xxx`) are REQUIRED by the
266
+ milestone protocol — see `## Milestone landing (mandatory)` in the feature-implementer
267
+ contract. Never commit outside your worktree, never push. Use `-m "milestone: <name>"`.
268
+ Copy-back diffs the working tree against base, so milestone commits do not affect it.
266
269
 
267
270
  Executor Report (append to task file — do NOT touch INDEX.md):
268
271
  ## Executor Report
@@ -0,0 +1,221 @@
1
+ #!/bin/bash
2
+ # task-watchdog.sh — wall-clock watchdog for handoff tasks.
3
+ #
4
+ # Why this exists:
5
+ # The Quality Gate lets handoff runs go on for a while, but a wedged task
6
+ # that never reports a blocker still burns the rest of the pipeline. This
7
+ # hook measures wall-clock per in_progress task against approved budgets
8
+ # (S 8/15, M 15/30, L 25/45 minutes by default) and either:
9
+ # - soft overrun: emits a VISIBLE "checkpoint milestone now + split remainder"
10
+ # advisory on Stop (systemMessage), never blocks.
11
+ # - hard overrun (split policy, default): emits Stop decision=block with a
12
+ # visible split instruction naming the follow-up
13
+ # TASK-<id>-b; keeps the cycle running instead of letting
14
+ # the agent stop silently mid-budget.
15
+ # - hard overrun (pause policy, selectable in config): emits a pause
16
+ # advisory on Stop, never blocks.
17
+ #
18
+ # PostToolUse NEVER emits decision — that would block the in-flight Edit.
19
+ # Stop is the only path that can hard-trip; PostToolUse stays advisory only.
20
+ #
21
+ # Everything fails open and stays inside the 4s hook-chain budget:
22
+ # - HOOK_DEADLINE_MS=3000 self-kill via setTimeout(...).unref()
23
+ # - all I/O is async (sync reads on a stalled mount would block the timer)
24
+ # - process.exit(0) on every path; never throws.
25
+ #
26
+ # Dispatch on hook_event_name:
27
+ # Stop → evaluate and may emit decision=block; never emits decision
28
+ # unless the trip crosses hardMin AND hardPolicy='split'.
29
+ # PostToolUse → advisory only (systemMessage); NEVER emits decision.
30
+ # anything else → exit 0 silently.
31
+
32
+ INPUT="$(cat)"
33
+ # Cap the payload before it reaches node's env: oversized stdin would exceed the
34
+ # exec environment limit and node would never start. A truncated payload simply
35
+ # fails JSON.parse inside node → {} → silent degrade exit 0 (documented posture).
36
+ INPUT="${INPUT:0:65536}"
37
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
38
+
39
+ INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
40
+ 'use strict';
41
+
42
+ const fsp = require('fs/promises');
43
+ const path = require('path');
44
+ const { pathToFileURL } = require('url');
45
+
46
+ // Wall-clock watchdog. Anything that stalls here must end in clean exit 0 —
47
+ // a stuck hook is indistinguishable from a wedged pipeline to the orchestrator.
48
+ // Every read below is async on purpose: a sync read on a stalled mount would
49
+ // block the loop and the timer below would never fire.
50
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
51
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
52
+
53
+ (async () => {
54
+ const payload = (() => {
55
+ try {
56
+ const parsed = JSON.parse(process.env.INPUT || '');
57
+ return parsed && typeof parsed === 'object' ? parsed : {};
58
+ } catch {
59
+ return {};
60
+ }
61
+ })();
62
+
63
+ const hookEvent = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
64
+ const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
65
+ const projectRoot = process.env.PROJECT_ROOT || process.cwd();
66
+
67
+ // Silent degrade for non-relevant events — same posture as every other
68
+ // UKit advisory hook: only do work when the event type is ours.
69
+ if (hookEvent !== 'Stop' && hookEvent !== 'PostToolUse') {
70
+ process.exit(0);
71
+ }
72
+
73
+ const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'task-watchdog.mjs');
74
+ // Async existence check — sync fs here would violate the all-async invariant
75
+ // (a stalled mount would block the loop and the 3s self-kill could not fire).
76
+ try {
77
+ await fsp.access(runtimePath);
78
+ } catch {
79
+ process.exit(0);
80
+ }
81
+
82
+ let runtime;
83
+ try {
84
+ runtime = await import(pathToFileURL(runtimePath).href);
85
+ } catch {
86
+ process.exit(0);
87
+ }
88
+
89
+ const configPath = path.join(projectRoot, '.ukit', 'storage', 'config.json');
90
+ const config = await runtime.loadConfig(configPath);
91
+
92
+ const stateDir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'task-watchdog');
93
+ const statePath = path.join(stateDir, 'state.json');
94
+ const state = await runtime.readState(statePath);
95
+
96
+ let tasks = [];
97
+ try {
98
+ tasks = await runtime.listInProgressTasks(projectRoot);
99
+ } catch {
100
+ process.exit(0);
101
+ }
102
+
103
+ // PostToolUse: advisory only. NEVER emit decision — that would block the
104
+ // in-flight Edit and stall the run. We still re-evaluate so the runtime
105
+ // state stays warm and the next Stop sees an up-to-date view.
106
+ if (hookEvent === 'PostToolUse') {
107
+ const now = Date.now();
108
+ const evals = runtime.evaluateBudgets({ tasks, state, now, config });
109
+ const advisories = evals
110
+ .filter((r) => r.phase !== 'ok')
111
+ .map((r) => {
112
+ const task = tasks.find((t) => t.id === r.id);
113
+ if (!task) return null;
114
+ if (r.phase === 'hard') {
115
+ const blocks = Number(state.hardBlocks[r.id] || 0);
116
+ if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
117
+ return runtime.describeDegraded({ id: r.id, hardBlocks: blocks });
118
+ }
119
+ return runtime.describeHardAdvisory(r, r.id);
120
+ }
121
+ return runtime.describeSoft(r, r.id);
122
+ })
123
+ .filter(Boolean);
124
+
125
+ if (advisories.length > 0) {
126
+ const message = advisories.join('\n');
127
+ try {
128
+ process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
129
+ } catch {}
130
+ }
131
+ process.exit(0);
132
+ }
133
+
134
+ // Stop: full evaluation, may emit decision=block under split policy.
135
+ const now = Date.now();
136
+ for (const task of tasks) {
137
+ try {
138
+ await runtime.ensureFirstSeen(state, task.id, now);
139
+ } catch {}
140
+ }
141
+
142
+ const evals = runtime.evaluateBudgets({ tasks, state, now, config });
143
+
144
+ // First save firstSeen updates so the next Stop sees the same anchored clock.
145
+ await runtime.writeState(statePath, state);
146
+
147
+ const hardResults = evals.filter((r) => r.phase === 'hard');
148
+ const softResults = evals.filter((r) => r.phase === 'soft');
149
+
150
+ if (hardResults.length === 0) {
151
+ // No hard trips. Emit a soft advisory if anything is in the soft zone,
152
+ // then exit 0 cleanly. We deliberately do not emit decision here — the
153
+ // completion gate owns Stop's continuation logic and we never want to
154
+ // double-block for the same reason.
155
+ if (softResults.length > 0) {
156
+ const advisory = softResults
157
+ .map((r) => runtime.describeSoft(r, r.id))
158
+ .join('\n');
159
+ try {
160
+ process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
161
+ } catch {}
162
+ }
163
+ process.exit(0);
164
+ }
165
+
166
+ // At least one hard-trip. Apply policy.
167
+ const hardPolicy = String(config.taskBudgets?.hardPolicy || 'split');
168
+ const firstHard = hardResults[0];
169
+ const blocks = Number(state.hardBlocks[firstHard.id] || 0);
170
+
171
+ if (hardPolicy !== 'split') {
172
+ // pause (or anything non-split) → advisory only, never block.
173
+ const advisory = hardResults
174
+ .map((r) => runtime.describePause({ id: r.id, result: r }))
175
+ .join('\n');
176
+ try {
177
+ process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
178
+ } catch {}
179
+ process.exit(0);
180
+ }
181
+
182
+ // split policy + hard-trip — but the per-task block cap has already been
183
+ // hit. Degrade to advisory so the orchestrator can hand back to the user.
184
+ if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
185
+ await runtime.bumpHardBlocks(state, firstHard.id);
186
+ await runtime.writeState(statePath, state);
187
+ const advisory = runtime.describeDegraded({ id: firstHard.id, hardBlocks: blocks + 1 });
188
+ try {
189
+ process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
190
+ } catch {}
191
+ process.exit(0);
192
+ }
193
+
194
+ // Hard trip + split policy + under the cap → emit Stop decision=block
195
+ // with the visible split instruction. This is the "auto-split and continue"
196
+ // path: blocking the stop IS the continue mechanism — the executor / orchestrator
197
+ // performs the actual TASK-<id>-b cut on the next turn following the reason.
198
+ const newBlocks = await runtime.bumpHardBlocks(state, firstHard.id);
199
+ await runtime.writeState(statePath, state);
200
+
201
+ const task = tasks.find((t) => t.id === firstHard.id) || { id: firstHard.id };
202
+ const lastGreen = runtime.pickLastGreen(task);
203
+ const reason = runtime.describeSplitReason({
204
+ id: firstHard.id,
205
+ result: firstHard,
206
+ hardBlocks: newBlocks,
207
+ lastGreen,
208
+ });
209
+ try {
210
+ process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
211
+ } catch {}
212
+ process.exit(0);
213
+ })().catch(() => {
214
+ // Fail-open: never let an exception kill the hook.
215
+ try {
216
+ process.exit(0);
217
+ } catch {}
218
+ });
219
+ NODE
220
+
221
+ exit 0
@@ -283,31 +283,34 @@ const { pathToFileURL } = require('url');
283
283
  }
284
284
 
285
285
  // Lane honored (or unverified — missing aliasAvailable defaults to today's
286
- // unic-vision dispatch). Capability-framed, never provider identity: the
287
- // remedy is "don't guess — use a verified reader", which holds whether the
288
- // active model reads images natively or hands off to the specialist.
286
+ // dispatch). NATIVE-FIRST ordering (2026-09-11): field evidence showed
287
+ // specialist dispatches landing on gateways/backends that could not return
288
+ // the image through the Read tool_result (STATUS: WRONG_MODEL, self-reported
289
+ // unic-lite / glm-5-turbo) while the parent — usually a Claude lane — could
290
+ // have read the image itself. So the hint verifies the parent's own vision
291
+ // FIRST by reading the materialized file, dispatches the specialist only as
292
+ // a fallback, and defines a fallback when the specialist fails anyway —
293
+ // an image must never end up unread just because one lane failed.
289
294
  const unicNote = (unicMode === true && gatewayResult?.visionModel)
290
295
  ? ` (UNIC gateway active — ${gatewayResult.visionModel} routes through it.)`
291
296
  : '';
292
297
 
293
- const reasonLines = [
294
- 'Advisory: never guess at image contents. If the active model has VERIFIED native',
295
- 'vision for these images it may read them directly; otherwise dispatch the specialist',
296
- 'before relying on them:',
297
- ];
298
-
299
298
  const lines = [
300
299
  `UKIT VISION ROUTE — new image input detected (${cases.join(', ')}).`,
301
- ...reasonLines,
300
+ 'Advisory: never guess at image contents. Verify a real reader before relying on them:',
302
301
  ` 1. ${materializeCmd}`,
303
- ' Materialize the images to disk. With --session the extractor targets the',
304
- ' PARENT transcript explicitly, so a subagent spawn cannot drop the image.',
305
- ' 2. Agent(subagent_type: "ukit-vision-analyst") [model: unic-vision]',
306
- ' Send the ABSOLUTE paths from images[].path as TEXT (subagents do NOT inherit',
307
- ' image blocks; they can only Read files). Include the task envelope: the ORIGINAL',
308
- ' user prompt verbatim, the visual question, and the task goal — the analyst must',
309
- ' know what the images are FOR.',
310
- ' 3. Continue the real task using the analyst\'s OBSERVATIONS + INFERENCES.',
302
+ ' Materialize to disk (--session targets the PARENT transcript, so a subagent',
303
+ ' spawn cannot drop the image).',
304
+ ' 2. Read each ABSOLUTE path in images[].path YOURSELF, in the main session. If',
305
+ ' the image actually arrives you have VERIFIED native vision: analyse it and',
306
+ ' write receipts (analyzed-<sha>.json; sha/sessionId from the extractor output;',
307
+ ' model = your actual model). No dispatch.',
308
+ ' 3. Only if your own Read shows no image: Agent(subagent_type: "ukit-vision-analyst")',
309
+ ' [model: unic-vision] — send paths as TEXT (subagents do NOT inherit image',
310
+ ' blocks) plus the task envelope: original prompt, visual question, goal.',
311
+ ' 4. Specialist answers WRONG_MODEL or NO_IMAGE → do not leave the image unread:',
312
+ ' re-Read yourself; for a pasted image you can see directly, analyse from your',
313
+ ' own view. No reader can see it → say so plainly, never fabricate.',
311
314
  ];
312
315
  if (sessionId) {
313
316
  lines.push(` Markers armed under sessionId: ${sessionId} (receipts: analyzed-<sha>.json).`);
@@ -151,6 +151,11 @@
151
151
  "type": "command",
152
152
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
153
153
  "timeout": 4
154
+ },
155
+ {
156
+ "type": "command",
157
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\"",
158
+ "timeout": 4
154
159
  }
155
160
  ]
156
161
  },
@@ -203,6 +208,11 @@
203
208
  "type": "command",
204
209
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\"",
205
210
  "timeout": 4
211
+ },
212
+ {
213
+ "type": "command",
214
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\"",
215
+ "timeout": 4
206
216
  }
207
217
  ]
208
218
  }