@ngockhoale/ukit 3.4.47 → 3.4.49
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 +15 -0
- package/manifests/documentation.yaml +22 -0
- package/manifests/platform.full.yaml +27 -0
- package/package.json +4 -1
- package/scripts/dev/worktree-preflight.mjs +20 -1
- package/src/core/diffPlan.js +38 -12
- package/src/core/executionContracts.js +79 -18
- package/src/core/metadata.js +4 -0
- package/src/core/runInstallPipeline.js +19 -2
- package/src/core/runtimeConfig.js +1 -1
- package/src/index/playbookRegistry.js +22 -3
- package/src/index/taskRouting.js +2 -2
- package/template_project/.claude/agents/bug-debugger.md +1 -0
- package/template_project/.claude/agents/code-reviewer.md +1 -0
- package/template_project/.claude/agents/feature-implementer.md +1 -0
- package/template_project/.claude/agents/handoff-planner.md +1 -0
- package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.claude/hooks/completion-gate.sh +1 -1
- package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
- package/template_project/.claude/hooks/skill-router.sh +159 -8
- package/template_project/.claude/settings.json +8 -8
- package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
- package/template_project/.claude/ukit/index/route-resolver.mjs +79 -17
- package/template_project/.claude/ukit/index/route-task.mjs +115 -37
- package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +331 -21
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
- package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +79 -3
- package/template_project/.omp/agents/bug-debugger.md +1 -0
- package/template_project/.omp/agents/code-reviewer.md +1 -0
- package/template_project/.omp/agents/feature-implementer.md +1 -0
- package/template_project/.omp/agents/handoff-planner.md +1 -0
- package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
- package/template_project/ukit/storage/config.json +1 -1
- package/scripts/bench/data-foundation.mjs +0 -929
- package/scripts/bench/decision-runtime-baseline.mjs +0 -427
- package/scripts/bench/decision-runtime-variant.mjs +0 -626
- package/scripts/bench/goldTasks.json +0 -38
- package/scripts/bench/memory-ablation.mjs +0 -495
- package/scripts/bench/memory-baseline.mjs +0 -596
- package/scripts/bench/memory-bench.mjs +0 -661
- package/scripts/bench/memory-canary.mjs +0 -331
- package/scripts/bench/memory-corpus.mjs +0 -354
- package/scripts/bench/memory-gate.mjs +0 -389
- package/scripts/bench/memory-metrics.mjs +0 -179
- package/scripts/bench/outline-savings.mjs +0 -107
- package/scripts/bench/parallel-agents.mjs +0 -224
- package/scripts/bench/recorder-overhead.mjs +0 -204
- package/scripts/bench/runGold.mjs +0 -238
- package/scripts/bench/sqlite-spike.mjs +0 -451
- package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
- package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
- package/scripts/bench/v3-ceremony.mjs +0 -220
- package/scripts/perf/audit-perf.mjs +0 -1195
- package/scripts/perf/diff-perf-findings.mjs +0 -136
- package/scripts/perf/perf-findings.json +0 -474
- package/scripts/perf/perf-measure.md +0 -271
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { withAsyncLock, LOCK_MAX_SLICE_MS } from './async-lock.mjs';
|
|
10
10
|
import { cronOperationReceipt, bashOperationReceipt, releaseOperationReceipt } from './cron-operation-adapter.mjs';
|
|
11
|
+
import * as playbookExecution from './playbook-execution.mjs';
|
|
11
12
|
import { operationCompletionPolicy, foldOperationEvidence, compactOperation, conditionalAbsenceGoal } from './operation-evidence.mjs';
|
|
12
13
|
|
|
13
14
|
// Hook-context self-deadline (2.4.1 orphan-leak class): armed ONLY on direct
|
|
@@ -659,7 +660,7 @@ function compactReceipt(receipt) {
|
|
|
659
660
|
kind: receipt.kind,
|
|
660
661
|
success: receipt.success,
|
|
661
662
|
};
|
|
662
|
-
for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'exitStatusUnproven']) {
|
|
663
|
+
for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'exitStatusUnproven', 'agentId', 'agentType', 'contentSha']) {
|
|
663
664
|
if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
|
|
664
665
|
compact[key] = key === 'operation' ? compactOperation(receipt[key]) : receipt[key];
|
|
665
666
|
}
|
|
@@ -774,6 +775,31 @@ function fingerprintExcluded(file) {
|
|
|
774
775
|
|| ['docs/WORKLOG.md', 'docs/STATUS.md', 'docs/TASKS.md', 'docs/MEMORY.md'].includes(p);
|
|
775
776
|
}
|
|
776
777
|
|
|
778
|
+
// A write to UKit's own bookkeeping (living docs, runtime state) is housekeeping the project rules
|
|
779
|
+
// REQUIRE after every task; it is never the implementation an implementation contract asks for.
|
|
780
|
+
// `bash-workspace:` is the prefix of fingerprint-derived Bash write receipts.
|
|
781
|
+
function isBookkeepingWrite(file) {
|
|
782
|
+
const p = String(file ?? '').replace(/^bash-workspace:/, '').replace(/\\/g, '/');
|
|
783
|
+
return /(^|\/)docs\/(WORKLOG|STATUS|TASKS|MEMORY)\.md$/.test(p)
|
|
784
|
+
|| /(^|\/)\.ukit\/storage\//.test(p)
|
|
785
|
+
|| /(^|\/)\.claude\/ukit\/skill-router-state\.json$/.test(p);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// A bookkeeping write counts as the requested write only when the user's own request names that file.
|
|
789
|
+
function promptNamesBookkeeping(state) {
|
|
790
|
+
const prompt = String(state?.routingContext?.lastExplicitUserPromptText ?? state?.routingContext?.promptText ?? '');
|
|
791
|
+
return /\b(WORKLOG|STATUS|TASKS|MEMORY)\.md\b|\.ukit\/storage|skill-router-state/i.test(prompt);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Per-file form: naming STATUS.md does not make a WORKLOG.md edit the requested write.
|
|
795
|
+
function promptNamesBookkeepingFile(state, file) {
|
|
796
|
+
const p = String(file ?? '').replace(/^bash-workspace:/, '').replace(/\\/g, '/');
|
|
797
|
+
const prompt = String(state?.routingContext?.lastExplicitUserPromptText ?? state?.routingContext?.promptText ?? '');
|
|
798
|
+
const doc = p.match(/(?:^|\/)docs\/(WORKLOG|STATUS|TASKS|MEMORY)\.md$/);
|
|
799
|
+
if (doc) return new RegExp(`\\b${doc[1]}\\.md\\b`, 'i').test(prompt);
|
|
800
|
+
return promptNamesBookkeeping(state);
|
|
801
|
+
}
|
|
802
|
+
|
|
777
803
|
function contentFingerprint(cwd) {
|
|
778
804
|
if (!cwd) return null;
|
|
779
805
|
try {
|
|
@@ -1246,6 +1272,10 @@ function carriedEvidenceLedger(fresh, current) {
|
|
|
1246
1272
|
directWrite: fresh.directWrite === true || current.directWrite === true,
|
|
1247
1273
|
writeAttempted: fresh.writeAttempted || current.writeAttempted === true,
|
|
1248
1274
|
writeSucceeded: fresh.writeSucceeded || current.writeSucceeded === true,
|
|
1275
|
+
// undefined (a ledger written before this field existed) keeps the legacy any-write behavior.
|
|
1276
|
+
substantiveWrite: fresh.substantiveWrite === true || current.substantiveWrite === true
|
|
1277
|
+
? true
|
|
1278
|
+
: (current.substantiveWrite === undefined && current.writeSucceeded === true ? undefined : false),
|
|
1249
1279
|
verificationAttempted: fresh.verificationAttempted || current.verificationAttempted === true,
|
|
1250
1280
|
verificationSucceeded: fresh.verificationSucceeded || current.verificationSucceeded === true,
|
|
1251
1281
|
verificationFailed: fresh.verificationFailed || current.verificationFailed === true,
|
|
@@ -1287,6 +1317,10 @@ function carriedEvidenceLedger(fresh, current) {
|
|
|
1287
1317
|
// TASK-008: the playbook-finding bank is request-scoped like the floor —
|
|
1288
1318
|
// a same-prompt re-key must keep the latest per-class statuses.
|
|
1289
1319
|
playbookFindings: current.playbookFindings || fresh.playbookFindings || null,
|
|
1320
|
+
// Round 5: the delegated-run state is request-scoped like the floor (this carry is
|
|
1321
|
+
// reached only for the SAME taskInstanceId / promptKey — never across tasks).
|
|
1322
|
+
playbookRun: current.playbookRun || fresh.playbookRun || null,
|
|
1323
|
+
delegationViolations: current.delegationViolations || fresh.delegationViolations || null,
|
|
1290
1324
|
verificationFailureCounts: current.verificationFailureCounts || {},
|
|
1291
1325
|
blocker: current.blocker || null,
|
|
1292
1326
|
// Journal bookkeeping belongs to the file, not the request: the dedupe memory must
|
|
@@ -1344,6 +1378,7 @@ function freshLedger(payload, routeState, harness) {
|
|
|
1344
1378
|
sourceFiles: [],
|
|
1345
1379
|
writeAttempted: false,
|
|
1346
1380
|
writeSucceeded: false,
|
|
1381
|
+
substantiveWrite: false,
|
|
1347
1382
|
verificationAttempted: false,
|
|
1348
1383
|
verificationSucceeded: false,
|
|
1349
1384
|
verificationFailed: false,
|
|
@@ -1390,7 +1425,7 @@ const MAX_JOURNAL_SEEN = 64;
|
|
|
1390
1425
|
const MAX_JOURNAL_RECORDS = 128;
|
|
1391
1426
|
const MAX_JOURNAL_QUARANTINE_LINES = 32;
|
|
1392
1427
|
const LEDGER_EVENT_TYPES = new Set(['receipt', 'continuation', 'notified', 'stop-progress', 'resumable-run']);
|
|
1393
|
-
const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace', 'operation']);
|
|
1428
|
+
const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace', 'operation', 'handback', 'review', 'delegation-violation']);
|
|
1394
1429
|
|
|
1395
1430
|
function journalPathFor(target) {
|
|
1396
1431
|
return `${target}.journal`;
|
|
@@ -1511,11 +1546,27 @@ async function bumpContinuationSidecar(target, event, { signal, deadlineMs } = {
|
|
|
1511
1546
|
return outcome.ok === true ? outcome.value : { ok: false };
|
|
1512
1547
|
}
|
|
1513
1548
|
|
|
1549
|
+
// Receipts that carry task-owned semantics: they must belong to the live task or be
|
|
1550
|
+
// rejected (operation proof; Round 5 worker handbacks and review decisions).
|
|
1551
|
+
const TASK_OWNED_RECEIPT_KINDS = new Set(['operation', 'handback', 'review', 'delegation-violation']);
|
|
1552
|
+
|
|
1553
|
+
// Bounded content digest of a file a worker wrote (change fingerprint input). Null on any
|
|
1554
|
+
// read failure or a file over the cap — a missing digest only weakens, never fabricates.
|
|
1555
|
+
function fileContentSha(file) {
|
|
1556
|
+
try {
|
|
1557
|
+
const stat = fsSync.statSync(file);
|
|
1558
|
+
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return null;
|
|
1559
|
+
return crypto.createHash('sha256').update(fsSync.readFileSync(file)).digest('hex').slice(0, 16);
|
|
1560
|
+
} catch {
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1514
1565
|
function newEventId() {
|
|
1515
1566
|
return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
|
|
1516
1567
|
}
|
|
1517
1568
|
|
|
1518
|
-
const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence'];
|
|
1569
|
+
const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'agentId', 'agentType', 'contentSha', 'handback', 'review', 'verdict'];
|
|
1519
1570
|
|
|
1520
1571
|
// TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
|
|
1521
1572
|
// surface / result / claim, the ARCH §Evidence Schema fields a receipt may
|
|
@@ -1535,11 +1586,29 @@ function sanitizeEvidenceForJournal(evidence) {
|
|
|
1535
1586
|
return Object.keys(clean).length > 0 ? clean : null;
|
|
1536
1587
|
}
|
|
1537
1588
|
|
|
1589
|
+
// The verification verdict is minted from LIVE git/workspace state at record time and cannot be
|
|
1590
|
+
// re-minted at replay (the state may have moved on), so it travels with the journaled receipt.
|
|
1591
|
+
// Fixed keys, bounded strings; an unknown kind is dropped (replay then stays pending, fail-safe).
|
|
1592
|
+
// Currency is still judged by verdictCurrent at assessment time, so a replayed stale verdict is
|
|
1593
|
+
// rejected exactly like a live one.
|
|
1594
|
+
const JOURNAL_VERDICT_FIELDS = ['workspaceRoot', 'evidence', 'verifier', 'headSha', 'baseSha', 'patchId', 'dirtyFp', 'contentFp', 'ts'];
|
|
1595
|
+
function sanitizeVerdictForJournal(verdict) {
|
|
1596
|
+
if (!verdict || typeof verdict !== 'object' || Array.isArray(verdict) || !VERDICT_KINDS.has(verdict.kind)) return null;
|
|
1597
|
+
const clean = { kind: verdict.kind };
|
|
1598
|
+
for (const key of JOURNAL_VERDICT_FIELDS) {
|
|
1599
|
+
if (typeof verdict[key] === 'string' && verdict[key]) clean[key] = verdict[key].slice(0, 400);
|
|
1600
|
+
else if (verdict[key] === null && (key === 'headSha' || key === 'baseSha' || key === 'patchId')) clean[key] = null;
|
|
1601
|
+
}
|
|
1602
|
+
return clean;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1538
1605
|
function sanitizeReceiptForJournal(receipt) {
|
|
1539
1606
|
const clean = {};
|
|
1540
1607
|
for (const key of JOURNAL_RECEIPT_FIELDS) {
|
|
1541
1608
|
if (receipt && receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
|
|
1542
|
-
clean[key] = key === 'operation' ? compactOperation(receipt[key])
|
|
1609
|
+
clean[key] = key === 'operation' ? compactOperation(receipt[key])
|
|
1610
|
+
: key === 'verdict' ? sanitizeVerdictForJournal(receipt[key]) : receipt[key];
|
|
1611
|
+
if (clean[key] === null) delete clean[key];
|
|
1543
1612
|
}
|
|
1544
1613
|
}
|
|
1545
1614
|
const evidence = sanitizeEvidenceForJournal(receipt && receipt.evidence);
|
|
@@ -1815,11 +1884,9 @@ const PLAYBOOK_STEP_MAPS = {
|
|
|
1815
1884
|
{ id: 'fix', derive: 'write', essential: true },
|
|
1816
1885
|
{ id: 'verify', derive: 'post-verify', essential: true },
|
|
1817
1886
|
],
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
{ id: 'verify', derive: 'post-verify', essential: true },
|
|
1822
|
-
],
|
|
1887
|
+
// Round 5: the step contract lives once, in playbook-execution.mjs; conditional steps
|
|
1888
|
+
// (integrate-verify / review) are filtered out of the unconditional trace below.
|
|
1889
|
+
'feature-implementation': playbookExecution.FEATURE_IMPLEMENTATION_STEPS,
|
|
1823
1890
|
migration: [
|
|
1824
1891
|
{ id: 'schema-inspect', derive: 'pre-source', essential: false },
|
|
1825
1892
|
{ id: 'write', derive: 'write', essential: true },
|
|
@@ -1829,7 +1896,7 @@ const PLAYBOOK_STEP_MAPS = {
|
|
|
1829
1896
|
|
|
1830
1897
|
function playbookStepsFor(playbookId) {
|
|
1831
1898
|
const steps = playbookId ? PLAYBOOK_STEP_MAPS[String(playbookId)] : null;
|
|
1832
|
-
return Array.isArray(steps) ? steps : [];
|
|
1899
|
+
return Array.isArray(steps) ? steps.filter((step) => !step.conditional) : [];
|
|
1833
1900
|
}
|
|
1834
1901
|
|
|
1835
1902
|
function boundedTraceText(value) {
|
|
@@ -1975,6 +2042,9 @@ export async function playbookStatus(projectRoot, payload = {}) {
|
|
|
1975
2042
|
taskType: ledger?.taskType || state?.routeSummary?.taskType || null,
|
|
1976
2043
|
trace,
|
|
1977
2044
|
summary: formatPlaybookTrace(trace),
|
|
2045
|
+
...(playbookId === playbookExecution.FEATURE_PLAYBOOK_ID
|
|
2046
|
+
? { acceptance: evaluateFeatureAcceptance({ state: state || {}, ledger, cwd: projectRoot }) }
|
|
2047
|
+
: {}),
|
|
1978
2048
|
};
|
|
1979
2049
|
}
|
|
1980
2050
|
|
|
@@ -2210,6 +2280,7 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
|
|
|
2210
2280
|
exitCode: receipt.exitCode,
|
|
2211
2281
|
file: `bash-workspace:${fp}`,
|
|
2212
2282
|
detail: 'bash-workspace-delta',
|
|
2283
|
+
...(receipt.agentId ? { agentId: receipt.agentId, agentType: receipt.agentType } : {}),
|
|
2213
2284
|
}, options);
|
|
2214
2285
|
if (applied) base = applied.ledger;
|
|
2215
2286
|
}
|
|
@@ -2221,6 +2292,25 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
|
|
|
2221
2292
|
function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
|
|
2222
2293
|
if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
|
|
2223
2294
|
const next = { ...ledger, receiptSequence: Number(ledger.receiptSequence || 0) + 1 };
|
|
2295
|
+
if (receipt.kind === 'delegation-violation') {
|
|
2296
|
+
// delegation-gate.mjs refused a main-thread source write while delegation is required and not
|
|
2297
|
+
// performed. Counted per task ledger (a new task starts from a fresh ledger); the limit-th one
|
|
2298
|
+
// mints the visible sticky blocker the Stop gate ends the run on. Never enters the receipt
|
|
2299
|
+
// window and never clears or replaces an existing blocker.
|
|
2300
|
+
const count = Number(ledger.delegationViolations?.count || 0) + 1;
|
|
2301
|
+
const blocked = count >= playbookExecution.DELEGATION_VIOLATION_LIMIT;
|
|
2302
|
+
next.delegationViolations = { count, blocked, lastTs: receipt.ts ?? Date.now(), lastTarget: receipt.target ?? null };
|
|
2303
|
+
if (blocked && !next.blocker) {
|
|
2304
|
+
next.blocker = {
|
|
2305
|
+
kind: 'delegation-required',
|
|
2306
|
+
visible: true,
|
|
2307
|
+
detail: `the feature-implementation Playbook requires the coding to be delegated to the Tier 2 worker, but the orchestrator tried to write source ${count} times without dispatching one (delegation-required-not-performed) — dispatch the worker via the Agent tool or report this blocker.`,
|
|
2308
|
+
ts: Date.now(),
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
next.updatedAt = Date.now();
|
|
2312
|
+
return { ledger: next, value: null };
|
|
2313
|
+
}
|
|
2224
2314
|
if (receipt.kind === 'operation') receipt = { ...receipt, sequence: next.receiptSequence };
|
|
2225
2315
|
if (receipt.kind === 'operation') {
|
|
2226
2316
|
next.operationBank = foldOperationEvidence(ledger.operationBank, receipt, ledger);
|
|
@@ -2235,6 +2325,8 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
|
|
|
2235
2325
|
} else if (receipt.kind === 'write') {
|
|
2236
2326
|
next.writeAttempted = true;
|
|
2237
2327
|
next.writeSucceeded = next.writeSucceeded || receipt.success;
|
|
2328
|
+
if (receipt.success && receipt.file && !isBookkeepingWrite(receipt.file)) next.substantiveWrite = true;
|
|
2329
|
+
else if (next.substantiveWrite === undefined) next.substantiveWrite = false;
|
|
2238
2330
|
// Evidence tier: a fingerprint-derived Bash delta is inferred (supporting) evidence; only a
|
|
2239
2331
|
// tool-attributed Edit/Write is direct.
|
|
2240
2332
|
if (receipt.detail === 'bash-workspace-delta') next.inferredWrite = true;
|
|
@@ -2361,6 +2453,22 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
|
|
|
2361
2453
|
next.updatedAt = Date.now();
|
|
2362
2454
|
return { ledger: next, value: null };
|
|
2363
2455
|
}
|
|
2456
|
+
// Round 5: feature-implementation run state (worker attribution, change barrier,
|
|
2457
|
+
// integration verification, handbacks, review decision). Banked outside the evictable
|
|
2458
|
+
// receipt window; handback/review receipts live only here and never enter the window.
|
|
2459
|
+
if (ledger?.playbookId === playbookExecution.FEATURE_PLAYBOOK_ID) {
|
|
2460
|
+
next.playbookRun = playbookExecution.foldPlaybookRun(ledger.playbookRun, receipt, {
|
|
2461
|
+
seq: next.receiptSequence,
|
|
2462
|
+
bookkeeping: receipt.kind === 'write' && isBookkeepingWrite(receipt.file),
|
|
2463
|
+
});
|
|
2464
|
+
if (receipt.kind === 'handback' || receipt.kind === 'review') {
|
|
2465
|
+
next.updatedAt = Date.now();
|
|
2466
|
+
return { ledger: next, value: null };
|
|
2467
|
+
}
|
|
2468
|
+
} else if (receipt.kind === 'handback' || receipt.kind === 'review') {
|
|
2469
|
+
// Not a feature-implementation run: these kinds mean nothing here.
|
|
2470
|
+
return { ledger: next, value: null };
|
|
2471
|
+
}
|
|
2364
2472
|
// TASK-009 (BL-011): fold the receipt into the bug-fix floor bank before it
|
|
2365
2473
|
// enters the (evictable) receipt window. The fold runs on the pre-update
|
|
2366
2474
|
// verificationFailureCounts: a pass over a surface whose failure is still
|
|
@@ -2558,7 +2666,7 @@ export async function recordLedgerEvent(event, {
|
|
|
2558
2666
|
// Hoisted so the post-commit outcome emitter sees the same state snapshot
|
|
2559
2667
|
// the lock callback evaluated (a fresh read could observe a re-route).
|
|
2560
2668
|
let lockedRouteState = routeState || await readRouteState(projectRoot, payload);
|
|
2561
|
-
if (event.type === 'receipt' && event.receipt?.kind
|
|
2669
|
+
if (event.type === 'receipt' && TASK_OWNED_RECEIPT_KINDS.has(event.receipt?.kind)) {
|
|
2562
2670
|
if (event.receipt.taskInstanceId && event.receipt.taskInstanceId !== lockedRouteState?.taskInstanceId) {
|
|
2563
2671
|
return { rejected: true, eventId, reason: 'receipt-task-mismatch' };
|
|
2564
2672
|
}
|
|
@@ -2606,7 +2714,7 @@ export async function recordLedgerEvent(event, {
|
|
|
2606
2714
|
if (event.receipt.taskInstanceId && event.receipt.taskInstanceId !== state?.taskInstanceId) {
|
|
2607
2715
|
return { committed: false, rejected: true, eventId, reason: 'receipt-task-mismatch' };
|
|
2608
2716
|
}
|
|
2609
|
-
const stampedReceipt = event.receipt.kind
|
|
2717
|
+
const stampedReceipt = TASK_OWNED_RECEIPT_KINDS.has(event.receipt.kind)
|
|
2610
2718
|
? { ...event.receipt, taskInstanceId: state?.taskInstanceId || null, sessionKey: sessionIdentity(payload) }
|
|
2611
2719
|
: event.receipt;
|
|
2612
2720
|
const applied = applyReceiptToLedger(ledger, stampedReceipt, { vibecode: event.vibecode === true });
|
|
@@ -2849,6 +2957,7 @@ export async function recordExecutionReceipt({
|
|
|
2849
2957
|
success,
|
|
2850
2958
|
exitCode,
|
|
2851
2959
|
};
|
|
2960
|
+
const featureRun = routeState?.routeSummary?.playbookId === playbookExecution.FEATURE_PLAYBOOK_ID;
|
|
2852
2961
|
|
|
2853
2962
|
const policy = routeState?.routeSummary?.executionContract?.releasePolicy;
|
|
2854
2963
|
if (toolName === 'Bash' && !bashScope.foreign && policy?.kind === 'conditional-release'
|
|
@@ -2913,10 +3022,38 @@ export async function recordExecutionReceipt({
|
|
|
2913
3022
|
// Any other Bash: no evidence of its own. The receipt only carries the workspace
|
|
2914
3023
|
// fingerprint so a verifiable tracked-file change can be attributed at apply time.
|
|
2915
3024
|
receipt.kind = 'workspace';
|
|
3025
|
+
} else if ((toolName === 'Agent' || toolName === 'Task') && featureRun) {
|
|
3026
|
+
// Round 5: a subagent dispatch result is a HANDBACK — a claim plus identity, never
|
|
3027
|
+
// evidence by itself. Only a feature-implementation route records it; every other
|
|
3028
|
+
// playbook keeps the old untracked rejection (their ledgers are unchanged).
|
|
3029
|
+
const handback = playbookExecution.buildHandbackRecord({ toolInput, toolResponse, projectRoot, success });
|
|
3030
|
+
if (!handback) return { rejected: true, eventId: null };
|
|
3031
|
+
receipt.kind = 'handback';
|
|
3032
|
+
// A prompt-carried task marker binds the result to ITS task: a mismatch with the live
|
|
3033
|
+
// task is rejected by the task-ownership check in recordLedgerEvent (receipt-task-mismatch).
|
|
3034
|
+
if (handback.markerTaskId) receipt.taskInstanceId = handback.markerTaskId;
|
|
3035
|
+
delete handback.markerTaskId;
|
|
3036
|
+
receipt.handback = handback;
|
|
3037
|
+
if (handback.agentId) { receipt.agentId = handback.agentId; receipt.agentType = handback.agentType; }
|
|
2916
3038
|
} else {
|
|
2917
3039
|
// Untracked tool: no event, no lock, no write — same as the old unlocked early return.
|
|
2918
3040
|
return { rejected: true, eventId: null };
|
|
2919
3041
|
}
|
|
3042
|
+
// Round 5: attribute subagent tool receipts to their native agent identity (Claude Code
|
|
3043
|
+
// stamps agent_id/agent_type on a subagent's hook payloads). Hosts without it leave the
|
|
3044
|
+
// receipt unattributed — the run then reports UNSUPPORTED instead of guessing.
|
|
3045
|
+
if (featureRun && receipt.kind !== 'handback') {
|
|
3046
|
+
const agentId = playbookExecution.sanitizeAgentId(payload.agent_id);
|
|
3047
|
+
if (agentId) {
|
|
3048
|
+
receipt.agentId = agentId;
|
|
3049
|
+
const agentType = typeof payload.agent_type === 'string' ? payload.agent_type.slice(0, 64) : null;
|
|
3050
|
+
if (agentType) receipt.agentType = agentType;
|
|
3051
|
+
if (receipt.kind === 'write' && receipt.success && typeof receipt.file === 'string') {
|
|
3052
|
+
const digest = fileContentSha(path.resolve(projectRoot, receipt.file));
|
|
3053
|
+
if (digest) receipt.contentSha = digest;
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
2920
3057
|
if (workspaceFp) {
|
|
2921
3058
|
receipt.workspaceFp = workspaceFp;
|
|
2922
3059
|
if (workspaceRoot !== projectRoot) {
|
|
@@ -3040,7 +3177,13 @@ function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
|
|
|
3040
3177
|
|| (cwd && bank[evidenceBankKey(path.resolve(cwd, file))] === true)
|
|
3041
3178
|
));
|
|
3042
3179
|
}
|
|
3043
|
-
|
|
3180
|
+
// A bookkeeping-only write (WORKLOG/STATUS/TASKS/MEMORY, runtime state) is not the implementation:
|
|
3181
|
+
// it satisfies write-evidence only when the request itself names that file.
|
|
3182
|
+
if (ledger.writeSucceeded !== true) return false;
|
|
3183
|
+
if (ledger.substantiveWrite !== false) return true;
|
|
3184
|
+
// Only bookkeeping was written: each written file must itself be named by the request.
|
|
3185
|
+
const written = (ledger.receipts || []).filter((r) => r?.kind === 'write' && r.success && r.file).map((r) => r.file);
|
|
3186
|
+
return written.length ? written.some((file) => promptNamesBookkeepingFile(state, file)) : promptNamesBookkeeping(state);
|
|
3044
3187
|
}
|
|
3045
3188
|
if (evidence === 'verification-evidence') {
|
|
3046
3189
|
// SPEC-typed-verdicts §2.3: when a verdict record exists it governs — only a
|
|
@@ -3154,6 +3297,9 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
|
|
|
3154
3297
|
} else if (missingEvidence.includes('root-cause')) {
|
|
3155
3298
|
instruction = 'Record the root cause: attest the named mechanism (UKIT_EVIDENCE root-cause/instrumentation/behavioral-check with claim) — the rejected hypotheses and the surviving mechanism, one line each.';
|
|
3156
3299
|
}
|
|
3300
|
+
if (!instruction && missingEvidence.includes('integration-verification') && !missingEvidence.includes('write-evidence')) {
|
|
3301
|
+
instruction = 'Worker changes are not yet verified together: in this session (not inside a worker), run the routed targeted verification once on the integrated state, after the last worker handback, and inspect its result. A worker\'s own pass does not count.';
|
|
3302
|
+
}
|
|
3157
3303
|
if (!instruction && missingEvidence.includes('write-evidence')) {
|
|
3158
3304
|
if (!ledger.sourceSucceeded) {
|
|
3159
3305
|
instruction = 'Pull one bounded indexed source slice, then make the requested Edit/Write in this continuation.';
|
|
@@ -3343,16 +3489,75 @@ function effectiveRequestLedger(state = {}, ledger = {}) {
|
|
|
3343
3489
|
// since (publishedReleaseDrifted). A failed/missing publish with no registry proof
|
|
3344
3490
|
// stays missing. A registry that already serves the version counts as published, so a finished release is
|
|
3345
3491
|
// never re-published just to satisfy the gate.
|
|
3346
|
-
|
|
3492
|
+
const PUBLISH_ORDER_NEGATOR = /\b(?:do not|don't|dont|never|without|not yet|no|not|avoid|skip|exclud\w*|forbid\w*|prohibit\w*|khong|dung|chua)\b|--dry-run/;
|
|
3493
|
+
const PUBLISH_ORDER_IMPERATIVE = /^(?:please\s+|just\s+|now\s+|also\s+)*(?:run|do|execute|perform|go|ship|publish|release|push|tag|commit|verify|make|cut|chay)\b/;
|
|
3494
|
+
const PUBLISH_ORDER_HEADER = /prohibit|forbidden|out of scope|non-goal|scope control/;
|
|
3495
|
+
// Release intent comes ONLY from the user's own affirmative order. A prohibition list ("No release, npm publish or CI.",
|
|
3496
|
+
// "Do not: ... npm publish") keeps its negation across comma/and/or list items and under a prohibition header; only a
|
|
3497
|
+
// explicit command can end a negated non-release clause, but bare list verbs cannot. Explicit publication
|
|
3498
|
+
// prohibitions win over positive references elsewhere. Only the task's authoritative prompt is consulted.
|
|
3499
|
+
export function promptOrdersNpmPublish(state = {}) {
|
|
3347
3500
|
const text = String(state?.routingContext?.lastExplicitUserPromptText || '')
|
|
3348
3501
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3502
|
+
// Quoted commands and historical/example blocks describe orders; they do not issue them.
|
|
3503
|
+
const active = text.replace(/```[^]*?```|~~~[^]*?~~~/g, '')
|
|
3504
|
+
.replace(/^[ \t]*>.*$/gm, '').replace(/[“"][^”"\n]*[”"]|‘[^’\n]*’/g, '');
|
|
3505
|
+
let prohibited = false;
|
|
3506
|
+
let ordered = false;
|
|
3507
|
+
let blockNegated = false;
|
|
3508
|
+
for (const line of active.split('\n')) {
|
|
3509
|
+
const bullet = /^\s*(?:[-*\u2022]|\d+[.)])\s+/.test(line);
|
|
3510
|
+
if (!line.trim()) { blockNegated = false; continue; }
|
|
3511
|
+
if (!bullet) blockNegated = line.trim().length <= 80 && (PUBLISH_ORDER_NEGATOR.test(line) || PUBLISH_ORDER_HEADER.test(line)) && /:\s*$|^[^.]{0,40}$/.test(line.trim()) && !/\bnpm\s+publish\b/.test(line);
|
|
3512
|
+
for (const sentence of line.split(/\s*;\s*|\.\s+|\.$/)) {
|
|
3513
|
+
for (const hard of sentence.split(/\bthen\b|\bbut\b|\binstead\b/)) {
|
|
3514
|
+
let negated = bullet && blockNegated;
|
|
3515
|
+
for (const raw of hard.split(/,|\band\b|\bor\b|\bnor\b/)) {
|
|
3516
|
+
const item = raw.replace(/^[\s\-*\u2022]+|^\d+[.)]\s*/g, '').trim();
|
|
3517
|
+
if (PUBLISH_ORDER_NEGATOR.test(item)) negated = true;
|
|
3518
|
+
// Bare list nouns (push, tag, commit) must not revoke an earlier "No".
|
|
3519
|
+
else if (/^(?:please\s+|now\s+|also\s+)*(?:run|execute|perform|chay)\s+\S/.test(item)) negated = false;
|
|
3520
|
+
const publication = /\bnpm\s+publish\b/.test(item);
|
|
3521
|
+
const releaseOrder = /^(?:please\s+|just\s+|now\s+|also\s+)*(?:release|ship|publish)\s+(?!readiness\b|verification\b|evidence\b|policy\b|intent\b|notes\b|terminology\b)\S/.test(item);
|
|
3522
|
+
const releaseReference = /\b(?:release|publish|publication)\b/.test(item);
|
|
3523
|
+
if (negated && releaseReference && !/--dry-run/.test(item)) prohibited = true;
|
|
3524
|
+
if (!negated && (releaseOrder || (publication && (
|
|
3525
|
+
/^npm\s+publish\b/.test(item) || PUBLISH_ORDER_IMPERATIVE.test(item)
|
|
3526
|
+
|| /\b(?:chay|run)\s+`?npm\s+publish\b/.test(item)
|
|
3527
|
+
|| /continue the release workflow:/.test(item))))) ordered = true;
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
return ordered && !prohibited;
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
// Conditional repair is not a publication order until owned project source actually changes.
|
|
3536
|
+
// Reuse the affirmative publication parser on the taken branch, including its negation rules.
|
|
3537
|
+
function conditionalRepairOrdersPublish(state, ledger, cwd) {
|
|
3538
|
+
if (ledger?.substantiveWrite !== true || !cwd) return false;
|
|
3539
|
+
const text = String(state?.routingContext?.lastExplicitUserPromptText || '')
|
|
3540
|
+
.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
|
|
3541
|
+
.replace(/```[^]*?```|~~~[^]*?~~~/g, '').replace(/^[ \t]*>.*$/gm, '');
|
|
3542
|
+
if (!/(?:^|\n)[ \t*#_-]*(?:muc tieu|goal|purpose|objective)\s*:[^\n]*\b(?:xac nhan|verify|confirm|validate)\b/.test(text)) return false;
|
|
3543
|
+
const branch = text.match(/(?:^|\n|\.\s+)[ \t]*(?:\d+[.)]\s*)?(?:neu\s+co\s+source fix|if\s+(?:there is a\s+)?source fix)\s*:[^\n]*/)?.[0];
|
|
3544
|
+
if (!branch) return false;
|
|
3545
|
+
const writes = [...(ledger.receipts || []), ...Object.keys(ledger.bankedWrites || {}).map((file) => ({ kind: 'write', success: true, file }))];
|
|
3546
|
+
const projectWrite = writes.some((receipt) => {
|
|
3547
|
+
if (receipt.kind !== 'write' || receipt.success !== true || !receipt.file || isBookkeepingWrite(receipt.file)) return false;
|
|
3548
|
+
if (receipt.detail === 'bash-workspace-delta' && /^bash-workspace:/.test(receipt.file)) return true;
|
|
3549
|
+
const relative = path.relative(path.resolve(cwd), path.resolve(cwd, receipt.file));
|
|
3550
|
+
return relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
|
|
3551
|
+
&& !/^(?:\.ukit|\.cache|node_modules|tests?|fixtures?|tmp)(?:[\\/]|$)/.test(relative);
|
|
3552
|
+
});
|
|
3553
|
+
if (!projectWrite) return false;
|
|
3554
|
+
// Explicit task-wide publication prohibitions still dominate the conditional branch.
|
|
3555
|
+
if (/(?:do not|never|khong|chua)\s+(?:npm\s+)?publish\b|\bno\s+(?:release|publication)\b/.test(text)) return false;
|
|
3556
|
+
return promptOrdersNpmPublish({ routingContext: { lastExplicitUserPromptText: branch.replace(/^.*?:/, 'Run ') } });
|
|
3352
3557
|
}
|
|
3353
3558
|
|
|
3354
3559
|
function releasePublicationMissing(state, ledger, cwd) {
|
|
3355
|
-
if (!promptOrdersNpmPublish(state)) return null;
|
|
3560
|
+
if (!promptOrdersNpmPublish(state) && !conditionalRepairOrdersPublish(state, ledger, cwd)) return null;
|
|
3356
3561
|
let pkg = null;
|
|
3357
3562
|
try { pkg = JSON.parse(fsSync.readFileSync(path.join(cwd || process.cwd(), 'package.json'), 'utf8')); } catch { return null; }
|
|
3358
3563
|
if (!pkg?.version) return null;
|
|
@@ -3595,6 +3800,18 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3595
3800
|
};
|
|
3596
3801
|
}
|
|
3597
3802
|
}
|
|
3803
|
+
// Round 5: the ONE new hard demand. A delegated feature-implementation run whose workers
|
|
3804
|
+
// produced attributed writes owes a passing verification of the INTEGRATED state after the
|
|
3805
|
+
// last worker change. Conflicts / missing files / claim-only workers are not fixable by
|
|
3806
|
+
// running a command, so they stay acceptance-level (advisory) findings, not Stop demands.
|
|
3807
|
+
// Bounded by MAX_CONTINUATIONS like every other missing-evidence item.
|
|
3808
|
+
if (playbookId === playbookExecution.FEATURE_PLAYBOOK_ID && !operationalTask) {
|
|
3809
|
+
const run = effectiveLedger?.playbookRun;
|
|
3810
|
+
if (run && playbookExecution.assessWorkers(run).workers.some((w) => w.state === 'evidenced')) {
|
|
3811
|
+
const integration = playbookExecution.assessIntegration(run, { cwd, verdictCurrent });
|
|
3812
|
+
if (['pending', 'stale', 'failed'].includes(integration.status)) missingEvidence.push('integration-verification');
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3598
3815
|
if (missingEvidence.length === 0) {
|
|
3599
3816
|
// Silent success: the route is present and every required evidence is satisfied. Marked
|
|
3600
3817
|
// `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
|
|
@@ -3790,6 +4007,92 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3790
4007
|
};
|
|
3791
4008
|
}
|
|
3792
4009
|
|
|
4010
|
+
// ─── Round 5: feature-implementation lifecycle (acceptance / review inputs / telemetry) ──────
|
|
4011
|
+
// All derived from the task-owned ledger snapshot (effectiveRequestLedger), so another task's
|
|
4012
|
+
// handbacks, writes or verification can never satisfy this task. Pure reads — no I/O besides the
|
|
4013
|
+
// existence/currency checks the integration assessment needs.
|
|
4014
|
+
function featureRunContext({ state = {}, ledger = {}, cwd } = {}) {
|
|
4015
|
+
const routeSummary = state?.routeSummary || {};
|
|
4016
|
+
const playbookId = routeSummary.playbookId || null;
|
|
4017
|
+
if (playbookId !== playbookExecution.FEATURE_PLAYBOOK_ID) return null;
|
|
4018
|
+
const owned = effectiveRequestLedger(state, ledger) || {};
|
|
4019
|
+
const trace = buildPlaybookTrace(owned, playbookId);
|
|
4020
|
+
const run = owned.playbookRun || null;
|
|
4021
|
+
return { routeSummary, playbookId, owned, trace, run, cwd, host: owned.harness || ledger?.harness || null };
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
/** Final Playbook acceptance (see playbook-execution.mjs). NOT_APPLICABLE outside feature-implementation. */
|
|
4025
|
+
export function evaluateFeatureAcceptance({ state = {}, ledger = {}, cwd, reviewDecision = null } = {}) {
|
|
4026
|
+
const context = featureRunContext({ state, ledger, cwd });
|
|
4027
|
+
if (!context) return playbookExecution.evaluatePlaybookAcceptance({ playbookId: state?.routeSummary?.playbookId || null });
|
|
4028
|
+
return playbookExecution.evaluatePlaybookAcceptance({
|
|
4029
|
+
playbookId: context.playbookId,
|
|
4030
|
+
requiredSteps: context.trace.requiredSteps,
|
|
4031
|
+
missingSteps: playbookTraceMissingSteps(context.trace),
|
|
4032
|
+
run: context.run,
|
|
4033
|
+
cwd,
|
|
4034
|
+
verdictCurrent,
|
|
4035
|
+
reviewDecision: reviewDecision ?? context.run?.reviewDecision ?? null,
|
|
4036
|
+
delegationRequired: context.routeSummary.tierLane?.delegationRequired === true,
|
|
4037
|
+
delegationBlocked: context.owned?.delegationViolations?.blocked === true,
|
|
4038
|
+
host: context.host,
|
|
4039
|
+
contractEvidence: contractEvidenceStatus(state, context.owned, cwd),
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
// Which contract evidence the route demanded and which of it is still unproven (existing predicates).
|
|
4044
|
+
function contractEvidenceStatus(state, owned, cwd) {
|
|
4045
|
+
const required = requiredEvidence(state);
|
|
4046
|
+
if (!required.length) return null;
|
|
4047
|
+
return { required, missing: required.filter((item) => !evidenceSatisfied(item, owned, state, { cwd })) };
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
/** Everything the Stop coordinator needs for a delegated feature run: review inputs, acceptance, telemetry. */
|
|
4051
|
+
export function featureRunReport({ state = {}, ledger = {}, cwd, reviewDecision = null } = {}) {
|
|
4052
|
+
const context = featureRunContext({ state, ledger, cwd });
|
|
4053
|
+
if (!context) return { applicable: false };
|
|
4054
|
+
const run = context.run || playbookExecution.emptyPlaybookRun();
|
|
4055
|
+
const integration = playbookExecution.assessIntegration(run, { cwd, verdictCurrent });
|
|
4056
|
+
const workers = playbookExecution.assessWorkers(run);
|
|
4057
|
+
const rs = context.routeSummary;
|
|
4058
|
+
const riskHigh = (rs.riskEscalation?.level === 'high' && rs.riskEscalation?.stage !== 'shadow') || rs.riskFloor?.floor === 'high-risk';
|
|
4059
|
+
const acceptance = evaluateFeatureAcceptance({ state, ledger, cwd, reviewDecision });
|
|
4060
|
+
return {
|
|
4061
|
+
applicable: true,
|
|
4062
|
+
delegated: integration.delegated,
|
|
4063
|
+
riskHigh,
|
|
4064
|
+
host: playbookExecution.normalizeHost(context.host),
|
|
4065
|
+
integration,
|
|
4066
|
+
workers,
|
|
4067
|
+
acceptance,
|
|
4068
|
+
reviewDispatch: reviewDecision
|
|
4069
|
+
? playbookExecution.reviewDispatchHint({ decision: reviewDecision, workers, integration, host: context.host, riskHigh })
|
|
4070
|
+
: null,
|
|
4071
|
+
reviewInputs: playbookExecution.playbookReviewInputs({
|
|
4072
|
+
run, workers, integration, riskHigh,
|
|
4073
|
+
routeFixLoopCount: Number(rs.historySignals?.fixLoopCount) || 0,
|
|
4074
|
+
}),
|
|
4075
|
+
telemetry: playbookExecution.summarizePlaybookRun({
|
|
4076
|
+
playbookId: context.playbookId, taskInstanceId: state?.taskInstanceId, trace: context.trace,
|
|
4077
|
+
run, acceptance, reviewDecision: reviewDecision ?? run.reviewDecision, host: context.host,
|
|
4078
|
+
}),
|
|
4079
|
+
};
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
/** Persist the review-policy decision on the ledger as a task-owned `review` receipt. */
|
|
4083
|
+
export async function recordReviewDecision({ projectRoot, payload = {}, decision, harness = 'unknown' } = {}) {
|
|
4084
|
+
if (!projectRoot || !decision || typeof decision.action !== 'string') return { rejected: true, eventId: null };
|
|
4085
|
+
const routeState = await readRouteState(projectRoot, payload);
|
|
4086
|
+
if (routeState?.routeSummary?.playbookId !== playbookExecution.FEATURE_PLAYBOOK_ID) return { rejected: true, eventId: null };
|
|
4087
|
+
return recordLedgerEvent({
|
|
4088
|
+
type: 'receipt',
|
|
4089
|
+
receipt: {
|
|
4090
|
+
ts: Date.now(), kind: 'review', toolName: 'review-policy', success: true,
|
|
4091
|
+
review: { action: decision.action, namedCheck: decision.namedCheck ?? null, signalsHit: decision.signalsHit || [], round: decision.reviewRounds ?? 0 },
|
|
4092
|
+
},
|
|
4093
|
+
}, { projectRoot, payload, harness, routeState });
|
|
4094
|
+
}
|
|
4095
|
+
|
|
3793
4096
|
// Playbook trace advisory (observability only). A routed playbook other than
|
|
3794
4097
|
// bug-fix — whose floor stays the one hard gate — whose required steps are
|
|
3795
4098
|
// neither receipt-done nor proportionately skipped, on a request that already
|
|
@@ -3797,7 +4100,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
|
|
|
3797
4100
|
// changes the decision (continue/notify/missingEvidence untouched): no new
|
|
3798
4101
|
// continuation loop, no hard block. A no-write release (answer/plan/review, or
|
|
3799
4102
|
// a proportionate trivial route) stays advisory-free.
|
|
3800
|
-
function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
|
|
4103
|
+
function playbookStopAdvisory({ state = {}, ledger = {}, cwd } = {}, result = {}) {
|
|
3801
4104
|
if (!result || result.continue === true) return null;
|
|
3802
4105
|
const routeSummary = state?.routeSummary || {};
|
|
3803
4106
|
const playbookId = routeSummary.playbookId || null;
|
|
@@ -3805,8 +4108,11 @@ function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
|
|
|
3805
4108
|
const effectiveLedger = effectiveRequestLedger(state, ledger);
|
|
3806
4109
|
if (effectiveLedger.writeAttempted !== true) return null;
|
|
3807
4110
|
const trace = buildPlaybookTrace(effectiveLedger, playbookId);
|
|
3808
|
-
|
|
3809
|
-
|
|
4111
|
+
const stepNote = playbookTraceMissingSteps(trace).length === 0 ? null
|
|
4112
|
+
: `UKit playbook note: ${formatPlaybookTrace(trace)} (advisory).`;
|
|
4113
|
+
// Round 5: a delegated feature run adds its acceptance verdict — advisory text only.
|
|
4114
|
+
const acceptanceNote = playbookExecution.formatAcceptance(evaluateFeatureAcceptance({ state, ledger, cwd }));
|
|
4115
|
+
return [stepNote, acceptanceNote && `UKit ${acceptanceNote} (advisory).`].filter(Boolean).join(' ') || null;
|
|
3810
4116
|
}
|
|
3811
4117
|
|
|
3812
4118
|
export function evaluateCompletion(args = {}) {
|
|
@@ -3825,6 +4131,10 @@ export function evaluateCompletion(args = {}) {
|
|
|
3825
4131
|
};
|
|
3826
4132
|
result.diagnostics = diagnostics;
|
|
3827
4133
|
if (result.reason) result.reason += ` [task=${diagnostics.taskInstanceId || 'unknown'} contract=${diagnostics.contract || 'unknown'} accepted-count=${diagnostics.acceptedEvidence.length} rejected=${diagnostics.rejectedEvidence.join(',') || 'none'}]`;
|
|
4134
|
+
try {
|
|
4135
|
+
const acceptance = evaluateFeatureAcceptance({ state: args.state, ledger: args.ledger, cwd: args.cwd });
|
|
4136
|
+
if (acceptance.applicable) result.playbookAcceptance = acceptance;
|
|
4137
|
+
} catch { /* acceptance is advisory and never breaks the gate */ }
|
|
3828
4138
|
try {
|
|
3829
4139
|
const advisory = playbookStopAdvisory(args, result);
|
|
3830
4140
|
if (advisory) return { ...result, playbookAdvisory: advisory };
|
|
@@ -325,6 +325,14 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
325
325
|
const parsedArgs = scriptArgs.map(parseScriptArg);
|
|
326
326
|
const scriptPaths = parsedArgs.map((a) => a.scriptPath);
|
|
327
327
|
const payload = JSON.parse(payloadText || '{}');
|
|
328
|
+
// The omp bridge stamps `ukit_harness` on every payload it builds. pi.exec drops its environment
|
|
329
|
+
// option and the host's spawn inherits the launch-time snapshot, so a runtime process.env write in
|
|
330
|
+
// the bridge never reaches hook children (live: omp ledger harness 'claude-code', main tier read
|
|
331
|
+
// from .claude/settings.json). Setting it here covers this runner, its in-proc .mjs steps and the
|
|
332
|
+
// .sh children (stepEnv spreads process.env).
|
|
333
|
+
if (typeof payload?.ukit_harness === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(payload.ukit_harness)) {
|
|
334
|
+
process.env.UKIT_HARNESS = payload.ukit_harness;
|
|
335
|
+
}
|
|
328
336
|
const firstScript = scriptPaths[0] || '';
|
|
329
337
|
const projectRoot = firstScript
|
|
330
338
|
? path.resolve(path.dirname(firstScript), '../..')
|
|
@@ -127,5 +127,7 @@ export async function applyLoopBreak({ merged, projectRoot, runRoot, payload, se
|
|
|
127
127
|
'Stop released. This is NOT approval: completion evidence was not accepted by the blocking policy; this does not mean no verification ran; no safety/permission denial is lifted.',
|
|
128
128
|
'Report it verbatim; do not retry, wake up, or resume any handoff.',
|
|
129
129
|
].join('\n');
|
|
130
|
-
|
|
130
|
+
// The release is a terminal diagnostic, not another carrier for prior hook
|
|
131
|
+
// output. Re-appending that output can recursively amplify escalation text.
|
|
132
|
+
return { ...merged, decision: null, reason: undefined, loopBreak: true, policyOwner: 'loop-breaker', systemMessage: note };
|
|
131
133
|
}
|
|
@@ -1110,6 +1110,43 @@ function linkageField(...candidates) {
|
|
|
1110
1110
|
* derivation, the session-boot request_key join, and the envelope session_id.
|
|
1111
1111
|
* `enforcement` derives per FR-006 unless the emitter overrode it.
|
|
1112
1112
|
*/
|
|
1113
|
+
// Round 5: minimal, non-sensitive Playbook-execution telemetry riding the existing
|
|
1114
|
+
// outcome.observed record. Closed field set, bounded strings/numbers; ids and counts only
|
|
1115
|
+
// (no prompts, paths or reply text). `observed.*` stays null unless the host really reported it.
|
|
1116
|
+
export function outcomePlaybook(value) {
|
|
1117
|
+
if (!isPlainObject(value) || !isNonEmptyString(value.playbookId)) return null;
|
|
1118
|
+
const text = (v, max = 64) => (isNonEmptyString(v) ? v.slice(0, max) : null);
|
|
1119
|
+
const num = (v) => (Number.isFinite(v) && v >= 0 ? Math.trunc(v) : null);
|
|
1120
|
+
const obj = (v) => (isPlainObject(v) ? v : {});
|
|
1121
|
+
const tokens = isPlainObject(value.tokens) ? {
|
|
1122
|
+
input: num(value.tokens.input), output: num(value.tokens.output), cacheRead: num(value.tokens.cacheRead),
|
|
1123
|
+
cacheCreate: num(value.tokens.cacheCreate), total: num(value.tokens.total),
|
|
1124
|
+
provenance: ['PROVIDER', 'ESTIMATED', 'UNKNOWN'].includes(value.tokens.provenance) ? value.tokens.provenance : 'UNKNOWN',
|
|
1125
|
+
} : null;
|
|
1126
|
+
const outcomes = obj(value.workerOutcomes);
|
|
1127
|
+
return {
|
|
1128
|
+
playbookId: text(value.playbookId),
|
|
1129
|
+
taskInstanceId: text(value.taskInstanceId, 96),
|
|
1130
|
+
host: text(value.host, 16),
|
|
1131
|
+
stepsRequired: num(value.stepsRequired),
|
|
1132
|
+
stepsDone: num(value.stepsDone),
|
|
1133
|
+
workerCount: num(value.workerCount),
|
|
1134
|
+
executionMode: ['none', 'single', 'sequential', 'concurrent', 'unknown'].includes(value.executionMode) ? value.executionMode : 'unknown',
|
|
1135
|
+
requested: { tier: text(obj(value.requested).tier, 16), model: text(obj(value.requested).model), effort: text(obj(value.requested).effort, 16) },
|
|
1136
|
+
observed: { model: text(obj(value.observed).model), effort: text(obj(value.observed).effort, 16) },
|
|
1137
|
+
resolved: { tier: text(obj(value.resolved).tier, 16), effort: text(obj(value.resolved).effort, 16), effortBinding: text(obj(value.resolved).effortBinding, 24) },
|
|
1138
|
+
effective: { model: text(obj(value.effective).model) || 'UNKNOWN', effort: text(obj(value.effective).effort, 16) || 'UNKNOWN' },
|
|
1139
|
+
durationMs: num(value.durationMs),
|
|
1140
|
+
retries: num(value.retries),
|
|
1141
|
+
workerOutcomes: { evidenced: num(outcomes.evidenced), claimOnly: num(outcomes.claimOnly), unobservable: num(outcomes.unobservable), failed: num(outcomes.failed) },
|
|
1142
|
+
tokens,
|
|
1143
|
+
integration: text(value.integration, 24),
|
|
1144
|
+
review: text(value.review, 32),
|
|
1145
|
+
reviewState: text(value.reviewState, 32),
|
|
1146
|
+
acceptance: ['PASS', 'NOT_PASS', 'UNVERIFIED'].includes(value.acceptance) ? value.acceptance : null,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1113
1150
|
export function buildOutcomeObservedRecord({
|
|
1114
1151
|
outcome = null,
|
|
1115
1152
|
projectRoot = null,
|
|
@@ -1156,6 +1193,7 @@ export function buildOutcomeObservedRecord({
|
|
|
1156
1193
|
source: isNonEmptyString(safe.source) ? safe.source.slice(0, 96) : null,
|
|
1157
1194
|
request_key: sessionBootRequestKey({ projectRoot, sessionId: resolvedSessionId }),
|
|
1158
1195
|
ts: isNonEmptyString(safe.ts) ? safe.ts : new Date().toISOString(),
|
|
1196
|
+
...(outcomePlaybook(safe.playbook) ? { playbook: outcomePlaybook(safe.playbook) } : {}),
|
|
1159
1197
|
};
|
|
1160
1198
|
return {
|
|
1161
1199
|
semantic_name: SEMANTIC_OUTCOME_OBSERVED,
|