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