@ngockhoale/ukit 3.4.28 → 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.
- package/CHANGELOG.md +23 -0
- package/manifests/documentation.yaml +88 -0
- package/package.json +1 -1
- package/src/core/buildPlan.js +11 -1
- package/src/core/permissionPolicy.js +1 -1
- package/src/core/status.js +3 -1
- package/src/index/buildIndex.js +244 -4
- package/src/index/impactContext.js +91 -11
- package/src/index/languageTools.js +126 -0
- package/src/index/paths.js +5 -1
- package/src/index/playbookRegistry.js +108 -9
- package/src/index/queryIndex.js +415 -1
- package/src/index/routeCatalog.js +1 -1
- package/src/index/taskRouting.js +25 -3
- package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
- package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
- package/template_project/.claude/hooks/record-execution.mjs +23 -0
- package/template_project/.claude/hooks/skill-router.sh +105 -7
- package/template_project/.claude/settings.json +21 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
- package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
- package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
- package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
- package/template_project/.claude/ukit/index/query-index.mjs +100 -7
- package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
- package/template_project/.claude/ukit/index/route-task.mjs +208 -31
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +455 -24
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
- package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
- package/template_user/playbooks/feature-implementation.md +9 -0
- 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)
|
|
311
|
-
|
|
312
|
-
|
|
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 = {}) {
|
|
@@ -501,15 +519,57 @@ function extractExitCode(payload = {}) {
|
|
|
501
519
|
return null;
|
|
502
520
|
}
|
|
503
521
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
522
|
+
// The trailing boundary accepts shell terminators (`;`, `&`, `|`, `)`) so
|
|
523
|
+
// `yarn release:verify; echo "exit=$?"` — the real-exit-code idiom — still mints a receipt.
|
|
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);
|
|
507
567
|
return [
|
|
508
|
-
/(
|
|
509
|
-
/(
|
|
510
|
-
/(
|
|
511
|
-
/(
|
|
512
|
-
/(
|
|
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,
|
|
513
573
|
].some((pattern) => pattern.test(text));
|
|
514
574
|
}
|
|
515
575
|
|
|
@@ -783,7 +843,21 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
|
783
843
|
// (a hook or subagent can attest its own kind/verifier); otherwise the kind is
|
|
784
844
|
// derived from the command shape — test runners prove `test-verified`, a run that
|
|
785
845
|
// only type-checks/lints is honestly `check-only`.
|
|
786
|
-
const TEST_RUNNER_COMMAND = /(
|
|
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
|
+
}
|
|
787
861
|
|
|
788
862
|
function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = projectRoot) {
|
|
789
863
|
const supplied = payload?.verdict && typeof payload.verdict === 'object' ? payload.verdict : {};
|
|
@@ -799,7 +873,7 @@ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = pr
|
|
|
799
873
|
let kind = supplied.kind;
|
|
800
874
|
if (!VERDICT_KINDS.has(kind)) {
|
|
801
875
|
if (receipt.success !== true) kind = 'failed';
|
|
802
|
-
else kind =
|
|
876
|
+
else kind = isTestGateCommand(receipt.command) ? 'test-verified' : 'check-only';
|
|
803
877
|
}
|
|
804
878
|
return {
|
|
805
879
|
kind,
|
|
@@ -1095,6 +1169,9 @@ function carriedEvidenceLedger(fresh, current) {
|
|
|
1095
1169
|
// too — dropping a blocker on re-key would resume the exact loop it recorded.
|
|
1096
1170
|
failedVerificationStreak: current.failedVerificationStreak || null,
|
|
1097
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,
|
|
1098
1175
|
// The bug-fix floor bank is monotone request-scoped evidence — a same-prompt
|
|
1099
1176
|
// re-key must keep it or the gate would re-demand repro receipts mid-request.
|
|
1100
1177
|
bugFixFloor: current.bugFixFloor || fresh.bugFixFloor || null,
|
|
@@ -1147,6 +1224,12 @@ function freshLedger(payload, routeState, harness) {
|
|
|
1147
1224
|
// TASK-008 (BL-010): latest-per-class playbook-finding statuses, banked
|
|
1148
1225
|
// outside the evictable receipt window.
|
|
1149
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,
|
|
1150
1233
|
sourceSucceeded: false,
|
|
1151
1234
|
sourceFiles: [],
|
|
1152
1235
|
writeAttempted: false,
|
|
@@ -1571,6 +1654,207 @@ async function drainJournal(target, { ledger, payload, signal, deadlineMs }) {
|
|
|
1571
1654
|
return { ledger: next, applied, quarantined: malformed.length, commit };
|
|
1572
1655
|
}
|
|
1573
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
|
+
|
|
1574
1858
|
// --- TASK-009 (BL-011): bug-fix completion floor --------------------------------
|
|
1575
1859
|
// A green verdict on a `playbookId: bug-fix` route requires verbatim
|
|
1576
1860
|
// `repro-before` (failing) + `repro-after` (passing) evidence receipts on the
|
|
@@ -1859,7 +2143,16 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
|
|
|
1859
2143
|
// ones are kept in a bounded history so "one rebuild restores currency" has data.
|
|
1860
2144
|
if (receipt.verdict && typeof receipt.verdict === 'object') {
|
|
1861
2145
|
const history = Array.isArray(next.verdictHistory) ? next.verdictHistory : [];
|
|
1862
|
-
|
|
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;
|
|
1863
2156
|
next.verdict = receipt.verdict;
|
|
1864
2157
|
}
|
|
1865
2158
|
|
|
@@ -1955,6 +2248,12 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
|
|
|
1955
2248
|
}
|
|
1956
2249
|
|
|
1957
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
|
+
}
|
|
1958
2257
|
next.updatedAt = Date.now();
|
|
1959
2258
|
return { ledger: next, value: null };
|
|
1960
2259
|
}
|
|
@@ -2148,6 +2447,17 @@ export async function recordLedgerEvent(event, {
|
|
|
2148
2447
|
if (state && ledger.playbookId !== playbookId) {
|
|
2149
2448
|
ledger = { ...ledger, playbookId };
|
|
2150
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
|
+
}
|
|
2151
2461
|
const applied = applyReceiptToLedger(ledger, event.receipt, { vibecode: event.vibecode === true });
|
|
2152
2462
|
if (!applied) return { committed: false, eventId, reason: 'invalid-receipt' };
|
|
2153
2463
|
ledger = applied.ledger;
|
|
@@ -2669,6 +2979,12 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
|
|
|
2669
2979
|
}
|
|
2670
2980
|
}
|
|
2671
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
|
+
}
|
|
2672
2988
|
if (!instruction && ledger.verificationFailed) {
|
|
2673
2989
|
instruction = 'Use the latest failed verification output, fix the failure, and rerun targeted verification.';
|
|
2674
2990
|
} else if (!instruction) {
|
|
@@ -2715,7 +3031,71 @@ function writeEvidenceTier(ledger = {}) {
|
|
|
2715
3031
|
return ledger.inferredWrite === true && ledger.directWrite !== true ? { evidenceTier: 'inferred' } : {};
|
|
2716
3032
|
}
|
|
2717
3033
|
|
|
2718
|
-
|
|
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 } = {}) {
|
|
2719
3099
|
const routeSummary = state?.routeSummary || {};
|
|
2720
3100
|
// Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
|
|
2721
3101
|
// so the completion gate keeps pushing instead of releasing at the continuation cap.
|
|
@@ -2782,10 +3162,7 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
|
|
|
2782
3162
|
// Request identity is the prompt (see evidencePromptKey): the router re-keys requestKey on
|
|
2783
3163
|
// every Edit/Write, so a requestKey mismatch alone must not discard the evidence — and the
|
|
2784
3164
|
// continuation budget — the request already accumulated. A different prompt keeps a clean slate.
|
|
2785
|
-
const
|
|
2786
|
-
|| ledger.requestKey === state.requestKey
|
|
2787
|
-
|| (ledger.promptKey && evidencePromptKey(state) === ledger.promptKey);
|
|
2788
|
-
const effectiveLedger = sameRequest ? ledger : {};
|
|
3165
|
+
const effectiveLedger = effectiveRequestLedger(state, ledger);
|
|
2789
3166
|
const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, state, { cwd }));
|
|
2790
3167
|
// TASK-009 (BL-011): the bug-fix floor. On a `playbookId: bug-fix` route a
|
|
2791
3168
|
// green verdict requires verbatim repro-before (fail) + repro-after (pass)
|
|
@@ -2833,6 +3210,9 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
|
|
|
2833
3210
|
return { continue: false, notify: false, complete: true, missingEvidence: [], ...writeEvidenceTier(effectiveLedger) };
|
|
2834
3211
|
}
|
|
2835
3212
|
|
|
3213
|
+
const released = phaseRelease(phase, effectiveLedger, missingEvidence);
|
|
3214
|
+
if (released) return released;
|
|
3215
|
+
|
|
2836
3216
|
// `find-cause` can validly end clean: an investigation may establish that no actionable
|
|
2837
3217
|
// defect exists. Its contract says a FIX cannot be claimed without write + verification;
|
|
2838
3218
|
// it does not make a mutation mandatory. Treating missing evidence as an unconditional
|
|
@@ -2917,12 +3297,17 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
|
|
|
2917
3297
|
|
|
2918
3298
|
const gated = IMPLEMENT_MODES.has(mode);
|
|
2919
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');
|
|
2920
3302
|
return {
|
|
2921
3303
|
continue: false,
|
|
2922
3304
|
notify: true,
|
|
2923
3305
|
missingEvidence,
|
|
3306
|
+
...(noWriteAdvisory ? { gateRelease: 'advisory-no-write' } : {}),
|
|
2924
3307
|
...(playbookId === BUGFIX_PLAYBOOK_ID ? { outcome: 'inconclusive' } : {}),
|
|
2925
|
-
reason:
|
|
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.`,
|
|
2926
3311
|
};
|
|
2927
3312
|
}
|
|
2928
3313
|
|
|
@@ -3005,6 +3390,34 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
|
|
|
3005
3390
|
};
|
|
3006
3391
|
}
|
|
3007
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
|
+
|
|
3008
3421
|
export async function incrementContinuation(projectRoot, payload = {}, ledger = null, requestKey = null, promptKey = null, { signal, deadlineMs } = {}) {
|
|
3009
3422
|
// Locked (and fail-closed) via recordLedgerEvent: parallel subagents firing Stop hooks
|
|
3010
3423
|
// share one ledger file, and a stale-snapshot rewrite would reset each other's
|
|
@@ -3135,7 +3548,8 @@ async function runEvaluateStop() {
|
|
|
3135
3548
|
return;
|
|
3136
3549
|
}
|
|
3137
3550
|
|
|
3138
|
-
const
|
|
3551
|
+
const phase = await resolveStopPhase(payload?.transcript_path);
|
|
3552
|
+
const result = evaluateCompletion({ state, ledger, cwd: projectRoot, phase });
|
|
3139
3553
|
|
|
3140
3554
|
// TASK-C85-019 fix C: a mutating-mode route released by a no-mutation valve
|
|
3141
3555
|
// while evidence is missing is the anomaly signal for misrouted advisory
|
|
@@ -3217,12 +3631,21 @@ async function runEvaluateStop() {
|
|
|
3217
3631
|
if (result.capped || result.notify) {
|
|
3218
3632
|
// Non-blocking endings (non-gated modes, an invisible blocker, an evidence-less route, or
|
|
3219
3633
|
// cap reached after the final notice) must still tell the user what is unfinished — a
|
|
3220
|
-
// 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;
|
|
3221
3637
|
process.stderr.write(`[ukit-completion] ${result.reason}\n`);
|
|
3222
|
-
process.stdout.write(`${JSON.stringify({ systemMessage:
|
|
3638
|
+
process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
|
|
3223
3639
|
return;
|
|
3224
3640
|
}
|
|
3225
|
-
if (result.complete)
|
|
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
|
+
}
|
|
3226
3649
|
// Defensive final else: no evaluation result may ever end a routed stop silently.
|
|
3227
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.`;
|
|
3228
3651
|
process.stderr.write(`[ukit-completion] ${fallback}\n`);
|
|
@@ -3238,6 +3661,14 @@ async function main() {
|
|
|
3238
3661
|
}
|
|
3239
3662
|
const payload = JSON.parse((await readStdin()) || '{}');
|
|
3240
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
|
+
}
|
|
3241
3672
|
if (process.argv.includes('--record')) {
|
|
3242
3673
|
const receiptResult = await recordExecutionReceipt({
|
|
3243
3674
|
projectRoot,
|