@ngockhoale/ukit 2.5.1 → 2.6.2

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 (53) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/manifests/platform.full.yaml +16 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/install.js +49 -2
  5. package/src/cli/commands/update.js +5 -0
  6. package/src/core/output/index.js +77 -0
  7. package/src/core/update.js +36 -5
  8. package/templates/.claude/agents/code-reviewer.md +12 -1
  9. package/templates/.claude/agents/feature-implementer.md +4 -2
  10. package/templates/.claude/agents/handoff-planner.md +36 -1
  11. package/templates/.claude/commands/ukit/handoff-clear.md +4 -0
  12. package/templates/.claude/commands/ukit/handoff-create.md +23 -7
  13. package/templates/.claude/commands/ukit/handoff-fullstack.md +181 -17
  14. package/templates/.claude/commands/ukit/handoff-implement.md +9 -2
  15. package/templates/.claude/commands/ukit/handoff-review.md +4 -1
  16. package/templates/.claude/commands/ukit/handoff-status.md +6 -2
  17. package/templates/.claude/hooks/auto-allow-bash.sh +17 -5
  18. package/templates/.claude/hooks/block-dangerous.sh +18 -5
  19. package/templates/.claude/hooks/completion-gate.sh +17 -5
  20. package/templates/.claude/hooks/compress-output.sh +15 -6
  21. package/templates/.claude/hooks/context-hardcap-gate.sh +77 -46
  22. package/templates/.claude/hooks/context-window-guard.sh +41 -27
  23. package/templates/.claude/hooks/handoff-model-guard.sh +62 -14
  24. package/templates/.claude/hooks/handoff-resume.sh +20 -7
  25. package/templates/.claude/hooks/post-edit-verify.sh +17 -5
  26. package/templates/.claude/hooks/pre-edit-backup.sh +17 -5
  27. package/templates/.claude/hooks/project-important.sh +18 -1
  28. package/templates/.claude/hooks/protect-files.sh +18 -5
  29. package/templates/.claude/hooks/record-execution.sh +17 -5
  30. package/templates/.claude/hooks/sensitive-data-guard.sh +48 -9
  31. package/templates/.claude/hooks/skill-router.sh +124 -86
  32. package/templates/.claude/hooks/stale-spec-guard.sh +22 -6
  33. package/templates/.claude/hooks/task-watchdog.sh +22 -7
  34. package/templates/.claude/hooks/verification-guard.sh +17 -5
  35. package/templates/.claude/hooks/vision-router.sh +17 -5
  36. package/templates/.claude/settings.json +15 -10
  37. package/templates/.claude/ukit/index/provision-worktree.mjs +30 -2
  38. package/templates/.claude/ukit/runtime/execution-ledger.mjs +99 -1
  39. package/templates/.claude/ukit/runtime/hook-input.sh +25 -0
  40. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +86 -2
  41. package/templates/.claude/ukit/runtime/output-compression.mjs +87 -0
  42. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +201 -11
  43. package/templates/.omp/RULES.md +9 -1
  44. package/templates/.omp/agents/code-reviewer.md +12 -1
  45. package/templates/.omp/agents/feature-implementer.md +4 -2
  46. package/templates/.omp/agents/handoff-planner.md +36 -1
  47. package/templates/.omp/hooks/pre/ukit-bridge.js +110 -3
  48. package/templates/AGENTS.md +14 -0
  49. package/templates/CLAUDE.md +14 -0
  50. package/templates/docs/AI_HANDOFF/RULES.md +37 -4
  51. package/templates/docs/AI_HANDOFF/SPEC.md +98 -0
  52. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +4 -1
  53. package/templates/ukit/storage/config.json +48 -9
@@ -12,9 +12,18 @@
12
12
  * `--evaluate-stop` exactly once (the ledger owns its own counting semantics);
13
13
  * - it evaluates the watchdog policy in-process through task-watchdog.mjs's
14
14
  * exported `evaluateStopWatchdog` exactly once;
15
- * - it merges the two evaluator results with a deterministic owner order —
16
- * fail-closed completion failure > completion block > watchdog block > merged
17
- * advisory > silent release — and emits AT MOST ONE Stop JSON line;
15
+ * - it evaluates the handoff-cursor policy in-process: while
16
+ * `docs/AI_HANDOFF/RUN.md` carries `Phase:` other than `done`/`blocked`, a
17
+ * handoff-fullstack run is still in flight and the stop is bounced back with
18
+ * the cursor's `Next:` step — this is what keeps an overnight run moving
19
+ * after a recap instead of stalling idle. A stalled-cursor breaker releases
20
+ * the stop if the cursor has not advanced across `stopGateMaxStalledBlocks`
21
+ * consecutive blocked stops (same liveness shape as the ledger's
22
+ * noProgressCount breaker);
23
+ * - it merges the three evaluator results with a deterministic owner order —
24
+ * fail-closed completion failure > completion block > handoff-cursor block >
25
+ * watchdog block > merged advisory > silent release — and emits AT MOST ONE
26
+ * Stop JSON line;
18
27
  * - a lock-scoped once-per-stop guard makes a legacy double-registered Stop
19
28
  * (old settings.json still listing both hooks) inert: the second coordinator
20
29
  * invocation for the same session inside the dedupe window emits nothing and
@@ -41,6 +50,7 @@
41
50
  * normalizeEvaluatorStdout — ledger stdout → { kind, reason?, systemMessage? }.
42
51
  * coordinateStopDecisions — the pure ordered merge; owns ordering + provenance
43
52
  * only (evaluators stay independently testable).
53
+ * evaluateHandoffCursor — in-process RUN.md lane; advisory on any failure.
44
54
  * evaluateCompletionPolicy — spawns the ledger once, returns the normalized
45
55
  * completion evaluator result (throws on failure).
46
56
  * runStopCoordinator — event guard + dedupe + both evaluators + merge.
@@ -162,6 +172,20 @@ const LOCK_BUDGET_MS = 600;
162
172
 
163
173
  const WATCHDOG_PROVENANCE_PREFIX = '[ukit-stop-coordinator] task-watchdog also requested a block: ';
164
174
  const WATCHDOG_FAILURE_PREFIX = '[ukit-stop-coordinator] task-watchdog evaluator failed (advisory lane, reported verbatim): ';
175
+ const HANDOFF_PROVENANCE_PREFIX = '[ukit-stop-coordinator] handoff-cursor also requested a block: ';
176
+ const HANDOFF_FAILURE_PREFIX = '[ukit-stop-coordinator] handoff-cursor evaluator failed (advisory lane, reported verbatim): ';
177
+
178
+ // Handoff-cursor lane defaults. Both are overridable through
179
+ // `.ukit/storage/config.json` → `handoff.fullstack.*`; any read/parse error fails
180
+ // OPEN to these values — the lane is advisory and must never wedge a session.
181
+ export const HANDOFF_CURSOR_DEFAULTS = Object.freeze({
182
+ enabled: true,
183
+ // A cursor that has not advanced across this many consecutive blocked stops is
184
+ // a run that can no longer make progress (wedged wave, dead dependency). The
185
+ // gate releases instead of trapping the session in an unstoppable loop — same
186
+ // liveness shape as the ledger's noProgressCount breaker.
187
+ maxStalledBlocks: 12,
188
+ });
165
189
 
166
190
  // ─── Redaction ────────────────────────────────────────────────────────────
167
191
  /**
@@ -223,24 +247,33 @@ export function normalizeEvaluatorStdout(stdout) {
223
247
  * advisory-lane failures travel in the single systemMessage) — one decision, no
224
248
  * duplicate prompts.
225
249
  */
226
- export function coordinateStopDecisions({ completion = null, watchdog = null, failures = {} } = {}) {
250
+ export function coordinateStopDecisions({ completion = null, watchdog = null, handoff = null, failures = {} } = {}) {
227
251
  const completionFailed = typeof failures?.completion === 'string' && failures.completion.length > 0;
228
252
  const watchdogFailed = typeof failures?.watchdog === 'string' && failures.watchdog.length > 0;
253
+ const handoffFailed = typeof failures?.handoff === 'string' && failures.handoff.length > 0;
229
254
  const watchdogFailureNote = watchdogFailed
230
255
  ? `${WATCHDOG_FAILURE_PREFIX}${failures.watchdog}`
231
256
  : null;
257
+ const handoffFailureNote = handoffFailed
258
+ ? `${HANDOFF_FAILURE_PREFIX}${failures.handoff}`
259
+ : null;
232
260
  const watchdogAdvisory = watchdog?.kind === 'advisory' ? watchdog.systemMessage : null;
261
+ const handoffAdvisory = handoff?.kind === 'advisory' ? handoff.systemMessage : null;
233
262
  const policies = [];
234
263
 
235
264
  // 1. The fail-closed blocker itself failed → block, redacted, detail on stderr only.
236
265
  if (completionFailed) {
237
266
  policies.push('completion-gate');
238
267
  let reason = redactInfrastructureReason();
268
+ if (handoff?.kind === 'block') {
269
+ policies.push('handoff-cursor');
270
+ reason += `\n${HANDOFF_PROVENANCE_PREFIX}${handoff.reason}`;
271
+ }
239
272
  if (watchdog?.kind === 'block') {
240
273
  policies.push('task-watchdog');
241
274
  reason += `\n${WATCHDOG_PROVENANCE_PREFIX}${watchdog.reason}`;
242
275
  }
243
- const notes = [watchdogFailureNote, watchdogAdvisory].filter(Boolean);
276
+ const notes = [watchdogFailureNote, handoffFailureNote, watchdogAdvisory, handoffAdvisory].filter(Boolean);
244
277
  return {
245
278
  decision: 'block',
246
279
  reason,
@@ -254,11 +287,15 @@ export function coordinateStopDecisions({ completion = null, watchdog = null, fa
254
287
  if (completion?.kind === 'block') {
255
288
  policies.push('completion-gate');
256
289
  let reason = completion.reason;
290
+ if (handoff?.kind === 'block') {
291
+ policies.push('handoff-cursor');
292
+ reason += `\n${HANDOFF_PROVENANCE_PREFIX}${handoff.reason}`;
293
+ }
257
294
  if (watchdog?.kind === 'block') {
258
295
  policies.push('task-watchdog');
259
296
  reason += `\n${WATCHDOG_PROVENANCE_PREFIX}${watchdog.reason}`;
260
297
  }
261
- const notes = [watchdogFailureNote, watchdogAdvisory].filter(Boolean);
298
+ const notes = [watchdogFailureNote, handoffFailureNote, watchdogAdvisory, handoffAdvisory].filter(Boolean);
262
299
  return {
263
300
  decision: 'block',
264
301
  reason,
@@ -268,11 +305,30 @@ export function coordinateStopDecisions({ completion = null, watchdog = null, fa
268
305
  };
269
306
  }
270
307
 
271
- // 3. Watchdog block → watchdog owns this stop; completion advisory rides along.
308
+ // 3. Handoff-cursor block → an unfinished handoff-fullstack run owns this stop.
309
+ if (handoff?.kind === 'block') {
310
+ policies.push('handoff-cursor');
311
+ let reason = handoff.reason;
312
+ if (watchdog?.kind === 'block') {
313
+ policies.push('task-watchdog');
314
+ reason += `\n${WATCHDOG_PROVENANCE_PREFIX}${watchdog.reason}`;
315
+ }
316
+ const notes = [completion?.systemMessage, watchdogFailureNote, watchdogAdvisory].filter(Boolean);
317
+ return {
318
+ decision: 'block',
319
+ reason,
320
+ systemMessage: notes.length > 0 ? notes.join('\n') : undefined,
321
+ policyOwner: 'handoff-cursor',
322
+ policies,
323
+ };
324
+ }
325
+
326
+ // 4. Watchdog block → watchdog owns this stop; other advisories ride along.
272
327
  if (watchdog?.kind === 'block') {
273
328
  policies.push('task-watchdog');
274
329
  if (completion?.kind === 'advisory') policies.push('completion-gate');
275
- const notes = [completion?.systemMessage, watchdogFailureNote].filter(Boolean);
330
+ if (handoff?.kind === 'advisory') policies.push('handoff-cursor');
331
+ const notes = [completion?.systemMessage, handoffAdvisory, watchdogFailureNote, handoffFailureNote].filter(Boolean);
276
332
  return {
277
333
  decision: 'block',
278
334
  reason: watchdog.reason,
@@ -282,10 +338,11 @@ export function coordinateStopDecisions({ completion = null, watchdog = null, fa
282
338
  };
283
339
  }
284
340
 
285
- // 4. No block → merge advisories + verbatim failures into ONE systemMessage, or stay silent.
341
+ // 5. No block → merge advisories + verbatim failures into ONE systemMessage, or stay silent.
286
342
  if (completion?.kind === 'advisory') policies.push('completion-gate');
287
343
  if (watchdog?.kind === 'advisory') policies.push('task-watchdog');
288
- const notes = [completion?.systemMessage, watchdogAdvisory, watchdogFailureNote].filter(Boolean);
344
+ if (handoff?.kind === 'advisory') policies.push('handoff-cursor');
345
+ const notes = [completion?.systemMessage, watchdogAdvisory, handoffAdvisory, watchdogFailureNote, handoffFailureNote].filter(Boolean);
289
346
  return {
290
347
  decision: null,
291
348
  reason: undefined,
@@ -359,6 +416,131 @@ export async function evaluateCompletionPolicy({ projectRoot, rawInput, budgetMs
359
416
  return normalizeEvaluatorStdout(stdout);
360
417
  }
361
418
 
419
+ // ─── Handoff-cursor evaluator (in-process, advisory-on-failure) ───────────
420
+ /**
421
+ * Read `.ukit/storage/config.json` → `handoff.fullstack.stopGate*` merged over
422
+ * HANDOFF_CURSOR_DEFAULTS. Any failure returns the defaults — a broken config
423
+ * must never silence the lane outright NOR wedge it; defaults keep both sane.
424
+ */
425
+ async function loadHandoffGateConfig(projectRoot) {
426
+ try {
427
+ const parsed = JSON.parse(
428
+ await fs.readFile(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'),
429
+ );
430
+ const gate = parsed?.handoff?.fullstack;
431
+ return {
432
+ enabled: typeof gate?.stopGateEnabled === 'boolean' ? gate.stopGateEnabled : HANDOFF_CURSOR_DEFAULTS.enabled,
433
+ maxStalledBlocks: Number.isFinite(gate?.stopGateMaxStalledBlocks)
434
+ ? Math.max(1, gate.stopGateMaxStalledBlocks)
435
+ : HANDOFF_CURSOR_DEFAULTS.maxStalledBlocks,
436
+ };
437
+ } catch {
438
+ return { ...HANDOFF_CURSOR_DEFAULTS };
439
+ }
440
+ }
441
+
442
+ function parseRunCursor(text) {
443
+ const field = (name) => (String(text).match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1] || '').trim();
444
+ return {
445
+ command: field('Command'),
446
+ goal: field('Goal'),
447
+ phase: field('Phase'),
448
+ cursor: field('Cursor'),
449
+ next: field('Next'),
450
+ };
451
+ }
452
+
453
+ /**
454
+ * Mutate `.ukit/storage/cache/stop-coordinator/state.json`'s `handoff` slot:
455
+ * { signature, count }. Same signature as last time → count+1; a moved cursor
456
+ * resets the streak. Returns the committed streak count; a lock-timeout or any
457
+ * I/O failure returns null (fail-open — never decides the stop).
458
+ */
459
+ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
460
+ const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
461
+ try {
462
+ const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, async () => {
463
+ let current = {};
464
+ try {
465
+ current = JSON.parse(await fs.readFile(statePath, 'utf8')) || {};
466
+ } catch {}
467
+ const prev = current.handoff || {};
468
+ const count = prev.signature === signature ? (Number(prev.count) || 0) + 1 : 1;
469
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
470
+ await fs.writeFile(
471
+ statePath,
472
+ `${JSON.stringify({ ...current, handoff: { signature, count } }, null, 1)}\n`,
473
+ 'utf8',
474
+ );
475
+ return count;
476
+ });
477
+ return outcome.ok ? outcome.value : null;
478
+ } catch {
479
+ return null;
480
+ }
481
+ }
482
+
483
+ /**
484
+ * The handoff-cursor policy lane.
485
+ *
486
+ * BLOCKS the stop while `docs/AI_HANDOFF/RUN.md` reports an in-flight phase —
487
+ * the run's own `Next:` line is the continuation instruction, so a recap or a
488
+ * premature "done" reply gets bounced straight back into the pipeline. Releases
489
+ * when the cursor says `done` (or `blocked` — a genuinely stuck run is a
490
+ * legitimate final report), when RUN.md is absent/unreadable, or when the same
491
+ * un-advancing cursor has already bounced `maxStalledBlocks` stops in a row
492
+ * (liveness breaker — the gate must never be an unstoppable loop).
493
+ *
494
+ * Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
495
+ */
496
+ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS } = {}) {
497
+ const gate = await loadHandoffGateConfig(projectRoot);
498
+ if (!gate.enabled) return { kind: 'none' };
499
+
500
+ let text;
501
+ try {
502
+ text = await fs.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
503
+ } catch {
504
+ return { kind: 'none' };
505
+ }
506
+ const { goal, phase, cursor, next } = parseRunCursor(text);
507
+ if (!phase || /^done\b/i.test(phase) || /^blocked\b/i.test(phase)) {
508
+ return { kind: 'none' };
509
+ }
510
+
511
+ const signature = `${phase}|${cursor}|${next}`;
512
+ const streak = await bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs });
513
+ if (streak !== null && streak > gate.maxStalledBlocks) {
514
+ return {
515
+ kind: 'advisory',
516
+ systemMessage:
517
+ `[ukit-stop-coordinator] handoff-cursor released this stop: the run cursor ` +
518
+ `("${signature}") has not advanced across ${streak - 1} consecutive blocked stops ` +
519
+ `(cap ${gate.maxStalledBlocks}), so the gate treats the run as stalled rather than ` +
520
+ `looping forever. Inspect docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md; ` +
521
+ `the run can be resumed with /ukit:handoff-fullstack or cleared with /ukit:handoff-clear.`,
522
+ };
523
+ }
524
+
525
+ const lines = [
526
+ 'UKit handoff run in progress — this stop is refused. A handoff-fullstack cycle is still',
527
+ 'in flight; a recap, wave summary, or "waiting" reply is NOT completion. The only valid',
528
+ 'terminal outputs are "HANDOFF FULLSTACK COMPLETE" (all gates passed, cursor set to',
529
+ '`Phase: done`) or "HANDOFF FULLSTACK BLOCKED" (every remaining task externally blocked,',
530
+ 'cursor set to `Phase: blocked`).',
531
+ '',
532
+ ` Goal: ${goal || '(not recorded)'}`,
533
+ ` Phase: ${phase}`,
534
+ ` Cursor: ${cursor || '(not recorded)'}`,
535
+ ` Next: ${next || '(not recorded)'}`,
536
+ '',
537
+ 'CONTINUE IMMEDIATELY: read docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md, execute',
538
+ 'the `Next:` step exactly, rewrite the run cursor, and keep working until the completion',
539
+ 'gate in .claude/commands/ukit/handoff-fullstack.md passes.',
540
+ ];
541
+ return { kind: 'block', reason: lines.join('\n') };
542
+ }
543
+
362
544
  // ─── Once-per-stop guard ──────────────────────────────────────────────────
363
545
  /**
364
546
  * Lock-scoped dedupe: persist `{ lastStop: { key, ts } }` and report whether this
@@ -434,7 +616,15 @@ export async function runStopCoordinator({
434
616
  process.stderr.write(`[ukit-stop-coordinator] task-watchdog evaluator failed: ${failures.watchdog}\n`);
435
617
  }
436
618
 
437
- return { skip: null, merged: coordinateStopDecisions({ completion, watchdog, failures }) };
619
+ let handoff = null;
620
+ try {
621
+ handoff = await evaluateHandoffCursor({ projectRoot, now, lockBudgetMs });
622
+ } catch (error) {
623
+ failures.handoff = error?.message || String(error);
624
+ process.stderr.write(`[ukit-stop-coordinator] handoff-cursor evaluator failed: ${failures.handoff}\n`);
625
+ }
626
+
627
+ return { skip: null, merged: coordinateStopDecisions({ completion, watchdog, handoff, failures }) };
438
628
  }
439
629
 
440
630
  // ─── CLI ──────────────────────────────────────────────────────────────────
@@ -57,7 +57,15 @@ Prefer unique current-file anchors over line numbers or stale pasted blocks. Nev
57
57
  stale spec — re-read current source and confirm before applying. Preserve existing BOM and line
58
58
  endings.
59
59
 
60
- ## 8. Never end a turn silently
60
+ ## 8. Handoff runs never park mid-cycle
61
+
62
+ While `docs/AI_HANDOFF/RUN.md` `Phase:` is not `done`/`blocked`, a handoff-fullstack run is
63
+ still in flight: a recap or checkpoint is progress output, never a stopping point. Continue
64
+ the cursor's `Next:` step; only `HANDOFF FULLSTACK COMPLETE` or `HANDOFF FULLSTACK BLOCKED`
65
+ ends the run. `handoff-clear` must close the cursor (`Phase: done` or delete) or the gate
66
+ keeps the next session running too.
67
+
68
+ ## 9. Never end a turn silently
61
69
 
62
70
  Ending a turn with no output is a defect, not a pause. If you stop before the work is finished, the
63
71
  last thing you emit is one short line naming what is unfinished and what you need. Silence is never
@@ -126,17 +126,28 @@ Same model is the most common silent failure. Do not skip this check.
126
126
  ### Inputs you expect
127
127
 
128
128
  - Path to the spec/plan document (e.g. `docs/plans/*.md`). No diff, no task file, no executor report — review the document itself.
129
+ - When invoked from the handoff pipeline you get BOTH `docs/AI_HANDOFF/SPEC.md` and `docs/AI_HANDOFF/PLAN.md`. Review them as one unit: the spec is the contract, the plan is the decomposition. Verdicts still append to PLAN.md's `## Plan Review Log`.
129
130
 
130
131
  ### Review order
131
132
 
132
133
  | Category | What to look for |
133
134
  |---|---|
134
135
  | Completeness | TODO/TBD/placeholders, incomplete sections |
135
- | Consistency | internal contradictions, conflicting requirements |
136
+ | Consistency | internal contradictions, conflicting requirements; SPEC and PLAN contradicting each other |
136
137
  | Clarity | requirements ambiguous enough to cause a wrong build |
137
138
  | Scope | focused enough for one plan, not silently covering multiple subsystems |
138
139
  | YAGNI | unrequested features, over-engineering |
139
140
 
141
+ **Handoff spec quality gate** (only when reviewing `docs/AI_HANDOFF/SPEC.md`):
142
+
143
+ 1. Every functional requirement is testable — Given/When/Then or a command, never "should work".
144
+ 2. Every applicable fullstack layer is covered or explicitly `N/A` with a reason.
145
+ 3. Every FR traces forward to plan scope; nothing in the plan is unbacked by the spec.
146
+ 4. Dependencies between parts are explicit.
147
+ 5. Legacy/unfinished work discovered by the Phase 0 sweep is either planned or recorded out-of-scope.
148
+ 6. Rollback/migration impact is addressed when data or schema changes.
149
+ 7. No vague instruction survives — "improve UI", "faster", "better UX" without defined behavior fails Clarity.
150
+
140
151
  Only flag issues that would cause real problems during implementation planning. Approve unless there are serious gaps that would lead to a flawed plan.
141
152
 
142
153
  ### Output
@@ -18,8 +18,10 @@ reasoning to the parent agent so it can decide whether to re-route.
18
18
  **In Handoff mode you are running unattended — ask nothing.** You were spawned by an
19
19
  orchestrator driving a pipeline; there is no human in your conversation to answer, and a
20
20
  question there is silently dropped while the run stalls. Resolve ambiguity in this order:
21
- the task file → `PLAN.md` → the surrounding code's existing patterns → the choice you would
22
- recommend. Record what you chose and why in the task's `## Discussion` thread. Only a blocker
21
+ the task file → the `Spec references` sections of `SPEC.md` → `PLAN.md` → the surrounding
22
+ code's existing patterns → the choice you would recommend. The spec is the contract; if the
23
+ task file and spec disagree, implement the spec and note it in the task's `## Discussion`
24
+ thread. Record what you chose and why in that thread. Only a blocker
23
25
  outside the repo (missing credential, unreachable service) justifies reporting `FAIL` early —
24
26
  and even then, report it, don't ask about it.
25
27
 
@@ -54,6 +54,35 @@ Before writing any path or command into `PLAN.md` or a task file, verify it:
54
54
  If something cannot be verified, say so in the task's `## Discussion` rather than guessing.
55
55
  A stated unknown costs the executor one read; a wrong path costs it a round.
56
56
 
57
+ ## Phase 0 — Legacy sweep (before writing anything)
58
+
59
+ The plan owns ALL unfinished work, not only the new request. Scan and fold in:
60
+
61
+ - `INDEX.md` rows that are not `done`/`cancelled_superseded`.
62
+ - Task files in stale `in_progress` / `blocked` / `changes_requested` /
63
+ `needs_executor_report` / `needs_breakdown` from dead sessions.
64
+ - `docs/AI_HANDOFF/HISTORY.md` + `archive/` — cycles closed with leftovers.
65
+ - `docs/TASKS.md` — `Ready for AI` items are newly-assigned work.
66
+ - `git status` — uncommitted work-in-progress (finish or checkpoint, never drop silently).
67
+
68
+ Every discovered item becomes either a task row in the new plan or an explicitly recorded
69
+ out-of-scope line in PLAN.md §2. Silent omission is a plan defect.
70
+
71
+ **Recovery:** a stuck task record that cannot be cleanly resumed (orphaned worktree,
72
+ contradicting reports, invalid state) is marked `cancelled_superseded` and replaced by
73
+ `TASK-xxx-R1` (`-R2`, …) carrying the same spec references, acceptance criteria and
74
+ verification — link both files' `## Discussion` threads.
75
+
76
+ ## Phase 0.5 — Write SPEC.md
77
+
78
+ Write `docs/AI_HANDOFF/SPEC.md` from the template at `templates/docs/AI_HANDOFF/SPEC.md`
79
+ (15 sections). The spec is the contract executors implement against — concrete enough that
80
+ nothing is guessed: exact paths, module and API names, schemas, validation rules,
81
+ permissions, empty/error states, migration behavior, test expectations. Every section is
82
+ filled or marked `N/A — <reason>`; every open question is resolved to a chosen default
83
+ recorded in §14. A vague line ("improve UI", "make it faster" with no number) is a spec
84
+ defect — fix it before writing tasks.
85
+
57
86
  ## Phase 1 — Write PLAN.md
58
87
 
59
88
  Write all 7 sections to `docs/AI_HANDOFF/PLAN.md`:
@@ -100,6 +129,7 @@ Use `_TEMPLATE.md` structure (from pre-read context or file).
100
129
 
101
130
  | Field | Rule |
102
131
  |-------|------|
132
+ | Spec references | SPEC.md section/FR IDs this task implements — every task traces to the spec |
103
133
  | Target Files | Exact paths — no two tasks in same wave share a file |
104
134
  | Dependencies | `TASK-xxx` or `none` — wave order is inferred from this |
105
135
  | Test Cases | Type \| Test Name \| Expected — ≥1 happy + ≥2 edge cases of different kinds |
@@ -159,6 +189,7 @@ most chains are ordering preferences that a wide wave 1 would satisfy just as we
159
189
  ```
160
190
  Cycle: <ID> Date: <YYYY-MM-DD> Base: <current HEAD branch>
161
191
  Goal: <1 sentence>
192
+ Spec: docs/AI_HANDOFF/SPEC.md
162
193
  Tasks: <N> total
163
194
  Status: planning_done — ready for executor
164
195
  ```
@@ -178,6 +209,8 @@ catches it:
178
209
  2. Does every task trace back to something in §1/§6? A task nothing asks for is scope creep — cut it.
179
210
  3. Do the tasks together actually deliver §1's success definition, or only the easy part of it? State the gap if there is one.
180
211
  4. Is the *unhappy* path planned — errors, empty input, permissions, migration of existing data — or only the feature?
212
+ 4b. Does every task carry `Spec references` into SPEC.md, and does every SPEC.md FR trace to at least one task? A spec section no task implements is a silent hole.
213
+ 4c. Did the Phase 0 sweep leave anything unplanned — stale tasks, legacy leftovers, Ready-for-AI items, uncommitted WIP — without a §2 out-of-scope line?
181
214
 
182
215
  **Correctness — is anything wrong?**
183
216
  5. Every `Target Files` path verified per Grounding? Any `(new)` file marked as such?
@@ -194,7 +227,7 @@ catches it:
194
227
  Append the result to `PLAN.md`:
195
228
  ```
196
229
  ## Planner Self-Audit
197
- Checklist: 12/12 pass
230
+ Checklist: 14/14 pass
198
231
  Fixed during audit: <what you changed, or "nothing">
199
232
  Known gaps: <what you deliberately left out and why, or "none">
200
233
  ```
@@ -208,6 +241,8 @@ Keep the returned message under 25 lines — the caller may be an orchestrator w
208
241
  budget is the constraint on the whole run. Detail belongs in `PLAN.md`, not in the reply.
209
242
 
210
243
  - Task count + IDs
244
+ - Spec path + one-line coverage statement (`SPEC.md §5 FR-001→TASK-003`, …)
245
+ - Recovery/superseded pairs (`TASK-007 → TASK-007-R1`) | none
211
246
  - Dependency graph (text form: TASK-001 → TASK-003, TASK-002 independent)
212
247
  - Wave plan: `wave 1: N tasks | wave 2: M tasks` — flag it if the graph is mostly a chain
213
248
  - Self-audit result + any `Known gaps`
@@ -47,6 +47,7 @@ export const HOOK_EVENT_MAP = {
47
47
  'sensitive-data-guard.sh',
48
48
  'handoff-model-guard.sh',
49
49
  'context-hardcap-gate.sh',
50
+ 'verification-guard.sh',
50
51
  ],
51
52
  },
52
53
  tool_result: {
@@ -102,6 +103,7 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
102
103
 
103
104
  export const ADVISORY_SCRIPTS = new Set([
104
105
  'skill-router.sh',
106
+ 'verification-guard.sh',
105
107
  'record-execution.sh',
106
108
  'auto-allow-bash.sh',
107
109
  'pre-edit-backup.sh',
@@ -331,17 +333,122 @@ export { translateExecResult };
331
333
  // 30s cap that silently disagreed with the runner whenever the env knobs were set.
332
334
  const chainExecTimeoutMs = resolveChainExecTimeoutMs;
333
335
 
334
- function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic) {
336
+ // BUG-C21-09 (FR-006): hook-errors/ used to append one JSONL row per chain
337
+ // transport error with no byte cap, no rotation, and no sweep — files grew per
338
+ // session forever. Per-file cap mirrors hook-telemetry's <=512KB rotate; the
339
+ // directory sweep is bounded (<= maxEntries stats, <= maxRemovals unlinks) and
340
+ // sampled (~1/16 writes, UKIT_HOOK_ERRORS_SWEEP_PROBABILITY to force/disable).
341
+ const HOOK_ERROR_MAX_BYTES = 512 * 1024;
342
+ const HOOK_ERROR_SWEEP_PROBABILITY_DEFAULT = 1 / 16;
343
+ const HOOK_ERROR_SWEEP_MAX_ENTRIES = 128;
344
+ const HOOK_ERROR_SWEEP_MAX_REMOVALS = 64;
345
+ const HOOK_ERROR_MAX_FILES = 200;
346
+ const HOOK_ERROR_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
347
+
348
+ function hookErrorsDirFor(projectRoot) {
349
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-errors');
350
+ }
351
+
352
+ // Bounded work on THIS session's file only — identical keep-newest-half shape
353
+ // as hook-telemetry's rotateIfNeeded.
354
+ function rotateHookErrorFileIfNeeded(filePath, incomingBytes, maxBytes) {
355
+ let size = 0;
356
+ try {
357
+ size = fs.statSync(filePath).size;
358
+ } catch {
359
+ return; // first row for this session
360
+ }
361
+ if (size + incomingBytes <= maxBytes) return;
362
+ try {
363
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
364
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
365
+ const keepBudget = Math.floor(maxBytes / 2);
366
+ const keep = [];
367
+ let kept = 0;
368
+ for (let i = lines.length - 1; i >= 0; i--) {
369
+ const lineBytes = Buffer.byteLength(lines[i], 'utf8') + 1;
370
+ if (kept + lineBytes > keepBudget) break;
371
+ keep.unshift(lines[i]);
372
+ kept += lineBytes;
373
+ }
374
+ fs.writeFileSync(filePath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
375
+ } catch {
376
+ // Rotation failed; drop this row rather than grow past the cap.
377
+ }
378
+ }
379
+
380
+ // sweepHookErrorsDir(dir, {now, maxAgeMs, maxFiles, maxEntries, maxRemovals}) ->
381
+ // { scanned, removed } — bounded: never scans or removes more than the caps,
382
+ // so a pre-existing oversized dir is amortized down across sampled sweeps.
383
+ function sweepHookErrorsDir(dir, {
384
+ now = Date.now,
385
+ maxAgeMs = HOOK_ERROR_MAX_AGE_MS,
386
+ maxFiles = HOOK_ERROR_MAX_FILES,
387
+ maxEntries = HOOK_ERROR_SWEEP_MAX_ENTRIES,
388
+ maxRemovals = HOOK_ERROR_SWEEP_MAX_REMOVALS,
389
+ } = {}) {
390
+ let names;
391
+ try {
392
+ names = fs.readdirSync(dir);
393
+ } catch {
394
+ return { scanned: 0, removed: 0 };
395
+ }
396
+ const cutoff = now() - maxAgeMs;
397
+ const entries = [];
398
+ let scanned = 0;
399
+ for (const name of names) {
400
+ if (scanned >= maxEntries) break;
401
+ scanned += 1;
402
+ if (!name.endsWith('.jsonl')) continue;
403
+ try {
404
+ const stats = fs.statSync(path.join(dir, name));
405
+ if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
406
+ } catch { /* raced away — fine */ }
407
+ }
408
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
409
+ // Overflow counts eligible FILES only — foreign entries (non-matching names,
410
+ // dirs) inflate `names.length` and would evict real entries while the dir is
411
+ // actually under the cap.
412
+ const overflow = Math.max(0, entries.length - maxFiles);
413
+ let removed = 0;
414
+ for (const entry of entries) {
415
+ if (removed >= maxRemovals) break;
416
+ if (removed < overflow || entry.mtimeMs < cutoff) {
417
+ try {
418
+ fs.rmSync(path.join(dir, entry.name), { force: true });
419
+ removed += 1;
420
+ } catch { /* raced away — fine */ }
421
+ }
422
+ }
423
+ return { scanned, removed };
424
+ }
425
+
426
+ function hookErrorsSweepProbabilityFromEnv() {
427
+ const raw = Number(process.env.UKIT_HOOK_ERRORS_SWEEP_PROBABILITY);
428
+ if (!Number.isFinite(raw)) return HOOK_ERROR_SWEEP_PROBABILITY_DEFAULT;
429
+ return Math.min(1, Math.max(0, raw));
430
+ }
431
+
432
+ function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic, { maxBytes = HOOK_ERROR_MAX_BYTES } = {}) {
335
433
  try {
336
- const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-errors');
434
+ const dir = hookErrorsDirFor(projectRoot);
337
435
  fs.mkdirSync(dir, { recursive: true });
338
436
  const safeSession = String(sessionId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
339
- fs.appendFileSync(path.join(dir, `${safeSession}.jsonl`), `${JSON.stringify(diagnostic)}\n`, 'utf8');
437
+ const filePath = path.join(dir, `${safeSession}.jsonl`);
438
+ const line = `${JSON.stringify(diagnostic)}\n`;
439
+ rotateHookErrorFileIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), maxBytes);
440
+ fs.appendFileSync(filePath, line, 'utf8');
441
+ // Sampled bounded dir sweep — amortizes down any pre-existing oversized dir.
442
+ if (Math.random() < hookErrorsSweepProbabilityFromEnv()) {
443
+ sweepHookErrorsDir(dir);
444
+ }
340
445
  } catch {
341
446
  // Diagnostics are advisory and must never block or throw.
342
447
  }
343
448
  }
344
449
 
450
+ export { recordHookErrorDiagnostic, sweepHookErrorsDir };
451
+
345
452
  // Cached across the omp process lifetime: resolution below spawns a probe process, and every
346
453
  // tool call would otherwise pay that cost again.
347
454
  let cachedNodeExecutable = null;
@@ -288,4 +288,18 @@ DuraOne skill chỉ active khi pack `duraone` được cài hoặc `.claude/skil
288
288
  - No unrelated changes
289
289
  - Verification executed and reported
290
290
  - Docs updated when source truth changed
291
+
292
+
293
+ ## Handoff Fullstack Rules
294
+
295
+ - `docs/AI_HANDOFF/RUN.md` là run cursor có thẩm quyền; `Phase:` ≠ `done`/`blocked` nghĩa là run còn sống — Stop gate sẽ từ chối stop và trả về `Next:` step.
296
+ - Recap/checkpoint không bao giờ là completion — chỉ `HANDOFF FULLSTACK COMPLETE` (sau khi `Phase: done`) hoặc `HANDOFF FULLSTACK BLOCKED` (sau khi `Phase: blocked`) mới kết thúc run.
297
+ - Resume tự động mọi task chưa xong: current, legacy, pending, interrupted, recovery (`-R<n>` thay `cancelled_superseded`), và task mới được giao.
298
+ - Handoff-create phải viết `docs/AI_HANDOFF/SPEC.md` chi tiết trước khi tạo task; task nào cũng mang `Spec references`.
299
+ - Kết thúc cycle: docs sync → archive `docs/AI_HANDOFF/archive/cycle-NN/` → `Phase: done` → Final Report có marker.
300
+ - `handoff-clear` bắt buộc đóng RUN.md (`Phase: done` hoặc xóa) — cursor sống sẽ giữ Stop gate chặn session sau.
301
+
302
+ ## Compact Instructions
303
+
304
+ Khi compact giữa một handoff run: giữ lại goal, RUN.md path + phase hiện tại, task inventory, task đang làm, `Next:` step, blockers, verification evidence, commits, worktree/copy-back state, và quy tắc "compact không phải completion". Sau compact: đọc lại RUN.md + INDEX.md rồi chạy tiếp `Next:` ngay.
291
305
  {{codegraphSection}}
@@ -271,4 +271,18 @@ DuraOne skill chỉ active khi pack `duraone` được cài hoặc `.claude/skil
271
271
  - No unrelated changes
272
272
  - Verification executed and reported
273
273
  - Docs updated when source truth changed
274
+
275
+
276
+ ## Handoff Fullstack Rules
277
+
278
+ - `docs/AI_HANDOFF/RUN.md` là run cursor có thẩm quyền; `Phase:` ≠ `done`/`blocked` nghĩa là run còn sống — Stop gate sẽ từ chối stop và trả về `Next:` step.
279
+ - Recap/checkpoint không bao giờ là completion — chỉ `HANDOFF FULLSTACK COMPLETE` (sau khi `Phase: done`) hoặc `HANDOFF FULLSTACK BLOCKED` (sau khi `Phase: blocked`) mới kết thúc run.
280
+ - Resume tự động mọi task chưa xong: current, legacy, pending, interrupted, recovery (`-R<n>` thay `cancelled_superseded`), và task mới được giao.
281
+ - Handoff-create phải viết `docs/AI_HANDOFF/SPEC.md` chi tiết trước khi tạo task; task nào cũng mang `Spec references`.
282
+ - Kết thúc cycle: docs sync → archive `docs/AI_HANDOFF/archive/cycle-NN/` → `Phase: done` → Final Report có marker.
283
+ - `handoff-clear` bắt buộc đóng RUN.md (`Phase: done` hoặc xóa) — cursor sống sẽ giữ Stop gate chặn session sau.
284
+
285
+ ## Compact Instructions
286
+
287
+ Khi compact giữa một handoff run: giữ lại goal, RUN.md path + phase hiện tại, task inventory, task đang làm, `Next:` step, blockers, verification evidence, commits, worktree/copy-back state, và quy tắc "compact không phải completion". Sau compact: đọc lại RUN.md + INDEX.md rồi chạy tiếp `Next:` ngay.
274
288
  {{codegraphSection}}