@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.
- package/CHANGELOG.md +26 -0
- package/bin/ukit +12 -4
- package/manifests/engineConformance.yaml +29 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +2 -1
- package/scripts/audit/decision-coverage.mjs +295 -0
- package/scripts/bench/outline-savings.mjs +19 -4
- package/scripts/bench/parallel-agents.mjs +15 -4
- package/scripts/bench/runGold.mjs +22 -4
- package/scripts/bench/v3-ceremony.mjs +7 -1
- package/scripts/bug/triage.mjs +56 -17
- package/scripts/index/build-index.mjs +94 -28
- package/scripts/index/query-index.mjs +48 -14
- package/scripts/index/refresh-index.mjs +142 -62
- package/scripts/perf/audit-perf.mjs +8 -2
- package/scripts/skill/audit-skill.mjs +54 -25
- package/src/bug/triageBug.js +9 -6
- package/src/cli/adapters.js +6 -0
- package/src/cli/commands/code.js +7 -1
- package/src/cli/commands/indexArgs.js +4 -2
- package/src/cli/commands/indexTools.js +8 -1
- package/src/cli/commands/install.js +13 -0
- package/src/cli/commands/memory.js +13 -9
- package/src/cli/commands/status.js +17 -1
- package/src/cli/commands/update.js +7 -0
- package/src/context/detectProjectContext.js +3 -1
- package/src/core/codeintel/analogy.js +1 -1
- package/src/core/codeintel/diagnostics.js +9 -6
- package/src/core/codeintel/graph.js +14 -8
- package/src/core/codeintel/impact.js +0 -1
- package/src/core/codeintel/invalidation.js +5 -3
- package/src/core/codeintel/packet.js +11 -0
- package/src/core/codeintel/router.js +17 -3
- package/src/core/codeintel/semanticProvider.js +1 -1
- package/src/core/codeintel/summaries.js +11 -7
- package/src/core/compact/contextBudget.js +26 -12
- package/src/core/compact/index.js +15 -8
- package/src/core/docContracts.js +10 -2
- package/src/core/experiments/deliberation.js +321 -0
- package/src/core/experiments/dynamicWorkflow.js +492 -0
- package/src/core/fileOps.js +8 -1
- package/src/core/gatewayProbe.js +29 -1
- package/src/core/gatewayResilienceEnv.js +44 -3
- package/src/core/handoffDocValidator.js +3 -1
- package/src/core/hookChainDoctor.js +16 -1
- package/src/core/memory/deltaOverlays.js +448 -0
- package/src/core/memory/learningCandidates.js +302 -0
- package/src/core/memory/migrate.js +59 -32
- package/src/core/memory/recordStore.js +24 -1
- package/src/core/memory/store.js +44 -8
- package/src/core/memory/storeV2.js +48 -33
- package/src/core/memory/userMemory.js +26 -10
- package/src/core/output/index.js +15 -10
- package/src/core/permissionPolicy.js +8 -0
- package/src/core/runtimeConfig.js +224 -4
- package/src/core/sensitiveValueScanner.js +10 -2
- package/src/core/taskBudgetValidator.js +12 -17
- package/src/core/taskProgressGuard.js +59 -9
- package/src/core/unattendedDoctor.js +5 -2
- package/src/core/uninstall.js +37 -8
- package/src/decision/client.js +371 -0
- package/src/decision/lease.js +198 -0
- package/src/decision/preflight.js +492 -0
- package/src/decision/protocol.js +308 -0
- package/src/decision/registry.js +384 -0
- package/src/decision/shadow.js +281 -0
- package/src/decision/statePacket.js +165 -0
- package/src/diagnostics/failurePatterns.js +2 -1
- package/src/diagnostics/ledgerFiles.js +3 -1
- package/src/diagnostics/routeOutcomes.js +1 -29
- package/src/index/buildIndex.js +23 -11
- package/src/index/impactContext.js +21 -2
- package/src/index/importResolution.js +13 -7
- package/src/index/queryIndex.js +11 -5
- package/src/index/resolveContext.js +22 -9
- package/src/index/taskRouting.js +63 -2
- package/src/index/verificationPlan.js +12 -1
- package/src/learning/patternProposals.js +6 -0
- package/src/render/renderTemplate.js +1 -1
- package/src/skill/auditSkill.js +3 -1
- package/src/stack/detectStack.js +3 -1
- package/template_project/.claude/agents/handoff-planner.md +2 -5
- package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
- package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
- package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
- package/template_project/.claude/hooks/handoff-resume.sh +10 -1
- package/template_project/.claude/hooks/protect-files.sh +0 -1
- package/template_project/.claude/hooks/record-execution.mjs +13 -1
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +80 -5
- package/template_project/.claude/hooks/session-episode.sh +9 -2
- package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
- package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +129 -42
- package/template_project/.claude/ukit/index/route-task.mjs +444 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
- package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
- package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
- package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
- package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
- package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
- package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
- package/template_project/ukit/storage/config.json +34 -1
- package/template_project/.claude/ukit/skill-router-state.json +0 -1
- 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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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) {
|