@ngockhoale/ukit 3.4.29 → 3.4.30

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 (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/manifests/documentation.yaml +88 -0
  3. package/package.json +1 -1
  4. package/src/core/permissionPolicy.js +1 -1
  5. package/src/index/buildIndex.js +244 -4
  6. package/src/index/impactContext.js +91 -11
  7. package/src/index/languageTools.js +126 -0
  8. package/src/index/paths.js +5 -1
  9. package/src/index/playbookRegistry.js +108 -9
  10. package/src/index/queryIndex.js +415 -1
  11. package/src/index/routeCatalog.js +1 -1
  12. package/src/index/taskRouting.js +25 -3
  13. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  14. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  15. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  16. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  17. package/template_project/.claude/hooks/record-execution.mjs +23 -0
  18. package/template_project/.claude/hooks/skill-router.sh +105 -7
  19. package/template_project/.claude/settings.json +21 -1
  20. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  21. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  22. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  23. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  24. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  25. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  26. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  27. package/template_project/.claude/ukit/index/route-task.mjs +208 -31
  28. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +453 -24
  29. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  30. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  31. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  32. package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
  33. package/template_user/playbooks/feature-implementation.md +9 -0
  34. package/template_user/playbooks/migration.md +4 -0
@@ -307,9 +307,27 @@ export async function readRouteState(projectRoot, payload = {}) {
307
307
  const state = await readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
308
308
  const sessionId = explicitSessionId(payload);
309
309
  const stateSessionId = explicitSessionId(state || {});
310
- if (!state || !sessionId) return state;
311
- if (!stateSessionId) return null;
312
- return stateSessionId === sessionId ? state : null;
310
+ if (!state || !sessionId) {
311
+ // The shared file is single-slot: with no shared file there is still a per-session record.
312
+ return sessionId ? readSessionRouteState(projectRoot, sessionId) : state;
313
+ }
314
+ if (stateSessionId === sessionId) return state;
315
+ // Shared file missing an owner or owned by another session (a concurrent session routed
316
+ // after this one): recover this session's own record instead of declaring the route lost.
317
+ return readSessionRouteState(projectRoot, sessionId);
318
+ }
319
+
320
+ // Per-session route-state sidecar (written by skill-router.sh and route-task.mjs next to the
321
+ // shared single-slot file). Owner is re-verified from the embedded sessionId, so a misfiled or
322
+ // tampered record can never be adopted by another session.
323
+ export async function readSessionRouteState(projectRoot, sessionId) {
324
+ const id = String(sessionId || '').trim();
325
+ if (!id) return null;
326
+ const state = await readJson(
327
+ path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state', `${safeSegment(id)}.json`),
328
+ null,
329
+ );
330
+ return state && explicitSessionId(state) === id ? state : null;
313
331
  }
314
332
 
315
333
  export async function readExecutionLedger(projectRoot, payload = {}) {
@@ -503,15 +521,55 @@ function extractExitCode(payload = {}) {
503
521
 
504
522
  // The trailing boundary accepts shell terminators (`;`, `&`, `|`, `)`) so
505
523
  // `yarn release:verify; echo "exit=$?"` — the real-exit-code idiom — still mints a receipt.
506
- function isVerificationCommand(command) {
507
- const text = String(command || '').trim();
508
- if (!text) return false;
524
+ // Blank out quoted literals so `echo '(jest passed)'` / `git commit -m "run yarn test"` do not
525
+ // look like a runner invocation. A `bash|sh|zsh -c '<script>'` wrapper really executes its
526
+ // quoted script, so that script is tested on its own (bounded recursion).
527
+ function maskQuotedLiterals(text) {
528
+ let out = '';
529
+ let quote = null;
530
+ let escaped = false;
531
+ for (const char of text) {
532
+ if (escaped) { escaped = false; if (!quote) out += char; continue; }
533
+ if (char === '\\' && quote !== "'") { escaped = true; if (!quote) out += char; continue; }
534
+ if (quote) { if (char === quote) { quote = null; out += char; } continue; }
535
+ if (char === "'" || char === '"') { quote = char; out += char; continue; }
536
+ out += char;
537
+ }
538
+ return out;
539
+ }
540
+
541
+ function insideQuote(text, index) {
542
+ let quote = null;
543
+ let escaped = false;
544
+ for (let i = 0; i < index; i += 1) {
545
+ const char = text[i];
546
+ if (escaped) { escaped = false; continue; }
547
+ if (char === '\\' && quote !== "'") { escaped = true; continue; }
548
+ if (quote) { if (char === quote) quote = null; continue; }
549
+ if (char === "'" || char === '"') quote = char;
550
+ }
551
+ return quote !== null;
552
+ }
553
+
554
+ const SHELL_C_WRAPPER = /(?:^|[\s;&|({])(?:ba|z|da|k)?sh\s+(?:-o\s+\w+\s+|-[a-z]+\s+)*-[a-z]*c\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)")/gi;
555
+
556
+ function isVerificationCommand(command, depth = 0) {
557
+ const raw = String(command || '').trim();
558
+ if (!raw) return false;
559
+ if (depth < 2) {
560
+ for (const match of raw.matchAll(SHELL_C_WRAPPER)) {
561
+ // A wrapper that is itself inside a quoted literal (`git commit -m "sh -c 'yarn test'"`) ran nothing.
562
+ if (insideQuote(raw, match.index)) continue;
563
+ if (isVerificationCommand(match[1] ?? match[2] ?? '', depth + 1)) return true;
564
+ }
565
+ }
566
+ const text = maskQuotedLiterals(raw);
509
567
  return [
510
- /(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$|[;&|)])/i,
511
- /(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|lint|typecheck|check|build)(?:\s|$|[;&|)])/i,
512
- /(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+release:verify(?:\s|$|[;&|)])/i,
513
- /(?:^|\s|\.bin\/)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$|[;&|)])/i,
514
- /(?:^|\s)node\s+--check(?:\s|$|[;&|)])/i,
568
+ /(?:^|[\s({]|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$|[;&|)])/i,
569
+ /(?:^|[\s({])(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test(?::[\w:.-]+)?|lint|typecheck|check|build)(?:\s|$|[;&|)])/i,
570
+ /(?:^|[\s({])(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+release:verify(?:\s|$|[;&|)])/i,
571
+ /(?:^|[\s({]|\.bin\/)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$|[;&|)])/i,
572
+ /(?:^|[\s({])node\s+--check(?:\s|$|[;&|)])/i,
515
573
  ].some((pattern) => pattern.test(text));
516
574
  }
517
575
 
@@ -785,7 +843,21 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
785
843
  // (a hook or subagent can attest its own kind/verifier); otherwise the kind is
786
844
  // derived from the command shape — test runners prove `test-verified`, a run that
787
845
  // only type-checks/lints is honestly `check-only`.
788
- const TEST_RUNNER_COMMAND = /(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
846
+ const TEST_RUNNER_COMMAND = /(?:^|[\s({]|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
847
+ // Package-script gates that actually execute tests: `test`, `test:<suite>` and the
848
+ // project's `release:verify` gate. Static-check / fixture-mutating variants
849
+ // (`test:types`, `test:lint`, `test:update-snapshots`, `test:watch`, ...) stay check-only.
850
+ const TEST_SCRIPT_COMMAND = /(?:^|[\s;&|(])(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(test(?::[\w:.-]+)?|release:verify)(?=$|[\s;&|)])/gi;
851
+ const NON_TEST_SCRIPT_SUFFIX = /(?:^|:)(?:types?|typecheck|tsc|lint|format|fmt|snapshots?|update[\w-]*|watch|coverage-?report|clean|setup|fixtures?)(?::|$)/i;
852
+
853
+ function isTestGateCommand(command) {
854
+ const text = String(command || '');
855
+ if (TEST_RUNNER_COMMAND.test(text)) return true;
856
+ for (const m of text.matchAll(TEST_SCRIPT_COMMAND)) {
857
+ if (!NON_TEST_SCRIPT_SUFFIX.test(m[1])) return true;
858
+ }
859
+ return false;
860
+ }
789
861
 
790
862
  function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = projectRoot) {
791
863
  const supplied = payload?.verdict && typeof payload.verdict === 'object' ? payload.verdict : {};
@@ -801,7 +873,7 @@ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = pr
801
873
  let kind = supplied.kind;
802
874
  if (!VERDICT_KINDS.has(kind)) {
803
875
  if (receipt.success !== true) kind = 'failed';
804
- else kind = TEST_RUNNER_COMMAND.test(receipt.command || '') ? 'test-verified' : 'check-only';
876
+ else kind = isTestGateCommand(receipt.command) ? 'test-verified' : 'check-only';
805
877
  }
806
878
  return {
807
879
  kind,
@@ -1097,6 +1169,9 @@ function carriedEvidenceLedger(fresh, current) {
1097
1169
  // too — dropping a blocker on re-key would resume the exact loop it recorded.
1098
1170
  failedVerificationStreak: current.failedVerificationStreak || null,
1099
1171
  playbookId: fresh.playbookId || current.playbookId || null,
1172
+ playbookReason: fresh.playbookReason || current.playbookReason || null,
1173
+ taskType: fresh.taskType || current.taskType || null,
1174
+ playbookTrace: current.playbookTrace || fresh.playbookTrace || null,
1100
1175
  // The bug-fix floor bank is monotone request-scoped evidence — a same-prompt
1101
1176
  // re-key must keep it or the gate would re-demand repro receipts mid-request.
1102
1177
  bugFixFloor: current.bugFixFloor || fresh.bugFixFloor || null,
@@ -1149,6 +1224,12 @@ function freshLedger(payload, routeState, harness) {
1149
1224
  // TASK-008 (BL-010): latest-per-class playbook-finding statuses, banked
1150
1225
  // outside the evictable receipt window.
1151
1226
  playbookFindings: null,
1227
+ // Playbook trace bookkeeping: the routed selection reason, the route's
1228
+ // proportionality signal, and the derived bounded step trace (persisted at
1229
+ // receipt-apply time). null until a playbook is routed.
1230
+ playbookReason: null,
1231
+ taskType: null,
1232
+ playbookTrace: null,
1152
1233
  sourceSucceeded: false,
1153
1234
  sourceFiles: [],
1154
1235
  writeAttempted: false,
@@ -1573,6 +1654,207 @@ async function drainJournal(target, { ledger, payload, signal, deadlineMs }) {
1573
1654
  return { ledger: next, applied, quarantined: malformed.length, commit };
1574
1655
  }
1575
1656
 
1657
+ // --- Playbook execution trace (observable progress, no LLM) ---------------------
1658
+ // A playbook is a text cue; the only durable signal of "was it followed" is the
1659
+ // receipt stream the hooks already record. This section folds that stream into
1660
+ // a bounded, deterministic `playbookTrace` record:
1661
+ // loaded — a Read receipt hit playbooks/<id>.md
1662
+ // stepsDone — receipt-derived evidence per the playbook's step map
1663
+ // stepsSkipped — non-essential steps on a simple/trivial route, justified
1664
+ // outputs — bounded list of written targets
1665
+ // verification — 'pending' | 'passed' | 'failed' | 'n/a'
1666
+ // No LLM call, no new store: everything derives from ledger.receipts plus the
1667
+ // route signals stamped beside them (playbookReason, taskType). The record is
1668
+ // persisted on the ledger at receipt-apply time so `ukit status` and the stop
1669
+ // message read it without re-deriving an evictable receipt window. Session and
1670
+ // request scoping are inherited from the ledger itself: a ledger only ever
1671
+ // holds its own session's receipts, and evaluateCompletion derives from the
1672
+ // same-request effective ledger, so another session's or patch's evidence can
1673
+ // never satisfy a step.
1674
+
1675
+ const PLAYBOOK_TRACE_EVIDENCE_MAX = 200;
1676
+ const MAX_PLAYBOOK_OUTPUTS = 8;
1677
+ const PROPORTIONATE_JUSTIFICATION = 'proportionate: simple task';
1678
+ const PROPORTIONATE_TASK_TYPES = new Set(['simple', 'trivial']);
1679
+
1680
+ // Per-playbook step maps. `derive` names the receipt shape that evidences the
1681
+ // step: 'pre-fail' (a failed verification before the first write), 'pre-source'
1682
+ // (a Read/Grep/Glob of a non-playbook file before the first write), 'write'
1683
+ // (the first write receipt), 'post-verify' (a passing verification receipt
1684
+ // after the first write). `essential: false` marks the pre-work a proportionate
1685
+ // (simple/trivial) route may skip — the fix/implement and verify core never
1686
+ // auto-skips. Playbooks outside this map keep the trace bookkeeping (loaded,
1687
+ // outputs, reason) but invent no requirements.
1688
+ const PLAYBOOK_STEP_MAPS = {
1689
+ 'bug-fix': [
1690
+ { id: 'reproduce', derive: 'pre-fail', essential: true },
1691
+ { id: 'locate', derive: 'pre-source', essential: false },
1692
+ { id: 'fix', derive: 'write', essential: true },
1693
+ { id: 'verify', derive: 'post-verify', essential: true },
1694
+ ],
1695
+ 'feature-implementation': [
1696
+ { id: 'analog-search', derive: 'pre-source', essential: false },
1697
+ { id: 'implement', derive: 'write', essential: true },
1698
+ { id: 'verify', derive: 'post-verify', essential: true },
1699
+ ],
1700
+ migration: [
1701
+ { id: 'schema-inspect', derive: 'pre-source', essential: false },
1702
+ { id: 'write', derive: 'write', essential: true },
1703
+ { id: 'verify', derive: 'post-verify', essential: true },
1704
+ ],
1705
+ };
1706
+
1707
+ function playbookStepsFor(playbookId) {
1708
+ const steps = playbookId ? PLAYBOOK_STEP_MAPS[String(playbookId)] : null;
1709
+ return Array.isArray(steps) ? steps : [];
1710
+ }
1711
+
1712
+ function boundedTraceText(value) {
1713
+ const text = String(value ?? '').trim();
1714
+ return text ? text.slice(0, PLAYBOOK_TRACE_EVIDENCE_MAX) : null;
1715
+ }
1716
+
1717
+ function isPlaybookRead(file, playbookId) {
1718
+ if (!file || !playbookId) return false;
1719
+ const normalized = String(file).replace(/\\/g, '/');
1720
+ return normalized === `playbooks/${playbookId}.md`
1721
+ || normalized.endsWith(`/playbooks/${playbookId}.md`);
1722
+ }
1723
+
1724
+ /**
1725
+ * Derive the playbook trace for `playbookId` from the ledger's own evidence.
1726
+ * Pure and deterministic: identical ledger in, identical trace out. A null
1727
+ * playbookId yields the not-applicable trace (no requirements) — the greeting /
1728
+ * informational shape.
1729
+ */
1730
+ export function buildPlaybookTrace(ledger = {}, playbookId = null) {
1731
+ const id = playbookId || null;
1732
+ if (!id) {
1733
+ return {
1734
+ playbookId: null,
1735
+ reason: 'not-applicable',
1736
+ selectedAt: null,
1737
+ requiredSteps: [],
1738
+ stepsDone: [],
1739
+ stepsSkipped: [],
1740
+ outputs: [],
1741
+ verification: 'n/a',
1742
+ loaded: false,
1743
+ };
1744
+ }
1745
+ const receipts = Array.isArray(ledger.receipts) ? ledger.receipts : [];
1746
+ const steps = playbookStepsFor(id);
1747
+ const firstWriteIndex = receipts.findIndex((receipt) => receipt?.kind === 'write');
1748
+ const stepEvidence = new Map();
1749
+ let loaded = false;
1750
+ let anyVerificationFailed = false;
1751
+ const outputs = [];
1752
+
1753
+ receipts.forEach((receipt, index) => {
1754
+ if (!receipt || typeof receipt !== 'object') return;
1755
+ if (receipt.kind === 'source') {
1756
+ const playbookRead = isPlaybookRead(receipt.file, id);
1757
+ if (playbookRead) loaded = true;
1758
+ const beforeWrite = firstWriteIndex === -1 || index < firstWriteIndex;
1759
+ // The playbook file itself is process, not target code — it loads the
1760
+ // trace but never satisfies a locate/analog-search step.
1761
+ if (beforeWrite && !playbookRead && !stepEvidence.has('pre-source')) {
1762
+ stepEvidence.set('pre-source', { at: receipt.ts, evidence: boundedTraceText(receipt.file) });
1763
+ }
1764
+ return;
1765
+ }
1766
+ if (receipt.kind === 'write') {
1767
+ const file = boundedTraceText(receipt.file);
1768
+ if (file && !outputs.includes(file) && outputs.length < MAX_PLAYBOOK_OUTPUTS) {
1769
+ outputs.push(file);
1770
+ }
1771
+ if (!stepEvidence.has('write')) {
1772
+ stepEvidence.set('write', { at: receipt.ts, evidence: boundedTraceText(receipt.file) });
1773
+ }
1774
+ return;
1775
+ }
1776
+ if (receipt.kind === 'verification') {
1777
+ const evidence = { at: receipt.ts, evidence: boundedTraceText(receipt.command) };
1778
+ if (receipt.success !== true) {
1779
+ anyVerificationFailed = true;
1780
+ if ((firstWriteIndex === -1 || index < firstWriteIndex) && !stepEvidence.has('pre-fail')) {
1781
+ stepEvidence.set('pre-fail', evidence);
1782
+ }
1783
+ return;
1784
+ }
1785
+ if (firstWriteIndex !== -1 && index > firstWriteIndex && !stepEvidence.has('post-verify')) {
1786
+ stepEvidence.set('post-verify', evidence);
1787
+ }
1788
+ }
1789
+ });
1790
+
1791
+ const stepsDone = [];
1792
+ const stepsSkipped = [];
1793
+ for (const step of steps) {
1794
+ const hit = stepEvidence.get(step.derive);
1795
+ if (hit) {
1796
+ stepsDone.push({
1797
+ id: step.id,
1798
+ at: hit.at ?? null,
1799
+ ...(hit.evidence ? { evidence: hit.evidence } : {}),
1800
+ });
1801
+ } else if (step.essential === false && PROPORTIONATE_TASK_TYPES.has(ledger.taskType)) {
1802
+ stepsSkipped.push({ id: step.id, justification: PROPORTIONATE_JUSTIFICATION });
1803
+ }
1804
+ }
1805
+ const verification = steps.length === 0
1806
+ ? 'n/a'
1807
+ : stepEvidence.has('post-verify') ? 'passed'
1808
+ : anyVerificationFailed ? 'failed'
1809
+ : 'pending';
1810
+ return {
1811
+ playbookId: id,
1812
+ reason: boundedTraceText(ledger.playbookReason) || 'routed',
1813
+ selectedAt: Number.isFinite(ledger.startedAt) ? ledger.startedAt : receipts[0]?.ts ?? null,
1814
+ requiredSteps: steps.map((step) => step.id),
1815
+ stepsDone,
1816
+ stepsSkipped,
1817
+ outputs,
1818
+ verification,
1819
+ loaded,
1820
+ };
1821
+ }
1822
+
1823
+ /** Required steps neither done nor proportionately skipped — the advisory list. */
1824
+ export function playbookTraceMissingSteps(trace = {}) {
1825
+ const done = new Set((Array.isArray(trace.stepsDone) ? trace.stepsDone : []).map((step) => step?.id));
1826
+ const skipped = new Set((Array.isArray(trace.stepsSkipped) ? trace.stepsSkipped : []).map((step) => step?.id));
1827
+ return (Array.isArray(trace.requiredSteps) ? trace.requiredSteps : [])
1828
+ .filter((id) => !done.has(id) && !skipped.has(id));
1829
+ }
1830
+
1831
+ /** 'playbook bug-fix: 3/4 steps, missing: verify' — null for no-playbook traces. */
1832
+ export function formatPlaybookTrace(trace = {}) {
1833
+ if (!trace || !trace.playbookId) return null;
1834
+ const required = Array.isArray(trace.requiredSteps) ? trace.requiredSteps : [];
1835
+ const missing = playbookTraceMissingSteps(trace);
1836
+ const base = `playbook ${trace.playbookId}: ${(Array.isArray(trace.stepsDone) ? trace.stepsDone : []).length}/${required.length} steps`;
1837
+ return missing.length > 0 ? `${base}, missing: ${missing.join(', ')}` : base;
1838
+ }
1839
+
1840
+ /**
1841
+ * Status/reporting view for `ukit status` and the `--playbook-status` CLI:
1842
+ * routed playbook id, the persisted trace, and its one-line summary.
1843
+ */
1844
+ export async function playbookStatus(projectRoot, payload = {}) {
1845
+ const state = await readRouteState(projectRoot, payload);
1846
+ const ledger = (await readExecutionLedger(projectRoot, payload)) || {};
1847
+ const playbookId = state?.routeSummary?.playbookId || ledger?.playbookId || null;
1848
+ const trace = buildPlaybookTrace(ledger, playbookId);
1849
+ return {
1850
+ playbookId,
1851
+ playbookReason: ledger?.playbookReason || state?.routeSummary?.playbookReason || null,
1852
+ taskType: ledger?.taskType || state?.routeSummary?.taskType || null,
1853
+ trace,
1854
+ summary: formatPlaybookTrace(trace),
1855
+ };
1856
+ }
1857
+
1576
1858
  // --- TASK-009 (BL-011): bug-fix completion floor --------------------------------
1577
1859
  // A green verdict on a `playbookId: bug-fix` route requires verbatim
1578
1860
  // `repro-before` (failing) + `repro-after` (passing) evidence receipts on the
@@ -1861,7 +2143,16 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
1861
2143
  // ones are kept in a bounded history so "one rebuild restores currency" has data.
1862
2144
  if (receipt.verdict && typeof receipt.verdict === 'object') {
1863
2145
  const history = Array.isArray(next.verdictHistory) ? next.verdictHistory : [];
1864
- next.verdictHistory = [...history, ...(next.verdict ? [next.verdict] : [])].slice(-MAX_VERDICT_HISTORY);
2146
+ const merged = [...history, ...(next.verdict ? [next.verdict] : [])];
2147
+ let kept = merged.slice(-MAX_VERDICT_HISTORY);
2148
+ // A later failed/unproven/check-only run must never push the last satisfying (real PASS)
2149
+ // verdict out of the bounded window — currency, not recency of failures, decides it.
2150
+ if (!kept.some((v) => VERDICT_SATISFYING_KINDS.has(v?.kind))
2151
+ && !VERDICT_SATISFYING_KINDS.has(receipt.verdict.kind)) {
2152
+ const lastPass = [...merged].reverse().find((v) => VERDICT_SATISFYING_KINDS.has(v?.kind));
2153
+ if (lastPass) kept = [lastPass, ...kept.slice(1)];
2154
+ }
2155
+ next.verdictHistory = kept;
1865
2156
  next.verdict = receipt.verdict;
1866
2157
  }
1867
2158
 
@@ -1957,6 +2248,12 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
1957
2248
  }
1958
2249
 
1959
2250
  next.receipts = appendReceipt(next.receipts, receipt);
2251
+ // Playbook trace: re-derived from the ledger's own receipts on every applied
2252
+ // receipt (live and journaled replay alike) so the persisted record never
2253
+ // shows an evictable window. Only playbook-routed ledgers grow the field.
2254
+ if (next.playbookId) {
2255
+ next.playbookTrace = buildPlaybookTrace(next, next.playbookId);
2256
+ }
1960
2257
  next.updatedAt = Date.now();
1961
2258
  return { ledger: next, value: null };
1962
2259
  }
@@ -2150,6 +2447,17 @@ export async function recordLedgerEvent(event, {
2150
2447
  if (state && ledger.playbookId !== playbookId) {
2151
2448
  ledger = { ...ledger, playbookId };
2152
2449
  }
2450
+ // Playbook trace signals: the selection reason and the route's taskType
2451
+ // (proportionality) ride the same route summary. Persisted so the trace
2452
+ // derives identically on journaled replays, where no route state is passed.
2453
+ const playbookReason = state?.routeSummary?.playbookReason || null;
2454
+ if (state && playbookReason && ledger.playbookReason !== playbookReason) {
2455
+ ledger = { ...ledger, playbookReason };
2456
+ }
2457
+ const routeTaskType = state?.routeSummary?.taskType || null;
2458
+ if (state && routeTaskType && ledger.taskType !== routeTaskType) {
2459
+ ledger = { ...ledger, taskType: routeTaskType };
2460
+ }
2153
2461
  const applied = applyReceiptToLedger(ledger, event.receipt, { vibecode: event.vibecode === true });
2154
2462
  if (!applied) return { committed: false, eventId, reason: 'invalid-receipt' };
2155
2463
  ledger = applied.ledger;
@@ -2671,6 +2979,12 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
2671
2979
  }
2672
2980
  }
2673
2981
  }
2982
+ // A pipe-masked run is UNPROVEN, not failed: name the real gap (no provable exit status)
2983
+ // instead of telling the model to "fix the failure" of a run that may have passed.
2984
+ const lastVerification = [...(ledger.receipts || [])].reverse().find((r) => r?.kind === 'verification');
2985
+ if (!instruction && ledger.verificationFailed && lastVerification?.exitStatusUnproven === true) {
2986
+ instruction = `The last verification (\`${String(lastVerification.command || '').slice(0, 80)}\`) ran in a pipeline, so its exit status is unproven. Re-run it without the pipe, or with \`set -o pipefail\`, and inspect the exit code.`;
2987
+ }
2674
2988
  if (!instruction && ledger.verificationFailed) {
2675
2989
  instruction = 'Use the latest failed verification output, fix the failure, and rerun targeted verification.';
2676
2990
  } else if (!instruction) {
@@ -2717,7 +3031,71 @@ function writeEvidenceTier(ledger = {}) {
2717
3031
  return ledger.inferredWrite === true && ledger.directWrite !== true ? { evidenceTier: 'inferred' } : {};
2718
3032
  }
2719
3033
 
2720
- export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
3034
+ // Lazy: a missing/unloadable intent module must degrade to the strict gate, never crash Stop.
3035
+ async function resolveStopPhase(transcriptPath) {
3036
+ try {
3037
+ const mod = await import(new URL('./handoff-intent.mjs', import.meta.url).href);
3038
+ return await mod.resolveStopPhase(transcriptPath);
3039
+ } catch {
3040
+ return null;
3041
+ }
3042
+ }
3043
+ export { resolveStopPhase };
3044
+
3045
+ // Planning artifacts (docs/, tests/) are the only writes a handoff-create session may make.
3046
+ const PLANNING_WRITE_PATH = /(?:^|\/)(?:docs|tests?)\//;
3047
+ function wroteOutsidePlanningScope(ledger) {
3048
+ return (ledger?.receipts || []).some((r) => r?.kind === 'write'
3049
+ && typeof r.file === 'string' && r.file
3050
+ && !r.file.startsWith('bash-workspace:')
3051
+ && !PLANNING_WRITE_PATH.test(r.file.replace(/\\/g, '/')));
3052
+ }
3053
+
3054
+ /**
3055
+ * Command-authority valve (RC-A). `phase` is the session's CURRENT handoff intent from
3056
+ * handoff-intent.mjs: 'create' is planning-only, 'review'/'neutral' (review, status, clear)
3057
+ * carry no implementation debt unless a write actually happened. The gate must never turn
3058
+ * a planning-only session into an "Edit/Write now" instruction. implement/fullstack keep
3059
+ * the normal gate. Returns a release decision or null.
3060
+ */
3061
+ function phaseRelease(phase, ledger, missingEvidence) {
3062
+ if (!missingEvidence.some((item) => item === 'write-evidence' || item === 'verification-evidence')) return null;
3063
+ if (phase === 'create' && !wroteOutsidePlanningScope(ledger)) {
3064
+ return {
3065
+ continue: false,
3066
+ notify: true,
3067
+ gateRelease: 'planning-only',
3068
+ phase,
3069
+ missingEvidence,
3070
+ reason: 'UKit completion gate: handoff-create is planning-only, so write/verification evidence is not expected. Stop once the plan, spec and tasks are written; implementation needs /ukit:handoff-implement or /ukit:handoff-fullstack.',
3071
+ };
3072
+ }
3073
+ if ((phase === 'review' || phase === 'neutral') && ledger?.writeAttempted !== true) {
3074
+ return {
3075
+ continue: false,
3076
+ notify: true,
3077
+ gateRelease: 'phase-no-mutation',
3078
+ phase,
3079
+ missingEvidence,
3080
+ reason: `UKit completion gate: this ${phase === 'review' ? 'handoff-review' : 'handoff status/clear'} session made no change, so write/verification evidence is not expected. A review fix needs an actual edit plus a passing verification.`,
3081
+ };
3082
+ }
3083
+ return null;
3084
+ }
3085
+
3086
+ // Request identity is the prompt (see evidencePromptKey): the router re-keys requestKey on
3087
+ // every Edit/Write, so a requestKey mismatch alone must not discard the evidence — and the
3088
+ // continuation budget — the request already accumulated. A different prompt keeps a clean slate.
3089
+ // Shared by the completion contract and the playbook advisory so both judge the same
3090
+ // request-scoped ledger (another prompt's evidence never counts).
3091
+ function effectiveRequestLedger(state = {}, ledger = {}) {
3092
+ const sameRequest = !ledger?.requestKey || !state?.requestKey
3093
+ || ledger.requestKey === state.requestKey
3094
+ || (ledger.promptKey && evidencePromptKey(state) === ledger.promptKey);
3095
+ return sameRequest ? ledger : {};
3096
+ }
3097
+
3098
+ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null } = {}) {
2721
3099
  const routeSummary = state?.routeSummary || {};
2722
3100
  // Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
2723
3101
  // so the completion gate keeps pushing instead of releasing at the continuation cap.
@@ -2784,10 +3162,7 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2784
3162
  // Request identity is the prompt (see evidencePromptKey): the router re-keys requestKey on
2785
3163
  // every Edit/Write, so a requestKey mismatch alone must not discard the evidence — and the
2786
3164
  // continuation budget — the request already accumulated. A different prompt keeps a clean slate.
2787
- const sameRequest = !ledger?.requestKey || !state?.requestKey
2788
- || ledger.requestKey === state.requestKey
2789
- || (ledger.promptKey && evidencePromptKey(state) === ledger.promptKey);
2790
- const effectiveLedger = sameRequest ? ledger : {};
3165
+ const effectiveLedger = effectiveRequestLedger(state, ledger);
2791
3166
  const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, state, { cwd }));
2792
3167
  // TASK-009 (BL-011): the bug-fix floor. On a `playbookId: bug-fix` route a
2793
3168
  // green verdict requires verbatim repro-before (fail) + repro-after (pass)
@@ -2835,6 +3210,9 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2835
3210
  return { continue: false, notify: false, complete: true, missingEvidence: [], ...writeEvidenceTier(effectiveLedger) };
2836
3211
  }
2837
3212
 
3213
+ const released = phaseRelease(phase, effectiveLedger, missingEvidence);
3214
+ if (released) return released;
3215
+
2838
3216
  // `find-cause` can validly end clean: an investigation may establish that no actionable
2839
3217
  // defect exists. Its contract says a FIX cannot be claimed without write + verification;
2840
3218
  // it does not make a mutation mandatory. Treating missing evidence as an unconditional
@@ -2919,12 +3297,17 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2919
3297
 
2920
3298
  const gated = IMPLEMENT_MODES.has(mode);
2921
3299
  if (!gated) {
3300
+ // Advisory, not a gate message: nothing was written, so verification was never owed.
3301
+ const noWriteAdvisory = !effectiveLedger.writeAttempted && missingEvidence.every((item) => item === 'verification-evidence');
2922
3302
  return {
2923
3303
  continue: false,
2924
3304
  notify: true,
2925
3305
  missingEvidence,
3306
+ ...(noWriteAdvisory ? { gateRelease: 'advisory-no-write' } : {}),
2926
3307
  ...(playbookId === BUGFIX_PLAYBOOK_ID ? { outcome: 'inconclusive' } : {}),
2927
- reason: `UKit completion gate: missing ${missingEvidence.join(', ')}. This mode does not auto-continue; tell the user what is unfinished.`,
3308
+ reason: noWriteAdvisory
3309
+ ? `UKit note: no files were changed in this session, so ${missingEvidence.join(', ')} is not expected (advisory only; nothing to verify).`
3310
+ : `UKit completion gate: missing ${missingEvidence.join(', ')}. This mode does not auto-continue; tell the user what is unfinished.`,
2928
3311
  };
2929
3312
  }
2930
3313
 
@@ -3007,6 +3390,34 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
3007
3390
  };
3008
3391
  }
3009
3392
 
3393
+ // Playbook trace advisory (observability only). A routed playbook other than
3394
+ // bug-fix — whose floor stays the one hard gate — whose required steps are
3395
+ // neither receipt-done nor proportionately skipped, on a request that already
3396
+ // attempted a write, gets one ADVISORY line at the released stop. It never
3397
+ // changes the decision (continue/notify/missingEvidence untouched): no new
3398
+ // continuation loop, no hard block. A no-write release (answer/plan/review, or
3399
+ // a proportionate trivial route) stays advisory-free.
3400
+ function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
3401
+ if (!result || result.continue === true) return null;
3402
+ const routeSummary = state?.routeSummary || {};
3403
+ const playbookId = routeSummary.playbookId || null;
3404
+ if (!playbookId || playbookId === BUGFIX_PLAYBOOK_ID) return null;
3405
+ const effectiveLedger = effectiveRequestLedger(state, ledger);
3406
+ if (effectiveLedger.writeAttempted !== true) return null;
3407
+ const trace = buildPlaybookTrace(effectiveLedger, playbookId);
3408
+ if (playbookTraceMissingSteps(trace).length === 0) return null;
3409
+ return `UKit playbook note: ${formatPlaybookTrace(trace)} (advisory).`;
3410
+ }
3411
+
3412
+ export function evaluateCompletion(args = {}) {
3413
+ const result = evaluateCompletionContract(args);
3414
+ try {
3415
+ const advisory = playbookStopAdvisory(args, result);
3416
+ if (advisory) return { ...result, playbookAdvisory: advisory };
3417
+ } catch { /* the advisory never breaks the gate */ }
3418
+ return result;
3419
+ }
3420
+
3010
3421
  export async function incrementContinuation(projectRoot, payload = {}, ledger = null, requestKey = null, promptKey = null, { signal, deadlineMs } = {}) {
3011
3422
  // Locked (and fail-closed) via recordLedgerEvent: parallel subagents firing Stop hooks
3012
3423
  // share one ledger file, and a stale-snapshot rewrite would reset each other's
@@ -3137,7 +3548,8 @@ async function runEvaluateStop() {
3137
3548
  return;
3138
3549
  }
3139
3550
 
3140
- const result = evaluateCompletion({ state, ledger, cwd: projectRoot });
3551
+ const phase = await resolveStopPhase(payload?.transcript_path);
3552
+ const result = evaluateCompletion({ state, ledger, cwd: projectRoot, phase });
3141
3553
 
3142
3554
  // TASK-C85-019 fix C: a mutating-mode route released by a no-mutation valve
3143
3555
  // while evidence is missing is the anomaly signal for misrouted advisory
@@ -3219,12 +3631,21 @@ async function runEvaluateStop() {
3219
3631
  if (result.capped || result.notify) {
3220
3632
  // Non-blocking endings (non-gated modes, an invisible blocker, an evidence-less route, or
3221
3633
  // cap reached after the final notice) must still tell the user what is unfinished — a
3222
- // silent end is indistinguishable from a stall.
3634
+ // silent end is indistinguishable from a stall. A playbook advisory rides the SAME
3635
+ // single line (evaluator stdout is parsed first-line-wins by the stop coordinator).
3636
+ const message = result.playbookAdvisory ? `${result.reason} ${result.playbookAdvisory}` : result.reason;
3223
3637
  process.stderr.write(`[ukit-completion] ${result.reason}\n`);
3224
- process.stdout.write(`${JSON.stringify({ systemMessage: result.reason })}\n`);
3638
+ process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
3225
3639
  return;
3226
3640
  }
3227
- if (result.complete) return; // silent success: route present, all evidence satisfied
3641
+ if (result.complete) {
3642
+ // A satisfied route stays a silent success — unless the routed playbook has
3643
+ // steps missing, where the trace advisory IS the one-line release message.
3644
+ if (result.playbookAdvisory) {
3645
+ process.stdout.write(`${JSON.stringify({ systemMessage: result.playbookAdvisory })}\n`);
3646
+ }
3647
+ return; // silent success: route present, all evidence satisfied
3648
+ }
3228
3649
  // Defensive final else: no evaluation result may ever end a routed stop silently.
3229
3650
  const fallback = `UKit completion gate: the stop was evaluated without a decision (missing evidence: ${(result.missingEvidence || []).join(', ') || 'none'}). If work is unfinished, re-send the task in a new message.`;
3230
3651
  process.stderr.write(`[ukit-completion] ${fallback}\n`);
@@ -3240,6 +3661,14 @@ async function main() {
3240
3661
  }
3241
3662
  const payload = JSON.parse((await readStdin()) || '{}');
3242
3663
  const projectRoot = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
3664
+ if (process.argv.includes('--playbook-status')) {
3665
+ // Reporting path for `ukit status`: { playbookId, playbookReason, taskType,
3666
+ // trace, summary } for this session's routed playbook (not-applicable when
3667
+ // no playbook is routed).
3668
+ const status = await playbookStatus(projectRoot, payload);
3669
+ process.stdout.write(`${JSON.stringify(status)}\n`);
3670
+ return;
3671
+ }
3243
3672
  if (process.argv.includes('--record')) {
3244
3673
  const receiptResult = await recordExecutionReceipt({
3245
3674
  projectRoot,