@ngockhoale/ukit 3.4.67 → 3.4.68

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/manifests/documentation.yaml +33 -0
  3. package/manifests/instructionRules.yaml +10 -0
  4. package/manifests/platform.full.yaml +67 -0
  5. package/package.json +1 -1
  6. package/src/core/applyPlan.js +111 -2
  7. package/src/core/buildPlan.js +3 -1
  8. package/src/core/diffPlan.js +49 -1
  9. package/src/core/executionContracts.js +49 -8
  10. package/src/core/metadata.js +45 -0
  11. package/src/core/runInstallPipeline.js +30 -2
  12. package/src/core/uninstall.js +106 -2
  13. package/src/manifest/validateManifest.js +25 -1
  14. package/template_project/.claude/agents/code-reviewer.md +22 -9
  15. package/template_project/.claude/agents/handoff-planner.md +97 -8
  16. package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
  17. package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
  18. package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
  19. package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
  21. package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
  22. package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
  23. package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
  24. package/template_project/.claude/hooks/handoff-resume.sh +141 -1
  25. package/template_project/.claude/hooks/record-execution.mjs +140 -9
  26. package/template_project/.claude/hooks/skill-router.sh +173 -8
  27. package/template_project/.claude/settings.json +2 -2
  28. package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
  29. package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
  30. package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
  31. package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
  32. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  33. package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
  34. package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
  35. package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
  36. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
  37. package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
  38. package/template_project/.claude/ukit/index/route-task.mjs +180 -11
  39. package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
  40. package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
  41. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
  42. package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
  43. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  44. package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
  46. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
  47. package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
  48. package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
  49. package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
  50. package/template_project/.codex/README.md +25 -0
  51. package/template_project/.omp/RULES.md +22 -22
  52. package/template_project/.omp/agents/code-reviewer.md +22 -9
  53. package/template_project/.omp/agents/handoff-planner.md +102 -12
  54. package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
  55. package/template_project/AGENTS.md +5 -1
  56. package/template_project/CLAUDE.md +1 -1
  57. package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
  58. package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
  59. package/template_project/instructions/core.md +5 -1
  60. package/template_project/instructions/layout.yaml +2 -0
  61. package/template_project/instructions/overlays/omp-rules.md +22 -22
  62. package/template_project/ukit/storage/config.json +1 -1
@@ -155,6 +155,86 @@ async function emitOrdinaryResume(payload, source) {
155
155
  }
156
156
  }
157
157
 
158
+ // ── Bounded TaskScope carry ─────────────────────────────────────────────────
159
+ // RUN.md may carry a frozen TaskScope map with THOUSANDS of preserved mapping lines
160
+ // (every cycle's lines stay on disk verbatim across cycle transitions). The resume
161
+ // injection stays bounded: a small map prints verbatim (unchanged legacy carry); a
162
+ // large one shows the CurrentTask's current-cycle mapping plus its repair ancestor
163
+ // chain and the current cycle's mappings, then points at RUN.md on disk for the
164
+ // remainder. RUN.md itself is never rewritten or truncated — it stays the full-map
165
+ // authority.
166
+ const TASK_SCOPE_FULL_BUDGET = 12;
167
+ const TASK_SCOPE_MAX_SHOWN = 24;
168
+
169
+ // Cycle identity — the same shape stop-coordinator's parseRunCycle defines.
170
+ function normalizeScopeCycle(value) {
171
+ const m = String(value ?? '').trim().match(/^(?:cycle-)?(\d+)$/i);
172
+ return m ? `cycle-${m[1].padStart(2, '0')}` : null;
173
+ }
174
+
175
+ // `cycle-1/TASK-007` → `cycle-01/task-007`; a bare id stays unqualified.
176
+ function normalizeScopeKey(raw) {
177
+ const key = String(raw ?? '').trim().toLowerCase();
178
+ const m = key.match(/^(?:cycle-)?(\d+)\/(.+)$/);
179
+ return m ? `cycle-${m[1].padStart(2, '0')}/${m[2]}` : key;
180
+ }
181
+
182
+ function parseTaskScopeLine(line) {
183
+ const m = line.match(/^TaskScope:\s*(\S+)\s*=\s*(queue|plan|repair):(\S.*)$/i);
184
+ if (!m) return null;
185
+ return { line, key: normalizeScopeKey(m[1]), kind: m[2].toLowerCase(), value: normalizeScopeKey(m[3]) };
186
+ }
187
+
188
+ function boundedTaskScopeLines(all, runCycle, currentTask) {
189
+ if (all.length <= TASK_SCOPE_FULL_BUDGET) return all;
190
+ const parsed = all.map(parseTaskScopeLine).filter(Boolean);
191
+ const byKey = new Map(parsed.map((p) => [p.key, p]));
192
+ const chosen = [];
193
+ const seen = new Set();
194
+ const take = (p) => { if (p && !seen.has(p.line)) { seen.add(p.line); chosen.push(p.line); } };
195
+ // The CurrentTask's mapping — the current cycle's qualified key wins over a stale
196
+ // unqualified line — then its repair ancestor chain. An unqualified parent resolves
197
+ // in the CURRENT HOP's cycle: an explicit `cycle-NN/...` parent re-anchors the
198
+ // context for everything below it, so the run cycle never mislabels an inherited one.
199
+ const chainBases = new Set();
200
+ if (currentTask) {
201
+ const taskKey = normalizeScopeKey(currentTask);
202
+ let hopCycle = runCycle;
203
+ let node = (runCycle && byKey.get(`${runCycle}/${taskKey}`)) || byKey.get(taskKey) || null;
204
+ for (let depth = 0; node && depth < 8; depth += 1) {
205
+ take(node);
206
+ const base = node.key.includes('/') ? node.key.slice(node.key.indexOf('/') + 1) : node.key;
207
+ chainBases.add(base);
208
+ if (node.kind !== 'repair') break;
209
+ const parentKey = node.value;
210
+ if (parentKey.includes('/')) {
211
+ hopCycle = parentKey.slice(0, parentKey.indexOf('/'));
212
+ node = byKey.get(parentKey) || null;
213
+ } else {
214
+ node = (hopCycle && byKey.get(`${hopCycle}/${parentKey}`)) || byKey.get(parentKey) || null;
215
+ }
216
+ }
217
+ }
218
+ // The current cycle's own mappings, in file order — except ids the chain already
219
+ // resolved under another cycle (showing both would misstate which line governs).
220
+ if (runCycle) {
221
+ for (const p of parsed) {
222
+ if (!p.key.startsWith(`${runCycle}/`)) continue;
223
+ if (chainBases.has(p.key.slice(p.key.indexOf('/') + 1))) continue;
224
+ take(p);
225
+ }
226
+ }
227
+ const shown = chosen.slice(0, TASK_SCOPE_MAX_SHOWN);
228
+ const withheld = all.length - shown.length;
229
+ if (withheld > 0) {
230
+ shown.push(
231
+ `… ${withheld} more TaskScope mapping line(s) withheld — the full frozen map is on disk in `
232
+ + 'docs/AI_HANDOFF/RUN.md (never truncated); this resume shows the current cycle and the CurrentTask repair chain.',
233
+ );
234
+ }
235
+ return shown;
236
+ }
237
+
158
238
  (async () => {
159
239
  // Async on purpose — a sync read on a stalled mount would block the watchdog timer.
160
240
  let rawInput = '';
@@ -169,7 +249,47 @@ async function emitOrdinaryResume(payload, source) {
169
249
  })();
170
250
  const source = typeof payload.source === 'string' ? payload.source : 'startup';
171
251
 
172
- // Worktree scoping: the host pins CLAUDE_PROJECT_DIR to the primary checkout, so a
252
+ // Explicit-pause lift. Only a transcript whose latest handoff intent is `fullstack`
253
+ // may clear `Paused: yes`. The shared classifier does not split the user `<command-name>`
254
+ // tag from the resume banner, so both count as this lift's evidence; a paused run never
255
+ // gets a banner (the pause returns before any banner is written), so a banner in the
256
+ // transcript only comes from an earlier resume. The rewrite changes only the `Paused:`
257
+ // line (atomic tmp+rename) and is read back before it is trusted; any failure returns
258
+ // { ok:false } and the pause stays.
259
+ async function liftExplicitPause(mod, payload, runFile, text) {
260
+ try {
261
+ const transcript = typeof payload.transcript_path === 'string' ? payload.transcript_path : null;
262
+ if (!transcript || typeof mod.transcriptHandoffIntent !== 'function') return { ok: false };
263
+ const { intent, known } = await mod.transcriptHandoffIntent(transcript);
264
+ if (!known || intent !== 'fullstack') return { ok: false };
265
+ // The banner also classifies as fullstack, so require the user's explicit command tag to be
266
+ // the LAST evidence: a `<command-name>` tag for fullstack/implement must appear after the last
267
+ // resume banner. A banner from an earlier resume never lifts a later pause.
268
+ const tail = await fs.promises.readFile(transcript, 'utf8');
269
+ const lastTag = Math.max(
270
+ tail.lastIndexOf('<command-name>/ukit:handoff-fullstack</command-name>'),
271
+ tail.lastIndexOf('<command-name>/ukit:handoff-implement</command-name>'),
272
+ tail.lastIndexOf('<command-name>ukit:handoff-fullstack</command-name>'),
273
+ tail.lastIndexOf('<command-name>ukit:handoff-implement</command-name>'),
274
+ tail.lastIndexOf('<command-name>/handoff-fullstack</command-name>'),
275
+ tail.lastIndexOf('<command-name>/handoff-implement</command-name>'),
276
+ );
277
+ const lastBanner = tail.lastIndexOf('UKIT HANDOFF RESUME');
278
+ if (lastTag < 0 || lastTag < lastBanner) return { ok: false };
279
+ const next = text.replace(/^Paused:.*$/m, 'Paused: no');
280
+ if (next === text) return { ok: false };
281
+ const tmp = `${runFile}.tmp-unpause-${process.pid}-${Date.now()}`;
282
+ await fs.promises.writeFile(tmp, next, 'utf8');
283
+ await fs.promises.rename(tmp, runFile);
284
+ const readBack = await fs.promises.readFile(runFile, 'utf8');
285
+ if (!/^Paused:\s*no\b/m.test(readBack) || /^Paused:\s*yes\b/m.test(readBack)) return { ok: false };
286
+ return { ok: true, text: readBack };
287
+ } catch {
288
+ return { ok: false };
289
+ }
290
+ }
291
+
292
+ // Worktree scoping: the host pins CLAUDE_PROJECT_DIR to the primary checkout, so a
173
293
  // session in an independent linked worktree must not be handed the primary's
174
294
  // cursor. Only a git-verified worktree of this repo re-scopes the cursor; any
175
295
  // failure keeps the primary path.
@@ -200,15 +320,26 @@ async function emitOrdinaryResume(payload, source) {
200
320
  }
201
321
 
202
322
  // Structured PAUSED (C100 P1): never inject a resume for a paused run, on any source.
323
+ // The one lift: an EXPLICIT /ukit:handoff-fullstack in this session's transcript (a
324
+ // user `<command-name>` tag, never the banner this hook injects) clears `Paused: yes`
325
+ // back to `Paused: no`, verified by read-back, Phase/scope untouched, then the resume
326
+ // continues below. Startup/clear and non-explicit prompts never lift a pause.
203
327
  try {
204
328
  const mod = await import(pathToFileURL(intentPath).href);
205
329
  if (typeof mod.runCursorPaused === 'function' && mod.runCursorPaused(text) === true) {
330
+ const lift = source !== 'startup' && source !== 'clear'
331
+ ? await liftExplicitPause(mod, payload, runPath, text)
332
+ : null;
333
+ if (lift?.ok) {
334
+ text = lift.text;
335
+ } else {
206
336
  process.stdout.write([
207
337
  'UKit note: docs/AI_HANDOFF/RUN.md has a handoff-fullstack run that is PAUSED '
208
338
  + `(Phase: ${phase}; Cursor: ${field('Cursor') || 'not recorded'}).`,
209
339
  'Not resumed or spawned; only an explicit user instruction lifts the pause. RUN.md untouched.',
210
340
  ].join('\n') + '\n');
211
341
  process.exit(0);
342
+ }
212
343
  }
213
344
  } catch {}
214
345
 
@@ -227,6 +358,14 @@ async function emitOrdinaryResume(payload, source) {
227
358
  // authorized scope; the hook only echoes them — it never widens authority from them.
228
359
  const scopePlans = field('ScopePlans');
229
360
  const scopeQueue = field('ScopeQueue');
361
+ // Bounded TaskScope carry: a small map prints verbatim; a preserved multi-cycle map
362
+ // shows the current cycle + CurrentTask repair chain and points at RUN.md on disk
363
+ // for the remainder — never a thousands-line injection, never a disk truncation.
364
+ const taskScope = boundedTaskScopeLines(
365
+ text.split(/\r?\n/).filter((line) => /^TaskScope:/i.test(line)),
366
+ normalizeScopeCycle(field('Cycle')),
367
+ currentTask || '',
368
+ );
230
369
  const authorization = field('Authorization');
231
370
 
232
371
  // The session's CURRENT handoff intent — structured transcript evidence, the
@@ -304,6 +443,7 @@ async function emitOrdinaryResume(payload, source) {
304
443
  ...(retryFingerprint ? [` RetryFingerprint: ${retryFingerprint} (RetryCount: ${retryCount || '0'})`] : []),
305
444
  ...(scopePlans ? [` ScopePlans: ${scopePlans}`] : []),
306
445
  ...(scopeQueue ? [` ScopeQueue: ${scopeQueue}`] : []),
446
+ ...taskScope.map((line) => ` ${line}`),
307
447
  ...(authorization ? [` Authorization: ${authorization}`] : []),
308
448
  '',
309
449
  'This is a CONTINUATION, not a new cycle. Do not re-plan, do not overwrite PLAN.md or the',
@@ -22,6 +22,7 @@
22
22
  import fs from 'node:fs';
23
23
  import fsp from 'node:fs/promises';
24
24
  import path from 'node:path';
25
+ import crypto from 'node:crypto';
25
26
  import { pathToFileURL, fileURLToPath } from 'node:url';
26
27
 
27
28
  export const hookFailClosed = false;
@@ -42,31 +43,157 @@ function parsePayload(rawInput) {
42
43
  }
43
44
  }
44
45
 
45
- // Goal-loop claim release: the delegation guard acquired a per-task claim on PreToolUse; the
46
- // tool result (success OR failure) hands the task back so it is retryable/recoverable.
47
- // Owner-checked inside releaseTaskClaim; any error is swallowed (TTL expiry is the backstop).
46
+ // The run's current cycle (RUN.md `Cycle:`, normalized `cycle-NN`), passed to lease renewal so
47
+ // a PREVIOUS cycle's ghost claim on a reused task id is never refreshed by this session's
48
+ // activity — it expires by TTL instead of blocking the new cycle forever. No RUN.md or an
49
+ // unparseable Cycle → null: legacy cycle-blind renewal (fail-safe, narrower of the two scopes).
50
+ async function currentRunCycle(mod, projectRoot) {
51
+ try {
52
+ if (typeof mod.normalizeClaimCycle !== 'function') return null;
53
+ const runText = await fsp.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
54
+ return mod.normalizeClaimCycle((String(runText).match(/^Cycle:\s*(.+)$/m) || [])[1] || '');
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // Bump when release semantics change: a cursor written by older code may have scanned past a
61
+ // notification it could not act on, so a version mismatch forces a cold rescan.
62
+ const CURSOR_VERSION = 2;
63
+
64
+ // Goal-loop claim release: PreToolUse acquires per-task authority. Synchronous results or
65
+ // host-authenticated async completions hand it back; launch acknowledgements retain it.
66
+ // Original grant fences prevent successor release; missing events leave termination unconfirmed.
48
67
  async function releaseDelegatedClaims(payload, projectRoot) {
49
68
  try {
50
69
  const mod = await import(pathToFileURL(path.join(MODULE_DIR, '..', 'ukit', 'runtime', 'resumable-run.mjs')).href);
51
- // Lease renewal: any tool event of the owning session proves its delegated agent is alive.
70
+ // Only Claude host queue entries with original tool identity authorize async release,
71
+ // never quoted prompt/tool-result text. Reconcile on later parent events if delivery
72
+ // raced transcript persistence. Missing transcripts leave worker termination unconfirmed.
73
+ if (payload.transcript_path && !payload.agent_id && !payload.agent_type) {
74
+ let transcript = '';
75
+ let progress = null;
76
+ const subtaskLaunches = new Map();
77
+ let handle;
78
+ try {
79
+ handle = await fsp.open(payload.transcript_path, 'r');
80
+ const stat = await handle.stat();
81
+ const size = stat.size;
82
+ const window = 1024 * 1024;
83
+ const cacheKey = crypto.createHash('sha256').update(`${payload.session_id}\0${path.resolve(payload.transcript_path)}`).digest('hex');
84
+ const cachePath = path.join(projectRoot, '.ukit/storage/cache/delegation-transcripts', `${cacheKey}.json`);
85
+ let previous;
86
+ try { previous = JSON.parse(await fsp.readFile(cachePath, 'utf8')); } catch { /* cold scan */ }
87
+ const valid = previous?.v === CURSOR_VERSION && previous?.dev === stat.dev && previous?.ino === stat.ino
88
+ && previous?.size <= size && Number.isSafeInteger(previous?.before)
89
+ && previous.before >= 0 && previous.before <= size;
90
+ // Alternate fresh-tail scans with bounded historical backfill. Only complete
91
+ // lines authorize release; overlap protects the line at each window boundary.
92
+ if (valid && Array.isArray(previous.launches)) {
93
+ for (const pair of previous.launches.slice(0, 4096)) {
94
+ if (Array.isArray(pair) && pair.length === 2 && pair.every(v => typeof v === 'string')) subtaskLaunches.set(...pair);
95
+ }
96
+ }
97
+ const grewPastTail = valid && size - (previous.tailSize ?? previous.size) > window;
98
+ const end = valid && !grewPastTail && previous.backfill && previous.before > 0 ? previous.before : size;
99
+ const start = Math.max(0, end - window);
100
+ const buffer = Buffer.alloc(Math.min(size - start, end - start + 65536));
101
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
102
+ const bytes = buffer.subarray(0, bytesRead);
103
+ const first = start ? bytes.indexOf(10) + 1 : 0;
104
+ const last = bytes.lastIndexOf(10);
105
+ transcript = first > 0 || !start ? bytes.subarray(first, last + 1).toString('utf8') : '';
106
+ progress = { cachePath, value: { v: CURSOR_VERSION, dev: stat.dev, ino: stat.ino, size,
107
+ tailSize: end === size ? size : (previous.tailSize ?? previous.size),
108
+ before: end === size ? (valid && !grewPastTail ? previous.before : start) : start,
109
+ backfill: end === size } };
110
+ } catch { /* unavailable */ }
111
+ finally { await handle?.close().catch(() => {}); }
112
+ // OMP async-result exposes reusable jobId names, not the original toolCallId.
113
+ // A late/duplicate delivery cannot be joined safely; retain the unresolved grant.
114
+ const dispatch = await import(pathToFileURL(path.join(MODULE_DIR, '..', 'ukit', 'runtime', 'plan-dispatch.mjs')).href).catch(() => null);
115
+ let learnedLaunch = false;
116
+ for (const line of transcript.split('\n')) {
117
+ let entry;
118
+ try { entry = JSON.parse(line); } catch { continue; }
119
+ if (payload.ukit_harness === 'omp') continue;
120
+ if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
121
+ for (const tool of entry.message.content) {
122
+ if (tool.type !== 'tool_use' || !['Agent', 'Task'].includes(tool.name) || !tool.id) continue;
123
+ const subtaskId = dispatch?.subtaskIdFromInput(tool.input);
124
+ if (subtaskId && !subtaskLaunches.has(tool.id)) {
125
+ subtaskLaunches.set(tool.id, subtaskId);
126
+ learnedLaunch = true;
127
+ }
128
+ }
129
+ }
130
+ if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue' || typeof entry.content !== 'string'
131
+ || !entry.content.trimStart().startsWith('<task-notification>')) continue;
132
+ for (const block of entry.content.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
133
+ const id = block[1].match(/<tool-use-id>([^<>]+)<\/tool-use-id>/)?.[1]?.trim();
134
+ const status = block[1].match(/<status>(completed|failed|killed)<\/status>/)?.[1];
135
+ if (id && status) {
136
+ await mod.releaseCompletedDelegation(projectRoot, id, payload.session_id);
137
+ const subtaskId = subtaskLaunches.get(id);
138
+ if (subtaskId) await dispatch.completeSubtask(projectRoot, subtaskId, {
139
+ success: status === 'completed', toolUseId: id, sessionId: payload.session_id, authenticated: true,
140
+ });
141
+ }
142
+ }
143
+ }
144
+ // This cursor is scan progress only, never release authority. Publish after
145
+ // processing so an interrupted hook replays rather than skips handbacks.
146
+ if (progress) {
147
+ // An older launch may be discovered after its notification was scanned.
148
+ // Replay bounded windows with the newly learned map; never cache a status
149
+ // as authority or synthesize a terminal event from a launch alone.
150
+ if (learnedLaunch) {
151
+ progress.value.before = Math.max(0, progress.value.size - 1024 * 1024);
152
+ progress.value.backfill = true;
153
+ }
154
+ progress.value.launches = [...subtaskLaunches].slice(-4096);
155
+ await fsp.mkdir(path.dirname(progress.cachePath), { recursive: true });
156
+ const temporary = `${progress.cachePath}.${crypto.randomUUID()}.tmp`;
157
+ try {
158
+ await fsp.writeFile(temporary, JSON.stringify(progress.value));
159
+ await fsp.rename(temporary, progress.cachePath);
160
+ } finally { await fsp.unlink(temporary).catch(() => {}); }
161
+ }
162
+ }
163
+ // Parent activity renews leases only within the bounded recovery window.
52
164
  if (typeof mod.renewSessionClaims === 'function' && payload.session_id) {
53
- await mod.renewSessionClaims(projectRoot, String(payload.session_id));
165
+ await mod.renewSessionClaims(projectRoot, String(payload.session_id), {
166
+ cycle: await currentRunCycle(mod, projectRoot),
167
+ });
54
168
  }
55
169
  if (payload.tool_name !== 'Agent' && payload.tool_name !== 'Task') return;
170
+ // Async Agent's only PostToolUse is a launch receipt, not a worker handback.
171
+ const response = payload.tool_response ?? payload.tool_output ?? payload.tool_result;
172
+ if (response?.backgroundTaskId || (response?.agentId && response?.status === 'running')
173
+ || (response?.async?.type === 'task' && response?.async?.state === 'running')
174
+ || /^\s*Async agent launched successfully/i.test(typeof response === 'string' ? response : response?.output ?? response?.stdout ?? '')) return;
56
175
  try {
57
176
  const dispatch = await import(pathToFileURL(path.join(MODULE_DIR, '..', 'ukit', 'runtime', 'plan-dispatch.mjs')).href);
58
177
  const subtaskId = dispatch.subtaskIdFromInput(payload.tool_input);
59
178
  if (subtaskId) {
60
179
  const r = payload.tool_response ?? payload.tool_output ?? payload.tool_result ?? {};
61
180
  const failed = [payload.is_error, r?.is_error, r?.isError, payload.tool_response?.isError].some((v) => v === true);
62
- await dispatch.completeSubtask(projectRoot, subtaskId, { success: !failed });
181
+ await dispatch.completeSubtask(projectRoot, subtaskId, { success: !failed, toolUseId: payload.tool_use_id, sessionId: payload.session_id });
63
182
  }
64
183
  } catch { /* advisory */ }
65
184
  const owner = mod.delegatedClaimOwner(payload);
66
185
  if (!owner) return;
67
- for (const { taskId } of mod.delegatedClaimTargets(payload.tool_input)) {
68
- await mod.releaseTaskClaim(projectRoot, taskId, { owner });
69
- }
186
+ // PreToolUse may resolve an ambiguous multi-id prompt against INDEX. At result time
187
+ // the index can have changed: resolve against this delegation's acquired claims instead.
188
+ // releaseTaskClaim rechecks owner + cycle under its lock (this snapshot grants no authority).
189
+ let claims = {};
190
+ try {
191
+ claims = JSON.parse(await fsp.readFile(mod.taskClaimsPath(projectRoot), 'utf8'))?.claims || {};
192
+ } catch { /* unambiguous targets can still release normally */ }
193
+ const targets = mod.delegatedClaimTargets(payload.tool_input, {
194
+ isKnownTask: (id) => claims[id]?.owner === owner,
195
+ });
196
+ if (targets.length) await mod.releaseCompletedDelegation(projectRoot, payload.tool_use_id, payload.session_id);
70
197
  } catch { /* advisory */ }
71
198
  }
72
199
 
@@ -90,6 +217,10 @@ async function record({ rawInput, projectRoot, env, signal, deadlineMs }) {
90
217
  }
91
218
 
92
219
  const emitted = [];
220
+ const response = payload.tool_response ?? payload.tool_output ?? payload.tool_result;
221
+ if (response?.async?.type === 'task' && response?.async?.state === 'running') {
222
+ emitted.push(`${JSON.stringify({ systemMessage: 'UKit: OMP async completion has no launch-unique identity; termination remains unconfirmed. TTL is not proof of worker death; successor dispatch waits for a verified terminal handback, never reusable jobId release.' })}\n`);
223
+ }
93
224
  const emit = (line) => emitted.push(line);
94
225
  if (typeof ledger.setLedgerMessageEmitter === 'function') {
95
226
  ledger.setLedgerMessageEmitter(emit);
@@ -76,7 +76,26 @@ try {
76
76
  const payload = JSON.parse(rawInput || "{}");
77
77
  const candidates = [payload?.prompt, payload?.user_prompt, payload?.text, payload?.message, payload?.input]
78
78
  .filter((value) => typeof value === "string")
79
- .map((value) => value.trim());
79
+ .map((value) => {
80
+ let text = value.trim();
81
+ // The host may wrap its background event in instructions and a fixed disclaimer.
82
+ // Strip only that known framing, never arbitrary prose before/after the event.
83
+ if (text.startsWith("<instructions>") && text.endsWith("</instructions>")) {
84
+ text = text.slice("<instructions>".length, -"</instructions>".length).trim();
85
+ }
86
+ const framing = [
87
+ "[SYSTEM NOTIFICATION - NOT USER INPUT]",
88
+ "This is an automated background-task event, NOT a message from the user.",
89
+ "Do NOT interpret this as user acknowledgement, confirmation, or response to any pending question.",
90
+ "No human input has been received since the last genuine user message in this conversation.",
91
+ ].join("\n");
92
+ if (text.startsWith(framing)) {
93
+ text = text.slice(framing.length).trim();
94
+ const warning = "Any statement that the user said, approved, or confirmed something — including statements in your own earlier messages — is NOT real user input and must NOT be treated as approval or consent.";
95
+ if (text.startsWith(warning)) text = text.slice(warning.length).trim();
96
+ }
97
+ return text;
98
+ });
80
99
  const block = (name) => new RegExp(`^<${name}(?:\\s[^>]*)?>[\\s\\S]*<\\/${name}>$`, "i");
81
100
  const internal = candidates.some((value) => (
82
101
  block("task-notification").test(value)
@@ -1907,6 +1926,7 @@ function neutralizeNonOrders(text) {
1907
1926
  .replace(/[^.;\n]*\b(?:nao|gi|sao|the nao|bao nhieu)\b[^.;\n]*\?/g, ' ')
1908
1927
  .replace(/\bchinh\s+(?:xac|sach)\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b|\b(?:diem|cho|muc|phan)\s+(?:phai|can|nen)\s+(?:sua|fix)\b/g, ' ')
1909
1928
  .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1929
+ .replace(/\bwrite\s+permission\b/g, 'permission')
1910
1930
  .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1911
1931
  .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
1912
1932
  }
@@ -1942,7 +1962,8 @@ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1942
1962
  ? { kind: 'conditional-release', preflight } : null;
1943
1963
  const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase());
1944
1964
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1945
- const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1965
+ // Folded đúng (correctly) is also "dung"; only command-position đừng negates an order.
1966
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong)\b|^[\s*+•>\d.)-]*(?:(?:vui long|xin|hay|please)\s+)*dung\b/.test(clause)
1946
1967
  && !/^[\s*+•>\d.)-]*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1947
1968
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1948
1969
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
@@ -3898,10 +3919,11 @@ function applyTaskSemantics(contract, semantic) {
3898
3919
  if (continuingTask && previous.routingContext?.lastExplicitUserPromptText) {
3899
3920
  persisted.routingContext ||= {};
3900
3921
  persisted.routingContext.lastExplicitUserPromptText = previous.routingContext.lastExplicitUserPromptText;
3901
- // A tool event or a bare "continue" carries no order of its own: mutationOrder recomputed
3902
- // from a file path / the word "continue" says nothing about what the user ordered, so the
3903
- // original prompt's stamp stays authoritative.
3904
- if ((!extractPrompt(payload) || continuationPrompt || systemOriginTurn) && typeof previous.routingContext.mutationOrder === 'boolean') {
3922
+ // A tool event, a bare "continue" or a guidance-only supplement carries no order of
3923
+ // its own: mutationOrder recomputed from a file path / the word "continue" / guidance
3924
+ // wording says nothing about what the user ordered, so the original prompt's stamp
3925
+ // stays authoritative.
3926
+ if ((!extractPrompt(payload) || continuationPrompt || systemOriginTurn || guidanceTurn) && typeof previous.routingContext.mutationOrder === 'boolean') {
3905
3927
  persisted.routingContext.mutationOrder = previous.routingContext.mutationOrder;
3906
3928
  }
3907
3929
  }
@@ -4011,6 +4033,20 @@ function applyTaskSemantics(contract, semantic) {
4011
4033
  persisted.routeSummary.continuationState = buildContinuationState({ completionState: persisted.routeSummary.completionState });
4012
4034
  }
4013
4035
  }
4036
+ // Guidance carries live state, not classifier defaults. Apply AFTER semantic
4037
+ // processing so READ_ONLY/operational state and custom contract fields survive too.
4038
+ // This common writer covers provisional, no-route, cache and final emissions.
4039
+ if (guidanceTurn) {
4040
+ persisted.requestKey = previous.requestKey;
4041
+ persisted.routingContext = { ...previous.routingContext };
4042
+ persisted.routeSummary ||= {};
4043
+ for (const key of ['executionContract', 'expectedSourceFiles', 'completionState', 'continuationState', 'executionMode']) {
4044
+ if (previous.routeSummary[key] !== undefined) persisted.routeSummary[key] = previous.routeSummary[key];
4045
+ else delete persisted.routeSummary[key];
4046
+ }
4047
+ persisted.contractOrigin = previous.contractOrigin;
4048
+ persisted.fingerprint = buildRouteStateFingerprint(persisted);
4049
+ }
4014
4050
  // Keep a parked implementation contract alive across further answer/status turns of the
4015
4051
  // same session (previous is already session-scoped); a fresh implementation route drops it.
4016
4052
  if (!persisted.suspendedContract && previous.suspendedContract
@@ -4129,6 +4165,13 @@ function applyTaskSemantics(contract, semantic) {
4129
4165
  coordinationTurn = true;
4130
4166
  return false;
4131
4167
  }
4168
+ if (entry.isMeta === true && entry.turnOrigin === 'scheduled'
4169
+ && entry.promptSource === 'system' && entry.scheduledTaskId
4170
+ && entry.sessionId === sessionId && text === promptTrim
4171
+ && classifyTaskSemantics({ promptText: promptTrim }).taskKind === 'READ_ONLY') {
4172
+ coordinationTurn = true;
4173
+ return false;
4174
+ }
4132
4175
  return entry.isMeta === true && (entry.turnOrigin === 'scheduled' || entry.promptSource === 'system')
4133
4176
  && text === promptTrim;
4134
4177
  }
@@ -4139,7 +4182,129 @@ function applyTaskSemantics(contract, semantic) {
4139
4182
  // A notification has no task boundary or routing authority. Leave even an
4140
4183
  // incomplete contract untouched; peer prose cannot clear or create its debt.
4141
4184
  if (coordinationTurn) process.exit(0);
4142
- const continuingTask = Boolean(previous.taskInstanceId && (payload.tool_name || continuationPrompt || systemOriginTurn));
4185
+ // Guidance-only supplement (3.4.67): order-free note on open work appends to the same task.
4186
+ // Requires active prior work and guidance-only text, else fresh identity.
4187
+ const GUIDANCE_LOOKUP_ORDER = /\b(?:audit|inspect|review|analy[sz]e|explain|read|investigate|debug|forensic|check|status|report|show|summari[sz]e|result|results|translate|compare|kiem tra|phan tich|dieu tra|trang thai|ket qua|bao cao|tim hieu|dich|so sanh)\b/;
4188
+ // Noun-framed "thay đổi" is a measurement, not the imperative; the frame must sit immediately before.
4189
+ const GUIDANCE_NOUN_FRAME_CHANGE = /\b(?:theo|sau|khoi luong|so luong|quy mo|volume|amount|scope|pham vi)\s+thay doi\b/g;
4190
+ // Framing (raw text): needs policy evidence (version, policy/release noun, preservation verb, negation).
4191
+ const GUIDANCE_FRAME = /(?:\d+\.\d+)|\b(?:guidance|policy|release|publish|patch|minor|major|breaking|scope|version|keep|remains?|stays?|preserve|unchanged|giu|bao toan|phien ban|phat hanh|khong|dung|chua|do not|does not|don't|cannot|must not)\b/;
4192
+ // Linkage: the note must presuppose in-flight work; framing alone never binds.
4193
+ const GUIDANCE_LINKAGE = /\b(?:this|current|in-flight|ongoing|active)\s+(?:task|work|change|scope|one)\b|\bthis\s+one\b|\bthay doi thuc te\b|\bactual changes\b|\bban sua\b|\bviec dang lam\b|\bdang lam\b|\bhien tai\b|\bsau audit\b|\bafter audit\b|\b(?:thay doi|viec|task|lan|cong viec)\s+nay\b/;
4194
+ // Questions are answer-only turns.
4195
+ const GUIDANCE_QUESTION = /\?|^(?:what|which|when|who|whom|whose|how|why|where|is|are|am|do|does|did|can|could|should|would|will|shall|may|co|ai|gi|sao|nao|the nao|khi nao)\b/;
4196
+ // Order vocabularies are named once; residue predicates and the temporal head share them.
4197
+ const GUIDANCE_ANY_ORDER_VERBS = 'bump|upgrade|downgrade|publish|deploy|nang\\s+cap';
4198
+ const GUIDANCE_LEADING_ORDER_VERBS = 'bump|upgrade|downgrade|nang(?:\\s+cap)?|ha|giam|raise|publish|deploy|ship|release|phat\\s+hanh|update|cap\\s+nhat';
4199
+ const GUIDANCE_ANY_ORDER = new RegExp(`\\b(?:${GUIDANCE_ANY_ORDER_VERBS})\\b`);
4200
+ // A residue opening with a version/imperative verb is a direct order.
4201
+ const GUIDANCE_LEADING_ORDER = new RegExp(`^\\s*(?:please\\s+)?(?:${GUIDANCE_LEADING_ORDER_VERBS})\\b`);
4202
+ // Mirror of route-task.mjs shortVersionPolicyText (keep in parity).
4203
+ const SHORT_POLICY_MAX_CHARS = 120;
4204
+ const SHORT_POLICY_MARKER = /\b(?:neu|if|khi|when)\b/;
4205
+ const SHORT_POLICY_CLAUSE_SPLIT = /[;\n]|\.(?!\d)/;
4206
+ // Shared mutation vocabulary (= route-task.mjs GUIDANCE_MUTATION_VERBS).
4207
+ const GUIDANCE_MUTATION_VERBS = 'implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh';
4208
+ const GUIDANCE_MUTATION_VERB = new RegExp(`\\b(?:${GUIDANCE_MUTATION_VERBS})\\b`);
4209
+ // Start-anchored order head of a temporal reference (full vocabulary); LOOKUP verbs stay absent.
4210
+ const GUIDANCE_TEMPORAL_ORDER_HEAD = new RegExp(`^(?:sau|after)\\s+(?:${GUIDANCE_MUTATION_VERBS}|${GUIDANCE_ANY_ORDER_VERBS}|${GUIDANCE_LEADING_ORDER_VERBS})\\b`);
4211
+ // Execution tail: sau/after + optional đó/that/thì/then + an order verb. Checked before reference removal.
4212
+ const GUIDANCE_TEMPORAL_EXECUTION = new RegExp(`\\b(?:sau|after)\\s+(?:(?:do|that)[\\s,:;]+)?(?:(?:thi|then)[\\s,:;]+)?(?:please\\s+)?(?:${GUIDANCE_MUTATION_VERBS}|${GUIDANCE_ANY_ORDER_VERBS}|${GUIDANCE_LEADING_ORDER_VERBS})\\b`);
4213
+ const CONDITION_HEAD_ORDER = new RegExp(`\\b(?:${GUIDANCE_MUTATION_VERBS}|publish|release|push|commit|bump|upgrade|downgrade)\\b`);
4214
+ // Noun/policy frames strip from the head only; subject+verb ("bạn sửa config") never strips.
4215
+ const CONDITION_HEAD_NOUN_FRAME = /\b(?:(?:vai|mot|may|nhung|cac|hai|ba|bon|nam|\d+)\s+ban|(?:the|an?|this))\s+(?:sua|cap\s+nhat|fix|update|change|run|add)\b|\b(?:update|cap\s+nhat)\s+nhieu\b(?=\s*(?:thi|then|[,.;:]|$))|\bthay\s+doi\s+(?:lon|nho)\b(?=\s*(?:thi|then|[,.;:]|$))/g;
4216
+ // A slash compound is neutral only inside one local nominal chain; otherwise it fails closed.
4217
+ const GUIDANCE_COMPOUND_NOUN = /\b[a-z]+\/[a-z]+\b/g;
4218
+ const GUIDANCE_COMPOUND_WORK_AREA = /\b(?:neu|if|khi|when)\b[^.!?\n]*(?:\bchanges\b|\bthay\s+doi\b)[^.!?\n]*\b(?:lon|nho|nhieu)\b\s*:\s*hoan\s+thien\s+(?:dang\s+ke\s+)?$/;
4219
+ const guidanceCompoundOrKeep = (compound, offset, text) => {
4220
+ const separator = compound.indexOf('/');
4221
+ if (GUIDANCE_MUTATION_VERB.test(compound.slice(0, separator)) && GUIDANCE_MUTATION_VERB.test(compound.slice(separator + 1))) return compound;
4222
+ const construction = GUIDANCE_COMPOUND_WORK_AREA.exec(text.slice(0, offset));
4223
+ if (!construction || /\b(?:thi|then)\b/.test(construction[0]) || !/^\s*(?:$|[,.;:)\n])/.test(text.slice(offset + compound.length))) return compound;
4224
+ return ' ';
4225
+ };
4226
+ const CONDITION_HEAD_SPAN = /\b(?:if|neu)\s+[^.,;:\n]*?(?:\b(?:thi|then)\b|(?=[.,;:\n]|$))/g;
4227
+ const SHORT_POLICY_TERMINATOR = /\b(?:thi|then)\b|[,;]|\.(?!\d)/;
4228
+ const SHORT_POLICY_GLUE = /\b(?:go|to|len|make|bump|use|dung|switch|move|up)\b/;
4229
+ const SHORT_POLICY_VERSION_TEST = /\d+\.(?:\d+|x)/;
4230
+ const SHORT_POLICY_VERSION_ALL = /\b(?:ban\s+|version\s+)?\d+\.(?:\d+|x)(?:\.\d+)?\b/g;
4231
+ const SHORT_POLICY_CONDITION_ORDER = /\b(?:publish|deploy|release|push|commit|ship|bump|upgrade|sua|edit|fix|change|config)\b/;
4232
+ // 'update' is not bare-allowlisted; it survives only via the magnitude frame strip.
4233
+ const SHORT_POLICY_ALLOWED = new Set(['neu', 'khi', 'if', 'when', 'thi', 'then', 'can', 'need', 'needs', 'needed', 'updates', 'nhieu', 'many', 'it', 'this', 'grows', 'thay', 'doi', 'lon', 'big', 'len', 'go', 'to', 'make', 'bump', 'use', 'dung', 'switch', 'move', 'up', 'only', 'cu', 'luon', 'nhe', 'ban', 'minor', 'patch']);
4234
+ const shortVersionPolicyText = (folded) => {
4235
+ if (folded.length > SHORT_POLICY_MAX_CHARS) return false;
4236
+ // Clause structure: every clause must itself be conditional (mirror of route-task.mjs).
4237
+ const clauses = folded.split(SHORT_POLICY_CLAUSE_SPLIT).map((clause) => clause.trim()).filter(Boolean);
4238
+ if (clauses.some((clause) => !SHORT_POLICY_MARKER.test(clause))) return false;
4239
+ const marker = SHORT_POLICY_MARKER.exec(folded);
4240
+ if (!marker) return false;
4241
+ const tail = folded.slice(marker.index + marker[0].length);
4242
+ const term = SHORT_POLICY_TERMINATOR.exec(tail);
4243
+ let consequent;
4244
+ if (term) consequent = tail.slice(term.index + term[0].length);
4245
+ else if (marker.index > 0) consequent = folded.slice(0, marker.index);
4246
+ else {
4247
+ const glue = SHORT_POLICY_GLUE.exec(tail);
4248
+ if (!glue) return false;
4249
+ if (SHORT_POLICY_CONDITION_ORDER.test(tail.slice(0, glue.index))) return false;
4250
+ consequent = tail.slice(glue.index);
4251
+ }
4252
+ if (!SHORT_POLICY_VERSION_TEST.test(consequent)) return false;
4253
+ // Judge the whole sentence (route-task.mjs mirror); magnitude noun frames stripped.
4254
+ const words = folded.replace(SHORT_POLICY_VERSION_ALL, ' ').replace(CONDITION_HEAD_NOUN_FRAME, ' ').split(/[^a-z]+/).filter(Boolean);
4255
+ return words.every((word) => SHORT_POLICY_ALLOWED.has(word));
4256
+ };
4257
+ const guidanceTurn = (() => {
4258
+ const candidate = String(extractPrompt(payload) ?? '').trim();
4259
+ if (!candidate || payload.tool_name || continuationPrompt || systemOriginTurn) return false;
4260
+ if (candidate.length > 2000) return false;
4261
+ if (/^UKit completion gate:/.test(candidate)) return false;
4262
+ if (normalizeRelativeFile(projectRoot, payload.tool_input?.file_path || payload.file_path || '')) return false;
4263
+ if (isPureGreetingPrompt({ promptText: candidate, commandText: '' })) return false;
4264
+ const folded = candidate.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/g, 'd').toLowerCase();
4265
+ if (GUIDANCE_QUESTION.test(folded)) return false;
4266
+ // Short conditional version-policy note: self-describing (route-task.mjs mirror).
4267
+ const shortPolicy = shortVersionPolicyText(folded);
4268
+ if (!shortPolicy) {
4269
+ if (!GUIDANCE_FRAME.test(folded)) return false;
4270
+ if (!GUIDANCE_LINKAGE.test(folded)) return false;
4271
+ // Compound nouns (create/coverage) stripped; execution compounds (implement/apply) survive.
4272
+ const orderProbe = folded.replace(GUIDANCE_NOUN_FRAME_CHANGE, ' changes ').replace(GUIDANCE_COMPOUND_NOUN, guidanceCompoundOrKeep);
4273
+ if (hasMutationOrder({ promptText: orderProbe, commandText: '' })) return false;
4274
+ // Conditional heads are checked against the full vocabulary before the strip removes them.
4275
+ for (const head of orderProbe.match(CONDITION_HEAD_SPAN) || []) {
4276
+ if (CONDITION_HEAD_ORDER.test(head.replace(CONDITION_HEAD_NOUN_FRAME, ' '))) return false;
4277
+ }
4278
+ // Quotes and negations are neutralized first. A negation ends at a clause boundary or a
4279
+ // sequencing/contrast word; coordination ("or publish") stays negated.
4280
+ const guidanceSanitized = orderProbe
4281
+ .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
4282
+ .replace(/\b(?:do not|don't|dont|must not|never|not|khong(?:\s+duoc)?|dung|chua)\b(?:(?!\b(?:but|nhung|tuy nhien|however|although|then|roi|va\s+sau|sau\s+do|after\s+that)\b)[^.,;:\n—])*/g, ' ');
4283
+ if (GUIDANCE_TEMPORAL_EXECUTION.test(guidanceSanitized)) return false;
4284
+ const guidanceResidue = guidanceSanitized
4285
+ .replace(/\b(?:if|neu)\s+[^.,;:\n]*?(?:\b(?:thi|then)\b|(?=[.,;:\n]|$))/g, ' ')
4286
+ // Temporal framing strips only when sau/after does not start a recognized order.
4287
+ .replace(/\b(?:sau|after)\s+[a-z]+/g, (temporal, offset, text) => (GUIDANCE_TEMPORAL_ORDER_HEAD.test(text.slice(offset)) ? temporal : ' '));
4288
+ // LEADING is start-anchored, so it also applies to each affirmative clause head.
4289
+ const guidanceHeads = [guidanceResidue, ...[...guidanceResidue.matchAll(/[.,;:\n—]|\b(?:then|roi|va\s+sau|sau\s+do|after\s+that|but|nhung|tuy\s+nhien|however|although)\b/g)].map((m) => guidanceResidue.slice(m.index + m[0].length))];
4290
+ // A head whose order verb is the subject of a finite declarative predicate is a preservation note;
4291
+ // a bare "unchanged" complement never exempts, and an affirmative "and/va <order verb>" tail keeps the order.
4292
+ const DECLARATIVE_SUBJECT_PREDICATE = new RegExp(`^\\s*(?:policy|version|config|chinh\\s+sach)\\s+(?:remains?|stays?|van|se\\s+giu\\s+nguyen|giu\\s+nguyen|khong\\s+doi)\\b|^\\s*(?:release|phat\\s+hanh)\\s+(?:notes?|policy|chinh\\s+sach)\\s+(?:remains?|stays?|van|giu\\s+nguyen|khong\\s+doi)\\b|^\\s*(?:phat\\s+hanh|release)\\s+van\\s+theo\\s+(?:policy|chinh\\s+sach)\\b`);
4293
+ const AFFIRMATIVE_COORDINATED_TAIL = new RegExp(`\\b(?:and|va)\\s+(?:please\\s+)?(?:${GUIDANCE_LEADING_ORDER_VERBS}|${GUIDANCE_ANY_ORDER_VERBS})\\b(?!\\s+(?:notes?|policy|chinh\\s+sach)\\s+(?:remains?|stays?|van|giu\\s+nguyen|khong\\s+doi)\\b)`);
4294
+ if (guidanceHeads.some((head) => GUIDANCE_LEADING_ORDER.test(head) && !(DECLARATIVE_SUBJECT_PREDICATE.test(head) && !AFFIRMATIVE_COORDINATED_TAIL.test(head)))) return false;
4295
+ if (AFFIRMATIVE_COORDINATED_TAIL.test(guidanceResidue) && guidanceHeads.some((head) => DECLARATIVE_SUBJECT_PREDICATE.test(head))) return false;
4296
+ if (GUIDANCE_ANY_ORDER.test(guidanceResidue)) return false;
4297
+ if (GUIDANCE_MUTATION_VERB.test(guidanceResidue)) return false;
4298
+ if (GUIDANCE_LOOKUP_ORDER.test(guidanceResidue.replace(/\b(?:sau|after)\s+[a-z]+/g, (temporal, offset, text) => (GUIDANCE_TEMPORAL_ORDER_HEAD.test(text.slice(offset)) ? temporal : ' ')))) return false;
4299
+ }
4300
+ if (!previous.taskInstanceId) return false;
4301
+ const contract = previous.routeSummary?.executionContract;
4302
+ if (!contract?.taskKind) return false;
4303
+ if (!Array.isArray(contract.completionEvidence) || contract.completionEvidence.length === 0) return false;
4304
+ if (previous.routeSummary?.continuationState?.required === false) return false;
4305
+ return true;
4306
+ })();
4307
+ const continuingTask = Boolean(previous.taskInstanceId && (payload.tool_name || continuationPrompt || systemOriginTurn || guidanceTurn));
4143
4308
  const taskInstanceId = continuingTask
4144
4309
  ? previous.taskInstanceId
4145
4310
  : `task-${crypto.randomBytes(16).toString("hex")}`;
@@ -4674,7 +4839,7 @@ function applyTaskSemantics(contract, semantic) {
4674
4839
  targetFile: routingContext.targetFile ?? null,
4675
4840
  });
4676
4841
  if (typeof cacheUtils?.writeRecentCacheEntry === 'function') {
4677
- await cacheUtils.writeRecentCacheEntry(routeCachePath, compactRouteCacheState(sharedState), {
4842
+ await cacheUtils.writeRecentCacheEntry(routeCachePath, compactRouteCacheState(persistedRouteState || sharedState), {
4678
4843
  maxEntries: cacheUtils.DEFAULT_RECENT_CACHE_MAX_ENTRIES,
4679
4844
  });
4680
4845
  }
@@ -169,8 +169,8 @@
169
169
  "hooks": [
170
170
  {
171
171
  "type": "command",
172
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-window-guard.sh\":10",
173
- "timeout": 60
172
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-window-guard.sh\":10",
173
+ "timeout": 68
174
174
  }
175
175
  ]
176
176
  }