@ngockhoale/ukit 3.0.3 → 3.0.5

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 (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +13 -9
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +26 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +22 -9
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +80 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +129 -42
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -18,6 +18,14 @@ import {
18
18
  import { ROUTE_CATALOG } from './route-catalog.mjs';
19
19
  import { detectUnicGateway } from './unic-gateway.mjs';
20
20
  import { withAsyncLock } from '../runtime/async-lock.mjs';
21
+ import { appendDecisionReceipt, recordLedgerEvent } from '../runtime/execution-ledger.mjs';
22
+ import {
23
+ RESUMABLE_RUN_SCHEMA_VERSION,
24
+ readResumableRun,
25
+ resumableRunSourceFingerprint,
26
+ safeTaskId,
27
+ writeResumableRun,
28
+ } from '../runtime/resumable-run.mjs';
21
29
 
22
30
  const {
23
31
  resolveContext,
@@ -280,6 +288,22 @@ export function resolveRouteStage(config = null, key) {
280
288
  return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
281
289
  }
282
290
 
291
+ // FR-008 (M07): decision-plane stage resolvers — same absent/malformed → 'off'
292
+ // contract as routing.* stages. `decisionPlane.enabled === false` is the global
293
+ // emergency disable and reads as 'off' regardless of stage keys.
294
+ export function resolveDecisionPlaneStage(config = null) {
295
+ const plane = config?.decisionPlane;
296
+ if (!plane || typeof plane !== 'object' || plane.enabled === false) return 'off';
297
+ return ROUTE_SCHEMA_STAGES.has(plane.stage) ? plane.stage : 'off';
298
+ }
299
+
300
+ // Per-family override wins over the global stage; absence inherits it.
301
+ export function resolveDecisionFamilyStage(config = null, family) {
302
+ const globalStage = resolveDecisionPlaneStage(config);
303
+ const override = config?.decisionPlane?.families?.[family]?.stage;
304
+ return ROUTE_SCHEMA_STAGES.has(override) ? override : globalStage;
305
+ }
306
+
283
307
  // FR-003 (M01.2'): derive the additive riskFloor from hard signals. Codes are
284
308
  // collected in ROUTE_RISK_REASON_CODES table order; the floor is 'high-risk' iff
285
309
  // any of the first six (floor-raising) codes fired — informational codes never
@@ -1420,6 +1444,8 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
1420
1444
  recentOutputSnapshot,
1421
1445
  previousRouteSummary: null,
1422
1446
  escalationConfig,
1447
+ sessionId: normalizedInput.sessionId ?? null,
1448
+ indexGeneratedAtMs,
1423
1449
  });
1424
1450
  const sharedState = createSharedRouteState({
1425
1451
  route,
@@ -1641,6 +1667,8 @@ async function main() {
1641
1667
  recentOutputSnapshot,
1642
1668
  previousRouteSummary: previousState?.routeSummary ?? null,
1643
1669
  escalationConfig,
1670
+ sessionId,
1671
+ indexGeneratedAtMs,
1644
1672
  });
1645
1673
  await seedHelperCaches({
1646
1674
  rootDir,
@@ -1801,6 +1829,372 @@ async function prepareTaskRoute({
1801
1829
  };
1802
1830
  }
1803
1831
 
1832
+ // --- M07 decision-plane shadow hook (TASK-005, SPEC §5 FR-008/FR-011) ----------
1833
+ // Stage-gated adapter call at route finalize. 'off' (or enabled:false) → zero
1834
+ // adapter invocation, zero output change. Any other stage → the standalone
1835
+ // unic-decision.mjs CLI is spawned once with a bounded batch, its typed result
1836
+ // is folded into a redacted receipt (codes/bands only — never prompts, state
1837
+ // text, or raw probabilities), the receipt rides routeSummary.decisionPlane
1838
+ // additively, and one row is appended to .ukit/storage/decisions.tsv via
1839
+ // execution-ledger's appendDecision. Deterministic policy stays authoritative:
1840
+ // the shadow answer never rewrites a route field. Every failure path is
1841
+ // fail-silent to the deterministic baseline.
1842
+ const UNIC_DECISION_CLI_PATH = path.join(__routeTaskDir, 'unic-decision.mjs');
1843
+ const DECISION_SHADOW_TIMEOUT_CAP_MS = 3000;
1844
+
1845
+ // Route-side shadow questions — mirror of the DECISION_REGISTRY entries owned
1846
+ // by taskRouting outside the preflight bundle (src/decision/registry.js).
1847
+ // Candidates are protocol-local labels, not user-facing prose.
1848
+ const ROUTE_SHADOW_QUESTIONS = Object.freeze([
1849
+ {
1850
+ decisionKey: 'route.intent-kind.v1',
1851
+ family: 'route',
1852
+ kind: 'choice',
1853
+ instruction: 'Intent kind for the route: informational | mutation | investigation | review | delivery.',
1854
+ candidates: ['informational', 'mutation', 'investigation', 'review', 'delivery'],
1855
+ },
1856
+ {
1857
+ decisionKey: 'route.rigor.v1',
1858
+ family: 'rigor',
1859
+ kind: 'score',
1860
+ instruction: 'R0-R4 rigor recommendation inside deterministic floors.',
1861
+ candidates: ['r0', 'r1', 'r2', 'r3', 'r4'],
1862
+ },
1863
+ ]);
1864
+
1865
+ // Only families whose resolved stage is not 'off' get a question.
1866
+ export function buildShadowDecisionQuestions(config = null) {
1867
+ return ROUTE_SHADOW_QUESTIONS
1868
+ .filter((q) => resolveDecisionFamilyStage(config, q.family) !== 'off')
1869
+ .map(({ family, ...question }) => question);
1870
+ }
1871
+
1872
+ // Probability → band for receipts. Bands only; raw probabilities never leave
1873
+ // the adapter result.
1874
+ function probabilityBand(value) {
1875
+ if (!Number.isFinite(value)) return 'unknown';
1876
+ if (value >= 0.8) return 'high';
1877
+ if (value >= 0.5) return 'medium';
1878
+ return 'low';
1879
+ }
1880
+
1881
+ // Deterministic baselines the shadow answers are compared against. Only fields
1882
+ // the deterministic route already computes; null baseline → 'unknown'.
1883
+ function shadowBaselineFor(decisionKey, routeSummary) {
1884
+ if (decisionKey === 'route.intent-kind.v1') return routeSummary?.intent?.kind ?? null;
1885
+ if (decisionKey === 'route.rigor.v1') return routeSummary?.execution?.rigor ?? null;
1886
+ return null;
1887
+ }
1888
+
1889
+ // Map a validated answer value onto its candidate label. Choice answers already
1890
+ // carry the label; score answers carry a 1-based integer over the question's
1891
+ // ordered candidates (2 → 'r1'). Returns null when no label can be resolved.
1892
+ function answerCandidateLabel(answer, question) {
1893
+ if (question?.kind === 'score') {
1894
+ const candidates = Array.isArray(question.candidates) ? question.candidates : [];
1895
+ return Number.isInteger(answer?.value) ? candidates[answer.value - 1] ?? null : null;
1896
+ }
1897
+ return typeof answer?.value === 'string' ? answer.value : null;
1898
+ }
1899
+
1900
+ // Fold a CLI batch result into the redacted receipt shape (FR-009):
1901
+ // {decisionKeys, probabilityBands, outcomeClass, checkpoint, latencyClass,
1902
+ // fallbackCode, agreement}. Never carries state text, prompts, or secrets.
1903
+ function buildDecisionShadowReceipt({ stage, result, routeSummary, questions = [] }) {
1904
+ const questionByKey = new Map(questions.map((q) => [q.decisionKey, q]));
1905
+ const answers = Array.isArray(result?.answers) ? result.answers : [];
1906
+ const probabilityBands = {};
1907
+ let compared = 0;
1908
+ let agreed = 0;
1909
+ for (const answer of answers) {
1910
+ if (answer?.validationStatus !== 'valid') continue;
1911
+ const label = answerCandidateLabel(answer, questionByKey.get(answer.decisionKey));
1912
+ const selectedProbability = label === null ? null : answer.probabilities?.[label];
1913
+ probabilityBands[answer.decisionKey] = probabilityBand(selectedProbability);
1914
+ const baseline = shadowBaselineFor(answer.decisionKey, routeSummary);
1915
+ if (label !== null && baseline !== null && baseline !== undefined) {
1916
+ compared += 1;
1917
+ if (label.toLowerCase() === String(baseline).toLowerCase()) agreed += 1;
1918
+ }
1919
+ }
1920
+ const agreement = compared === 0
1921
+ ? 'unknown'
1922
+ : agreed === compared ? 'agree' : agreed === 0 ? 'disagree' : 'mixed';
1923
+ return {
1924
+ stage,
1925
+ outcomeClass: result?.status ?? 'unavailable',
1926
+ decisionKeys: answers.map((a) => a?.decisionKey).filter(Boolean),
1927
+ probabilityBands,
1928
+ checkpoint: result?.checkpoint ?? null,
1929
+ latencyClass: result?.latencyClass ?? 'unknown',
1930
+ fallbackCode: result?.fallbackCode ?? null,
1931
+ agreement,
1932
+ };
1933
+ }
1934
+
1935
+ /**
1936
+ * Run the decision-plane shadow adapter for a finalized route. Returns the
1937
+ * redacted receipt, or null when the plane is off / no family is enabled.
1938
+ * Deadline-bounded (spawn timeout capped at DECISION_SHADOW_TIMEOUT_CAP_MS);
1939
+ * every failure degrades to a receipt with outcomeClass 'unavailable' —
1940
+ * never throws, never blocks the route.
1941
+ */
1942
+ export async function runRouteDecisionShadow({
1943
+ rootDir = process.cwd(),
1944
+ config = null,
1945
+ routeSummary = null,
1946
+ routingContext = {},
1947
+ spawnImpl = spawnSync,
1948
+ appendDecisionImpl = null,
1949
+ now = () => Date.now(),
1950
+ } = {}) {
1951
+ const stage = resolveDecisionPlaneStage(config);
1952
+ if (stage === 'off') return null;
1953
+ const questions = buildShadowDecisionQuestions(config);
1954
+ if (questions.length === 0) {
1955
+ return { stage, outcomeClass: 'skipped', decisionKeys: [], probabilityBands: {},
1956
+ checkpoint: null, latencyClass: 'unknown', fallbackCode: 'no-enabled-families',
1957
+ agreement: 'unknown' };
1958
+ }
1959
+
1960
+ const cliPath = process.env.UKIT_DECISION_CLI_PATH || UNIC_DECISION_CLI_PATH;
1961
+ const timeoutMs = Math.min(
1962
+ (Number.isFinite(config?.decisionPlane?.timeoutMs)
1963
+ ? config.decisionPlane.timeoutMs
1964
+ : DECISION_SHADOW_TIMEOUT_CAP_MS) + 1000,
1965
+ DECISION_SHADOW_TIMEOUT_CAP_MS + 1000,
1966
+ );
1967
+ const batch = {
1968
+ batchId: `route-shadow-${now().toString(36)}`,
1969
+ boundary: 'route',
1970
+ deadlineMs: Math.max(500, timeoutMs - 1000),
1971
+ statePacket: {
1972
+ stateVersion: 1,
1973
+ taskClass: routingContext.taskClass ?? routingContext.taskType ?? 'unknown',
1974
+ intentSignals: routingContext.intentMode ? [routingContext.intentMode] : [],
1975
+ targetClass: routingContext.targetFile ? 'file-target' : 'no-target',
1976
+ execution: routeSummary?.executionMode ? { mode: routeSummary.executionMode } : {},
1977
+ riskSignals: routeSummary?.riskFloor?.codes ?? [],
1978
+ },
1979
+ questions,
1980
+ };
1981
+
1982
+ let result = null;
1983
+ const startedAt = now();
1984
+ try {
1985
+ const spawned = spawnImpl(
1986
+ process.execPath,
1987
+ [cliPath, '--root', rootDir],
1988
+ {
1989
+ cwd: rootDir,
1990
+ input: JSON.stringify(batch),
1991
+ encoding: 'utf8',
1992
+ timeout: timeoutMs,
1993
+ },
1994
+ );
1995
+ if (spawned && !spawned.error && spawned.status === 0 && spawned.stdout) {
1996
+ result = JSON.parse(spawned.stdout);
1997
+ } else {
1998
+ result = { status: 'unavailable', fallbackCode: 'adapter-spawn-failed' };
1999
+ }
2000
+ } catch {
2001
+ result = { status: 'unavailable', fallbackCode: 'adapter-spawn-failed' };
2002
+ }
2003
+ if (!result || typeof result !== 'object') {
2004
+ result = { status: 'unavailable', fallbackCode: 'malformed-response' };
2005
+ }
2006
+ if (result.latencyClass == null) {
2007
+ const elapsed = now() - startedAt;
2008
+ result = {
2009
+ ...result,
2010
+ latencyClass: elapsed < 250 ? 'fast' : elapsed < 2000 ? 'normal' : 'slow',
2011
+ };
2012
+ }
2013
+
2014
+ const receipt = buildDecisionShadowReceipt({ stage, result, routeSummary, questions });
2015
+ // Append-only receipt row (decisions.tsv): codes/bands only via the ledger's
2016
+ // typed receipt writer (statically imported above alongside recordLedgerEvent).
2017
+ try {
2018
+ const append = appendDecisionImpl ?? appendDecisionReceipt;
2019
+ await append(rootDir, {
2020
+ kind: 'shadow',
2021
+ boundary: 'route',
2022
+ stage,
2023
+ outcomeClass: receipt.outcomeClass,
2024
+ checkpoint: receipt.checkpoint,
2025
+ latencyClass: receipt.latencyClass,
2026
+ decisionKeys: receipt.decisionKeys,
2027
+ agreement: receipt.agreement,
2028
+ fallbackCode: receipt.fallbackCode,
2029
+ now: now(),
2030
+ });
2031
+ } catch {
2032
+ // Receipt loss is advisory — never fail the route over telemetry.
2033
+ }
2034
+ return receipt;
2035
+ }
2036
+
2037
+ // --- M04.1 resumable run emit (TASK-007, SPEC §5 FR-013..FR-015) --------------
2038
+ // At route finalize, stage != 'off' persists/updates the C10 record under
2039
+ // .ukit/storage/runs/<taskId>.json through resumable-run.mjs's fail-closed
2040
+ // lock/journal path, then lands a bounded `resumable-run` pointer event on the
2041
+ // execution ledger. Stage 'off' (or absent/malformed) returns null before any
2042
+ // I/O — the route stays byte-identical. Every failure is advisory: routing never
2043
+ // fails over run-state persistence.
2044
+
2045
+ // Same absent/malformed → 'off' contract as the routing.* stage resolvers; the
2046
+ // stage key lives under continuity.resumableRun (TASK-001 config block).
2047
+ export function resolveResumableRunStage(config = null) {
2048
+ const stage = config?.continuity?.resumableRun?.stage;
2049
+ return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
2050
+ }
2051
+
2052
+ // The logical task boundary is the explicit user prompt — a new prompt means a
2053
+ // new task, so the persisted record resets instead of merging stale plans.
2054
+ // Hashed: prompt text never persists (C10 redaction contract).
2055
+ function resumableTaskBoundary(routingContext = {}) {
2056
+ const text = String(
2057
+ routingContext.lastExplicitUserPromptText ?? routingContext.promptText ?? '',
2058
+ ).trim();
2059
+ return text
2060
+ ? crypto.createHash('sha256').update(text).digest('hex').slice(0, 32)
2061
+ : 'no-prompt';
2062
+ }
2063
+
2064
+ // Session-scoped when a session id exists (one record file per session, reset by
2065
+ // taskBoundary); prompt-scoped otherwise so concurrent anonymous routes never
2066
+ // collide on a shared 'default' file.
2067
+ function resumableTaskId({ sessionId = null, taskBoundary }) {
2068
+ return safeTaskId(sessionId || `prompt-${taskBoundary}`);
2069
+ }
2070
+
2071
+ // Merge semantics: within the same taskBoundary the record is UPDATED — plans
2072
+ // (hypotheses/decisions/failures) and completed receipts carry forward; a new
2073
+ // boundary starts a fresh record. The phase that just completed is appended to
2074
+ // completedBlocks when it changes.
2075
+ function buildResumableRunRecord({
2076
+ taskId,
2077
+ taskBoundary,
2078
+ routeSummary,
2079
+ previousRecord = null,
2080
+ sourceSnapshot,
2081
+ }) {
2082
+ const previous = previousRecord && previousRecord.taskBoundary === taskBoundary
2083
+ ? previousRecord
2084
+ : null;
2085
+ const phase = String(
2086
+ routeSummary?.continuationState?.nextMilestone
2087
+ ?? routeSummary?.nextActionType
2088
+ ?? 'routed',
2089
+ ).slice(0, 512);
2090
+ const completedBlocks = [...(previous?.workflow?.completedBlocks ?? [])];
2091
+ const previousPhase = previous?.workflow?.phase;
2092
+ if (previousPhase && previousPhase !== phase && !completedBlocks.includes(previousPhase)) {
2093
+ completedBlocks.push(previousPhase);
2094
+ }
2095
+ const nextAction = routeSummary?.nextActionCommand ?? routeSummary?.nextActionType ?? null;
2096
+ return {
2097
+ schemaVersion: RESUMABLE_RUN_SCHEMA_VERSION,
2098
+ taskId,
2099
+ taskBoundary,
2100
+ route: {
2101
+ routeVersion: String(ROUTE_VERSION),
2102
+ mode: String(routeSummary?.executionMode ?? 'unknown'),
2103
+ rigor: String(routeSummary?.execution?.rigor ?? 'unresolved'),
2104
+ contractVersion: String(ROUTE_CONTRACT_VERSION),
2105
+ },
2106
+ budget: {
2107
+ policyVersion: String(routeSummary?.ceremonyBudget?.policyVersion ?? ROUTE_CONTRACT_VERSION),
2108
+ consumed: 0,
2109
+ remaining: Number.isFinite(routeSummary?.ceremonyBudget?.limits?.maxReadPasses)
2110
+ ? routeSummary.ceremonyBudget.limits.maxReadPasses
2111
+ : 0,
2112
+ },
2113
+ workflow: {
2114
+ workflowId: String(routeSummary?.workflowPolicyName ?? 'route'),
2115
+ workflowVersion: 'v1',
2116
+ phase,
2117
+ completedBlocks,
2118
+ },
2119
+ invariants: previous?.invariants ?? [],
2120
+ hypotheses: previous?.hypotheses ?? [],
2121
+ decisions: previous?.decisions ?? [],
2122
+ evidenceRefs: previous?.evidenceRefs ?? [],
2123
+ escalationHistory: previous?.escalationHistory ?? [],
2124
+ unresolvedFailures: previous?.unresolvedFailures ?? [],
2125
+ nextAction: typeof nextAction === 'string' ? nextAction.slice(0, 512) : null,
2126
+ sourceSnapshot,
2127
+ };
2128
+ }
2129
+
2130
+ /**
2131
+ * Emit/update the C10 record at route finalize. Returns the bounded pointer
2132
+ * ({taskId, taskBoundary, stage, status}) that rides routeSummary.resumableRun
2133
+ * additively, or null when the stage is off. Never throws.
2134
+ */
2135
+ export async function emitResumableRun({
2136
+ rootDir = process.cwd(),
2137
+ config = null,
2138
+ routeSummary = null,
2139
+ routingContext = {},
2140
+ sessionId = null,
2141
+ indexGeneratedAtMs = null,
2142
+ projectVerification = null,
2143
+ signal = null,
2144
+ deadlineMs = null,
2145
+ } = {}) {
2146
+ const stage = resolveResumableRunStage(config);
2147
+ if (stage === 'off') return null;
2148
+ try {
2149
+ const taskBoundary = resumableTaskBoundary(routingContext);
2150
+ const taskId = resumableTaskId({ sessionId, taskBoundary });
2151
+ const fingerprint = await resumableRunSourceFingerprint(rootDir, { indexGeneratedAtMs });
2152
+ const sourceSnapshot = {
2153
+ source: String(projectVerification?.fingerprint ?? 'none'),
2154
+ ...fingerprint,
2155
+ };
2156
+ // Read-then-write: within the same boundary the record is updated (plans and
2157
+ // receipts carry forward); a boundary change reads 'absent' → fresh record.
2158
+ const resumed = await readResumableRun(rootDir, { taskId, taskBoundary });
2159
+ const previousRecord = resumed.status === 'fresh' || resumed.status === 'stale'
2160
+ ? resumed.record
2161
+ : null;
2162
+ const record = buildResumableRunRecord({
2163
+ taskId,
2164
+ taskBoundary,
2165
+ routeSummary,
2166
+ previousRecord,
2167
+ sourceSnapshot,
2168
+ });
2169
+ const write = await writeResumableRun(rootDir, record, {
2170
+ signal,
2171
+ deadlineMs: deadlineMs ?? undefined,
2172
+ config: { continuity: { resumableRun: { stage } } },
2173
+ });
2174
+ const status = write.ok
2175
+ ? 'written'
2176
+ : (write.journaled ? 'journaled' : `skipped-${write.reason ?? 'unknown'}`);
2177
+ const pointer = { taskId, taskBoundary, stage, status };
2178
+ // Ledger pointer event — same fail-closed journal discipline; advisory only.
2179
+ try {
2180
+ await recordLedgerEvent(
2181
+ { type: 'resumable-run', run: { ...pointer, updatedAt: record.updatedAt ?? Date.now() } },
2182
+ {
2183
+ projectRoot: rootDir,
2184
+ payload: sessionId ? { session_id: sessionId } : {},
2185
+ signal,
2186
+ deadlineMs: deadlineMs ?? undefined,
2187
+ },
2188
+ );
2189
+ } catch {
2190
+ // ledger pointer loss is advisory — the record itself is already durable
2191
+ }
2192
+ return pointer;
2193
+ } catch {
2194
+ return null; // persistence must never fail the route
2195
+ }
2196
+ }
2197
+
1804
2198
  async function finalizeTaskRoute({
1805
2199
  rootDir = process.cwd(),
1806
2200
  commandNamespace = '.claude',
@@ -1813,6 +2207,8 @@ async function finalizeTaskRoute({
1813
2207
  recentOutputSnapshot = null,
1814
2208
  previousRouteSummary = null,
1815
2209
  escalationConfig = null,
2210
+ sessionId = null,
2211
+ indexGeneratedAtMs = null,
1816
2212
  } = {}) {
1817
2213
  const absoluteRoot = path.resolve(rootDir);
1818
2214
  const activeSkills = preparedRoute?.activeSkills ?? [];
@@ -1910,6 +2306,35 @@ async function finalizeTaskRoute({
1910
2306
  targetFile: routingContext.targetFile ?? null,
1911
2307
  });
1912
2308
  appendRiskEscalationSegment(routeSummary);
2309
+ // M07 (TASK-005): stage-gated decision-plane shadow call. 'off' → null and
2310
+ // nothing else happens (byte-identical route); any other stage → bounded
2311
+ // adapter batch, redacted receipt attached additively, decisions.tsv row.
2312
+ const decisionShadow = await runRouteDecisionShadow({
2313
+ rootDir: absoluteRoot,
2314
+ config: escalationConfig,
2315
+ routeSummary,
2316
+ routingContext,
2317
+ });
2318
+ if (decisionShadow) {
2319
+ routeSummary.decisionPlane = decisionShadow;
2320
+ }
2321
+ // M04.1 (TASK-007): stage-gated resumable-run emit. 'off' → null and nothing
2322
+ // else happens (byte-identical route); any other stage → the C10 record is
2323
+ // persisted/updated under .ukit/storage/runs/ and a bounded pointer lands on
2324
+ // the execution ledger. The pointer rides routeSummary.resumableRun so the
2325
+ // shared route state carries the taskId reinject-context resumes from.
2326
+ const resumableRun = await emitResumableRun({
2327
+ rootDir: absoluteRoot,
2328
+ config: escalationConfig,
2329
+ routeSummary,
2330
+ routingContext,
2331
+ sessionId,
2332
+ indexGeneratedAtMs,
2333
+ projectVerification,
2334
+ });
2335
+ if (resumableRun) {
2336
+ routeSummary.resumableRun = resumableRun;
2337
+ }
1913
2338
  const approachSelector = routeSummary?.approachSelector ?? null;
1914
2339
 
1915
2340
  return {
@@ -2641,6 +3066,17 @@ async function deriveVerificationPlan({
2641
3066
  reasons.push('highRiskFallback');
2642
3067
  commands.push(buildScriptCommand(packageManager, 'test'));
2643
3068
  }
3069
+
3070
+ // Non-empty floor (RULES.md test-selection §3): when nothing targeted and
3071
+ // no fallback resolved, the plan must still route the broadest test
3072
+ // script the project declares — an empty selection is never permitted.
3073
+ if (commands.length === 0 && fallbackCommands.length === 0) {
3074
+ const floorScript = scripts['test:release-core'] ? 'test:release-core' : (scripts.test ? 'test' : null);
3075
+ if (floorScript) {
3076
+ reasons.push('nonEmptyFloor');
3077
+ fallbackCommands.push(buildScriptCommand(packageManager, floorScript));
3078
+ }
3079
+ }
2644
3080
  }
2645
3081
 
2646
3082
  if (commands.length === 0 && fallbackCommands.length === 0) {
@@ -4600,6 +5036,10 @@ export function compactRouteSummary(routeSummary = null) {
4600
5036
  nextActionType: routeSummary.nextActionType ?? null,
4601
5037
  nextActionCommand: routeSummary.nextActionCommand ?? null,
4602
5038
  helperHint: routeSummary.helperHint ?? null,
5039
+ // M04.1 (TASK-007): the resumable-run pointer survives compaction so
5040
+ // reinject-context resumes from the same taskId/boundary. Conditional
5041
+ // spread — stage-off output stays byte-identical (no key when absent).
5042
+ ...(routeSummary.resumableRun ? { resumableRun: routeSummary.resumableRun } : {}),
4603
5043
  // v3 advisory blocks: survive compaction so printRouteState and cached/shared route
4604
5044
  // states still emit them. Null when the lane suppresses them — cheap either way.
4605
5045
  workflowPolicyName: routeSummary.workflowPolicyName ?? null,
@@ -4617,6 +5057,10 @@ export function compactRouteSummary(routeSummary = null) {
4617
5057
  // skill-router-state.json compaction. Conditional spread keeps stage-off
4618
5058
  // output byte-identical (no key emitted when the field is absent).
4619
5059
  ...(routeSummary.riskEscalation ? { riskEscalation: routeSummary.riskEscalation } : {}),
5060
+ // M07 (TASK-005): the decision-plane shadow receipt survives compaction so
5061
+ // persisted route state keeps the redacted outcome. Conditional spread —
5062
+ // stage-off output stays byte-identical (no key emitted when absent).
5063
+ ...(routeSummary.decisionPlane ? { decisionPlane: routeSummary.decisionPlane } : {}),
4620
5064
  // M01.1 compact whitelist (C01): grouped fields survive compaction minus verbose
4621
5065
  // evidence; absent entirely when the route schema stage is off.
4622
5066
  ...(compactResolvedRoute(routeSummary) ?? {}),
@@ -95,23 +95,19 @@ function countTargetFiles(body) {
95
95
 
96
96
  function countTestCases(body) {
97
97
  if (body == null) return 0;
98
- let count = 0;
99
- let sawHeader = false;
100
- let sawSeparator = false;
101
- for (const line of body.split('\n')) {
102
- const trimmed = line.trim();
103
- if (!trimmed.startsWith('|')) continue;
104
- if (!sawHeader) {
105
- sawHeader = true;
106
- continue;
107
- }
108
- if (!sawSeparator && /^\|[-\s|]+\|\s*$/.test(trimmed)) {
109
- sawSeparator = true;
110
- continue;
111
- }
112
- count += 1;
98
+ const rows = body
99
+ .split('\n')
100
+ .map((line) => line.trim())
101
+ .filter((line) => line.startsWith('|'));
102
+ const separatorIdx = rows.findIndex((r) => /^\|[-\s|]+\|\s*$/.test(r));
103
+ if (separatorIdx >= 0) {
104
+ // Header row + separator row precede the data rows.
105
+ return rows.length - (separatorIdx + 1);
113
106
  }
114
- return count;
107
+ // No separator row: a leading canonical header (first cell '#') is still a
108
+ // header; every other pipe row is a test case — a headerless table counts
109
+ // every row, never silently dropping the first.
110
+ return rows[0] && /^\|\s*#\s*\|/.test(rows[0]) ? rows.length - 1 : rows.length;
115
111
  }
116
112
 
117
113
  function extractVerificationCommands(body) {