@ngockhoale/ukit 3.4.47 → 3.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/manifests/documentation.yaml +22 -0
  3. package/manifests/platform.full.yaml +27 -0
  4. package/package.json +4 -1
  5. package/scripts/dev/worktree-preflight.mjs +20 -1
  6. package/src/core/diffPlan.js +38 -12
  7. package/src/core/executionContracts.js +67 -16
  8. package/src/core/metadata.js +4 -0
  9. package/src/core/runInstallPipeline.js +19 -2
  10. package/src/core/runtimeConfig.js +1 -1
  11. package/src/index/playbookRegistry.js +22 -3
  12. package/src/index/taskRouting.js +2 -2
  13. package/template_project/.claude/agents/bug-debugger.md +1 -0
  14. package/template_project/.claude/agents/code-reviewer.md +1 -0
  15. package/template_project/.claude/agents/feature-implementer.md +1 -0
  16. package/template_project/.claude/agents/handoff-planner.md +1 -0
  17. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  18. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  19. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  20. package/template_project/.claude/hooks/skill-router.sh +147 -6
  21. package/template_project/.claude/settings.json +8 -8
  22. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  23. package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
  24. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  25. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  26. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  27. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
  28. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  29. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  30. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  31. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  32. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +79 -3
  33. package/template_project/.omp/agents/bug-debugger.md +1 -0
  34. package/template_project/.omp/agents/code-reviewer.md +1 -0
  35. package/template_project/.omp/agents/feature-implementer.md +1 -0
  36. package/template_project/.omp/agents/handoff-planner.md +1 -0
  37. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  39. package/template_project/ukit/storage/config.json +1 -1
  40. package/scripts/bench/data-foundation.mjs +0 -929
  41. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  42. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  43. package/scripts/bench/goldTasks.json +0 -38
  44. package/scripts/bench/memory-ablation.mjs +0 -495
  45. package/scripts/bench/memory-baseline.mjs +0 -596
  46. package/scripts/bench/memory-bench.mjs +0 -661
  47. package/scripts/bench/memory-canary.mjs +0 -331
  48. package/scripts/bench/memory-corpus.mjs +0 -354
  49. package/scripts/bench/memory-gate.mjs +0 -389
  50. package/scripts/bench/memory-metrics.mjs +0 -179
  51. package/scripts/bench/outline-savings.mjs +0 -107
  52. package/scripts/bench/parallel-agents.mjs +0 -224
  53. package/scripts/bench/recorder-overhead.mjs +0 -204
  54. package/scripts/bench/runGold.mjs +0 -238
  55. package/scripts/bench/sqlite-spike.mjs +0 -451
  56. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  57. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  58. package/scripts/bench/v3-ceremony.mjs +0 -220
  59. package/scripts/perf/audit-perf.mjs +0 -1195
  60. package/scripts/perf/diff-perf-findings.mjs +0 -136
  61. package/scripts/perf/perf-findings.json +0 -474
  62. package/scripts/perf/perf-measure.md +0 -271
@@ -0,0 +1,704 @@
1
+ // playbook-execution.mjs — Round 5 vertical slice: the feature-implementation
2
+ // Playbook as an executable lifecycle (step contract → worker handback →
3
+ // integration verification → review obligation → final acceptance).
4
+ //
5
+ // Pure and deterministic: no ledger I/O, no route reads, no config. The ledger
6
+ // (execution-ledger.mjs) owns persistence and calls these functions at receipt-
7
+ // fold time and at evaluation time; this module never imports the ledger or
8
+ // `index/` (runtime must not static-import index/). Text is never proof: a
9
+ // worker's reply can only be a CLAIM; evidence is the attributed receipts the
10
+ // hook layer recorded (write / verification / source with `agentId`).
11
+
12
+ import crypto from 'node:crypto';
13
+ import fsSync from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ export const FEATURE_PLAYBOOK_ID = 'feature-implementation';
17
+ // Refused main-thread source writes (delegation-gate.mjs) before the run ends as BLOCKED.
18
+ export const DELEGATION_VIOLATION_LIMIT = 3;
19
+
20
+ // ─── Phase 1: the step contract ─────────────────────────────────────────────
21
+ // ONE registry. execution-ledger.mjs builds its PLAYBOOK_STEP_MAPS entry from
22
+ // this list (conditional steps are filtered out of the unconditional trace), so
23
+ // the step ids/essentiality exist in exactly one place. `mdStep` is the ordinal
24
+ // of the numbered step in playbooks/feature-implementation.md the runtime step
25
+ // realises (null = runtime-only); `mdAnchor` is a word that ordinal must still
26
+ // contain — a drift test fails when the prose is reordered under the map.
27
+ //
28
+ // derive how buildPlaybookTrace proves it from receipts (existing tokens)
29
+ // or 'integration' / 'review' for the conditional runtime steps.
30
+ // conditional null = always required; 'delegated' = required once an executor
31
+ // worker handed back; 'review-required' = required when the review
32
+ // policy demands an independent review.
33
+ // role/tier who performs it: executor→code(Tier 2), orchestrator/reviewer→smart(Tier 3).
34
+ // complexity reasoning complexity of the step; effort is derived from it
35
+ // (resolveStepEffort) — never from risk and never from tier.
36
+ // delegation 'optional' | 'preferred' | 'never' | 'required'.
37
+ // A worker may execute several compatible steps in one dispatch.
38
+ export const FEATURE_IMPLEMENTATION_STEPS = Object.freeze([
39
+ Object.freeze({
40
+ id: 'analog-search', derive: 'pre-source', essential: false, conditional: null,
41
+ mdStep: 2, mdAnchor: 'analog', role: 'executor', tier: 'code', complexity: 'low', effort: 'medium',
42
+ delegation: 'optional', dependsOn: Object.freeze([]), evidence: 'source-receipt',
43
+ acceptance: 'an existing analog was read before the first write, or the skip is justified',
44
+ }),
45
+ Object.freeze({
46
+ id: 'implement', derive: 'write', essential: true, conditional: null,
47
+ mdStep: 4, mdAnchor: 'implement', role: 'executor', tier: 'code', complexity: 'medium', effort: 'medium',
48
+ delegation: 'preferred', dependsOn: Object.freeze(['analog-search']), evidence: 'write-receipt',
49
+ acceptance: 'a successful product write exists for the change',
50
+ }),
51
+ Object.freeze({
52
+ id: 'verify', derive: 'post-verify', essential: true, conditional: null,
53
+ mdStep: 5, mdAnchor: 'verify', role: 'executor', tier: 'code', complexity: 'low', effort: 'medium',
54
+ delegation: 'optional', dependsOn: Object.freeze(['implement']), evidence: 'verification-receipt',
55
+ acceptance: 'a passing verification ran after the first write',
56
+ }),
57
+ Object.freeze({
58
+ id: 'integrate-verify', derive: 'integration', essential: true, conditional: 'delegated',
59
+ mdStep: null, mdAnchor: null, role: 'orchestrator', tier: 'smart', complexity: 'medium', effort: 'medium',
60
+ delegation: 'never', dependsOn: Object.freeze(['implement', 'verify']), evidence: 'integration-verification',
61
+ acceptance: 'a passing verification ran in the integrated session state after every worker change and handback',
62
+ }),
63
+ Object.freeze({
64
+ id: 'review', derive: 'review', essential: true, conditional: 'review-required',
65
+ mdStep: null, mdAnchor: null, role: 'reviewer', tier: 'smart', complexity: 'medium', effort: 'medium',
66
+ delegation: 'required', dependsOn: Object.freeze(['integrate-verify']), evidence: 'review-verdict',
67
+ acceptance: 'an independent reviewer approved the current diff against the request and the verification evidence',
68
+ }),
69
+ ]);
70
+
71
+ // Numbered steps of the prose with no runtime step above, classified once (no receipt is minted
72
+ // for prose). guidance = no verifiable artifact: reported, never blocks. conditional = mandatory
73
+ // once the route requires `evidence`, proven by that EXISTING evidence predicate.
74
+ export const FEATURE_UNMAPPED_MD_STEPS = Object.freeze([
75
+ Object.freeze({ mdStep: 1, mdAnchor: 'predicate', class: 'guidance', basis: 'its checkable form is the route completion contract, enforced through steps 4/5; "stated before code" has no receipt' }),
76
+ Object.freeze({ mdStep: 3, mdAnchor: 'data shape', class: 'guidance', basis: 'a design act with no artifact the runtime can observe' }),
77
+ Object.freeze({ mdStep: 6, mdAnchor: 'impact', class: 'conditional', evidence: 'impact-evidence', basis: 'mandatory when the route contract demands impact-evidence (map-impact); otherwise the route named no surface' }),
78
+ ]);
79
+ // Unnumbered prose paragraphs: constraints/guidance only.
80
+ export const FEATURE_PROSE_GUIDANCE = Object.freeze(['reference reuse', 'reuse-first', 'clone', 'reply:', 'ask the human only']);
81
+
82
+ // Logical tier an agent is bound to (its `model:` frontmatter). A drift test
83
+ // pins this against .claude/agents/*.md so the map cannot silently diverge.
84
+ export const LOGICAL_TIER_BY_AGENT = Object.freeze({
85
+ 'feature-implementer': 'code',
86
+ 'bug-debugger': 'code',
87
+ 'ukit-small-task-maintainer': 'lite',
88
+ 'code-reviewer': 'smart',
89
+ 'handoff-planner': 'smart',
90
+ 'ukit-vision-analyst': 'vision',
91
+ });
92
+
93
+ export const ROLE_BY_AGENT = Object.freeze({
94
+ 'feature-implementer': 'executor',
95
+ 'bug-debugger': 'executor',
96
+ 'code-reviewer': 'reviewer',
97
+ 'ukit-small-task-maintainer': 'support',
98
+ 'handoff-planner': 'planner',
99
+ 'ukit-vision-analyst': 'support',
100
+ });
101
+
102
+ // Whether a per-dispatch reasoning-effort parameter can actually be bound on a
103
+ // host. Claude Code's Agent tool and OMP's task tool accept it; Codex has no
104
+ // per-subagent knob. 'high' is requested ONLY where it can be bound.
105
+ export const HOST_EFFORT_BINDING = Object.freeze({ 'claude-code': 'dispatch-param', omp: 'dispatch-param', codex: 'none' });
106
+
107
+ export function normalizeHost(harness) {
108
+ const text = String(harness || '').toLowerCase();
109
+ if (text.includes('omp') || text.includes('oh-my-pi')) return 'omp';
110
+ if (text.includes('codex')) return 'codex';
111
+ return 'claude-code';
112
+ }
113
+
114
+ /** Steps active for this run: unconditional ones plus the conditional ones whose condition holds. */
115
+ export function activeFeatureSteps({ delegated = false, reviewRequired = false } = {}) {
116
+ return FEATURE_IMPLEMENTATION_STEPS.filter((step) => step.conditional === null
117
+ || (step.conditional === 'delegated' && delegated)
118
+ || (step.conditional === 'review-required' && reviewRequired));
119
+ }
120
+
121
+ /**
122
+ * Effort for a step. Medium by default; High only with justified complexity
123
+ * (the step's own, the route's complexity evidence, or verified failure
124
+ * evidence) AND native host support. Risk and tier never raise effort.
125
+ */
126
+ export function resolveStepEffort(step, { host = 'claude-code', complexityHigh = false, failures = 0 } = {}) {
127
+ const justified = step?.complexity === 'high' || complexityHigh === true || Number(failures) >= 2;
128
+ const binding = HOST_EFFORT_BINDING[normalizeHost(host)] || 'none';
129
+ if (!justified) return { effort: 'medium', binding, justified: false, suppressed: false };
130
+ if (binding === 'none') return { effort: 'medium', binding, justified: true, suppressed: true };
131
+ return { effort: 'high', binding, justified: true, suppressed: false };
132
+ }
133
+
134
+ // ─── Phase 2: the trusted handback ──────────────────────────────────────────
135
+ const TASK_MARKER_RE = /\[ukit-task\s+([A-Za-z0-9._-]{1,96})\]/;
136
+ const STEPS_MARKER_RE = /\[ukit-steps\s+([A-Za-z0-9_, -]{1,160})\]/;
137
+ const SCOPE_MARKER_RE = /\[ukit-scope\s+([^\]]{1,480})\]/;
138
+ const SUBTASK_RE = /\[ukit-subtask\s+([A-Za-z0-9_-]+\.(?:\d+|integrate))\]/;
139
+ const AGENT_ID_RE = /^[A-Za-z0-9_-]{4,96}$/;
140
+ const VERDICT_RE = /\b(APPROVED-WITH-MINOR|APPROVED|CHANGES-REQUESTED|CRITICAL)\b/;
141
+ export const REVIEW_VERDICTS = Object.freeze(['APPROVED', 'APPROVED-WITH-MINOR', 'CHANGES-REQUESTED', 'CRITICAL']);
142
+ const APPROVING_VERDICTS = new Set(['APPROVED', 'APPROVED-WITH-MINOR']);
143
+ const MAX_HANDBACKS = 16;
144
+ const MAX_WORKERS = 12;
145
+ const MAX_WORKER_FILES = 24;
146
+ const MAX_WORKER_VERIFICATIONS = 6;
147
+
148
+ export function sanitizeAgentId(value) {
149
+ return typeof value === 'string' && AGENT_ID_RE.test(value.trim()) ? value.trim() : null;
150
+ }
151
+
152
+ const str = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null);
153
+ const count = (value) => (Number.isFinite(Number(value)) && Number(value) >= 0 ? Math.trunc(Number(value)) : null);
154
+ const sha = (text, len = 16) => crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, len);
155
+
156
+ function replyText(response) {
157
+ if (typeof response === 'string') return response;
158
+ if (!response || typeof response !== 'object') return '';
159
+ if (Array.isArray(response.content)) {
160
+ return response.content.map((part) => (typeof part === 'string' ? part : part?.text || '')).join('\n');
161
+ }
162
+ return [response.result, response.text, response.output].find((v) => typeof v === 'string') || '';
163
+ }
164
+
165
+ function listOf(raw, max, itemMax) {
166
+ return String(raw || '').split(',').map((v) => v.trim()).filter(Boolean).slice(0, max).map((v) => v.slice(0, itemMax));
167
+ }
168
+
169
+ function normalizePathEntry(entry, root) {
170
+ const text = String(entry || '').trim();
171
+ if (!text) return null;
172
+ const abs = path.isAbsolute(text) ? text : path.resolve(root || '.', text);
173
+ const rel = root ? path.relative(path.resolve(root), abs) : text;
174
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
175
+ return rel.split(path.sep).join('/').slice(0, 200);
176
+ }
177
+
178
+ /**
179
+ * Build the bounded handback record from an Agent/Task tool result. Returns
180
+ * null for a payload that is not a recognisable subagent dispatch. NOTHING in
181
+ * the reply text can mint evidence: `claimedPass` is derived from prose and is
182
+ * only ever a claim; `observed` stays null unless the response really carries it.
183
+ */
184
+ export function buildHandbackRecord({ toolInput, toolResponse, projectRoot, success = true } = {}) {
185
+ let input = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput) ? toolInput : null;
186
+ if (!input) return null;
187
+ // omp `task` fan-out: {context, tasks:[{agent,name,task}]}. One agent type across all entries is
188
+ // one dispatch; a mixed fan-out is not recognisable. `name` (and the result's progress[].id,
189
+ // which echoes it) is chosen by the calling model — never an identity, never read as agentId.
190
+ if (Array.isArray(input.tasks) && input.tasks.length > 0 && !input.subagent_type && !input.agent) {
191
+ const agents = new Set(input.tasks.map((t) => str(t?.agent, 64)));
192
+ if (agents.size !== 1 || agents.has(null)) return null;
193
+ input = { agent: [...agents][0], prompt: [input.context, ...input.tasks.map((t) => t?.task)].filter((v) => typeof v === 'string').join('\n'), isolation: input.isolation };
194
+ }
195
+ const agentType = str(input.subagent_type ?? input.agent ?? input.agent_type, 64);
196
+ if (!agentType) return null;
197
+ const resp = toolResponse && typeof toolResponse === 'object' && !Array.isArray(toolResponse) ? toolResponse : {};
198
+ // OMP returns an asynchronous spawn ACK before the worker completes. Progress IDs
199
+ // echo caller-chosen names; neither they nor an empty results array are a handback.
200
+ const asyncState = resp.details?.async?.state ?? resp.async?.state;
201
+ if (/^(running|pending|queued|started)$/i.test(String(asyncState ?? ''))) return null;
202
+ const text = replyText(toolResponse);
203
+ const promptText = [input.prompt, input.description, input.task].filter((v) => typeof v === 'string').join('\n');
204
+ const agentIdRaw = str(resp.agentId ?? resp.agent_id, 96);
205
+ const agentId = agentIdRaw && AGENT_ID_RE.test(agentIdRaw) ? agentIdRaw : null;
206
+ const taskMarker = TASK_MARKER_RE.exec(promptText);
207
+ const stepsMarker = STEPS_MARKER_RE.exec(promptText);
208
+ const scopeMarker = SCOPE_MARKER_RE.exec(promptText);
209
+ const subtask = SUBTASK_RE.exec(promptText);
210
+ const role = ROLE_BY_AGENT[agentType] || 'support';
211
+ const failed = success !== true || resp.is_error === true || resp.isError === true
212
+ || /^(failed|error)$/i.test(String(resp.status || ''));
213
+ const usage = resp.usage && typeof resp.usage === 'object' ? resp.usage : null;
214
+ const tokenParts = {
215
+ input: count(usage?.input_tokens), output: count(usage?.output_tokens),
216
+ cacheRead: count(usage?.cache_read_input_tokens), cacheCreate: count(usage?.cache_creation_input_tokens),
217
+ total: count(resp.totalTokens ?? resp.total_tokens),
218
+ };
219
+ const hasTokens = Object.values(tokenParts).some((v) => v !== null);
220
+ let reviewVerdict = null;
221
+ let findings = [];
222
+ if (role === 'reviewer' && text) {
223
+ const labelled = /verdict\W{0,6}(APPROVED-WITH-MINOR|APPROVED|CHANGES-REQUESTED|CRITICAL)/i.exec(text);
224
+ reviewVerdict = labelled ? labelled[1].toUpperCase() : (VERDICT_RE.exec(text)?.[1] ?? null);
225
+ findings = text.split(/\r?\n/).map((line) => /^\s*[-*]\s+(.{4,})$/.exec(line)?.[1]).filter(Boolean)
226
+ .slice(0, 5).map((line) => line.trim().slice(0, 160));
227
+ }
228
+ const root = projectRoot ? path.resolve(projectRoot) : null;
229
+ return {
230
+ agentId,
231
+ agentType,
232
+ subtaskId: subtask ? subtask[1] : null,
233
+ logicalTier: LOGICAL_TIER_BY_AGENT[agentType] || null,
234
+ role,
235
+ // 'native' only when the host returned a real agent id AND later receipts can carry it
236
+ // (Claude Code stamps agent_id on subagent tool payloads). Otherwise nothing a worker
237
+ // did is attributable to it — reported, never guessed.
238
+ attribution: agentId ? 'native' : 'unsupported',
239
+ taskBinding: taskMarker ? 'marker' : 'live-state',
240
+ markerTaskId: taskMarker ? taskMarker[1] : null,
241
+ assignedSteps: stepsMarker ? listOf(stepsMarker[1], 8, 40) : [],
242
+ scope: scopeMarker ? listOf(scopeMarker[1], 8, 160).map((e) => normalizePathEntry(e, root)).filter(Boolean) : [],
243
+ isolated: input.isolation === 'worktree',
244
+ outcome: failed ? 'failed' : 'completed',
245
+ claimedPass: !failed && /\b(pass(ed|es|ing)?|all green|verified|status:\s*done)\b/i.test(text.slice(-2400)),
246
+ requested: { model: str(input.model, 64), effort: str(input.effort ?? input.reasoning_effort, 16) },
247
+ observed: { model: str(resp.model, 64), effort: str(resp.effort, 16) },
248
+ durationMs: count(resp.totalDurationMs ?? resp.total_duration_ms),
249
+ toolUses: count(resp.totalToolUseCount ?? resp.total_tool_use_count),
250
+ tokens: hasTokens ? { ...tokenParts, provenance: 'PROVIDER' } : null,
251
+ reviewVerdict,
252
+ findings,
253
+ digest: sha(`${agentType}\n${String(input.prompt || '').slice(0, 2000)}\n${String(input.description || '')}`),
254
+ };
255
+ }
256
+
257
+ /** Re-validates a persisted/journaled handback; unknown shapes yield null. */
258
+ export function sanitizeHandback(raw) {
259
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
260
+ const agentType = str(raw.agentType, 64);
261
+ if (!agentType) return null;
262
+ const list = (v, max, itemMax) => (Array.isArray(v) ? v.filter((e) => typeof e === 'string').slice(0, max).map((e) => e.slice(0, itemMax)) : []);
263
+ const tokens = raw.tokens && typeof raw.tokens === 'object' ? {
264
+ input: count(raw.tokens.input), output: count(raw.tokens.output), cacheRead: count(raw.tokens.cacheRead),
265
+ cacheCreate: count(raw.tokens.cacheCreate), total: count(raw.tokens.total),
266
+ provenance: raw.tokens.provenance === 'PROVIDER' ? 'PROVIDER' : 'UNKNOWN',
267
+ } : null;
268
+ return {
269
+ agentId: str(raw.agentId, 96) && AGENT_ID_RE.test(raw.agentId) ? raw.agentId : null,
270
+ agentType,
271
+ subtaskId: str(raw.subtaskId, 80),
272
+ logicalTier: LOGICAL_TIER_BY_AGENT[agentType] || null,
273
+ role: ROLE_BY_AGENT[agentType] || 'support',
274
+ attribution: raw.attribution === 'native' && raw.agentId ? 'native' : 'unsupported',
275
+ taskBinding: raw.taskBinding === 'marker' ? 'marker' : 'live-state',
276
+ assignedSteps: list(raw.assignedSteps, 8, 40),
277
+ scope: list(raw.scope, 8, 200),
278
+ isolated: raw.isolated === true,
279
+ outcome: raw.outcome === 'failed' ? 'failed' : 'completed',
280
+ claimedPass: raw.claimedPass === true,
281
+ requested: { model: str(raw.requested?.model, 64), effort: str(raw.requested?.effort, 16) },
282
+ observed: { model: str(raw.observed?.model, 64), effort: str(raw.observed?.effort, 16) },
283
+ durationMs: count(raw.durationMs),
284
+ toolUses: count(raw.toolUses),
285
+ tokens,
286
+ reviewVerdict: REVIEW_VERDICTS.includes(raw.reviewVerdict) ? raw.reviewVerdict : null,
287
+ findings: list(raw.findings, 5, 160),
288
+ digest: str(raw.digest, 32),
289
+ };
290
+ }
291
+
292
+ // ─── The run fold (persisted on the ledger as `playbookRun`) ────────────────
293
+ export function emptyPlaybookRun() {
294
+ return { changeSeq: 0, lastExecutorHandbackSeq: 0, inlineWrites: 0, duplicates: 0, handbacks: [], workers: {}, integration: null, reviewer: null, reviewDecision: null };
295
+ }
296
+
297
+ function ensureWorker(run, agentId, seq) {
298
+ if (!run.workers[agentId]) {
299
+ const keys = Object.keys(run.workers);
300
+ if (keys.length >= MAX_WORKERS) delete run.workers[keys[0]];
301
+ run.workers[agentId] = { files: {}, verifications: [], sources: 0, failures: 0, firstSeq: seq, lastSeq: seq };
302
+ }
303
+ return run.workers[agentId];
304
+ }
305
+
306
+ /** Stable fingerprint of what a worker changed: sha over sorted `file:contentSha`. */
307
+ export function workerChangeFingerprint(worker) {
308
+ const files = worker?.files && typeof worker.files === 'object' ? worker.files : {};
309
+ const keys = Object.keys(files).sort();
310
+ return keys.length ? sha(keys.map((k) => `${k}:${files[k] || ''}`).join('\n')) : null;
311
+ }
312
+
313
+ /**
314
+ * Fold one receipt into the run. `seq` is the ledger receiptSequence this
315
+ * receipt was applied at; `bookkeeping` marks a WORKLOG/STATUS-style write that
316
+ * never invalidates verification. Pure: returns a new run, never mutates.
317
+ */
318
+ export function foldPlaybookRun(previous, receipt, { seq, bookkeeping = false } = {}) {
319
+ const run = previous && typeof previous === 'object'
320
+ ? { ...emptyPlaybookRun(), ...previous, handbacks: [...(previous.handbacks || [])], workers: { ...(previous.workers || {}) } }
321
+ : emptyPlaybookRun();
322
+ const ok = receipt?.success === true;
323
+ if (receipt?.kind === 'write') {
324
+ if (ok && !bookkeeping) run.changeSeq = seq;
325
+ if (typeof receipt.agentId === 'string' && receipt.agentId) {
326
+ const worker = { ...ensureWorker(run, receipt.agentId, seq) };
327
+ worker.files = { ...worker.files };
328
+ worker.lastSeq = seq;
329
+ if (ok && receipt.file && !bookkeeping && String(receipt.file).startsWith('bash-workspace:')) {
330
+ worker.bash = (worker.bash || 0) + 1; // a Bash-made change: real, but names no file
331
+ } else if (ok && receipt.file && !bookkeeping) {
332
+ if (worker.files[receipt.file] !== undefined || Object.keys(worker.files).length < MAX_WORKER_FILES) {
333
+ worker.files[String(receipt.file).slice(0, 200)] = typeof receipt.contentSha === 'string' ? receipt.contentSha.slice(0, 16) : null;
334
+ }
335
+ } else if (!ok) worker.failures += 1;
336
+ run.workers[receipt.agentId] = worker;
337
+ } else if (ok && !bookkeeping) {
338
+ run.inlineWrites += 1;
339
+ }
340
+ } else if (receipt?.kind === 'source') {
341
+ if (ok && typeof receipt.agentId === 'string' && receipt.agentId) {
342
+ const worker = { ...ensureWorker(run, receipt.agentId, seq) };
343
+ worker.sources += 1;
344
+ worker.lastSeq = seq;
345
+ run.workers[receipt.agentId] = worker;
346
+ }
347
+ } else if (receipt?.kind === 'verification') {
348
+ if (typeof receipt.agentId === 'string' && receipt.agentId) {
349
+ const worker = { ...ensureWorker(run, receipt.agentId, seq) };
350
+ worker.verifications = [...worker.verifications,
351
+ { command: String(receipt.command || '').slice(0, 120), success: ok, seq }].slice(-MAX_WORKER_VERIFICATIONS);
352
+ if (!ok) worker.failures += 1;
353
+ worker.lastSeq = seq;
354
+ run.workers[receipt.agentId] = worker;
355
+ } else if (!receipt.workspaceRoot) {
356
+ // A verification in the session root, by the orchestrator: the only kind of
357
+ // receipt that can become integration evidence. Whether it is CURRENT is
358
+ // decided at assessment time against changeSeq / lastExecutorHandbackSeq.
359
+ const kind = receipt.verdict?.kind;
360
+ if (ok && (kind === 'test-verified' || kind === 'live-verified')) {
361
+ run.integration = { status: 'verified', atSeq: seq, command: String(receipt.command || '').slice(0, 160), verdictKind: kind, verdict: receipt.verdict, ts: receipt.ts ?? null };
362
+ } else if (!ok) {
363
+ run.integration = { status: 'failed', atSeq: seq, command: String(receipt.command || '').slice(0, 160), ts: receipt.ts ?? null };
364
+ }
365
+ }
366
+ } else if (receipt?.kind === 'handback') {
367
+ const handback = sanitizeHandback(receipt.handback);
368
+ if (!handback) return run;
369
+ const key = receipt.toolUseId ? `t:${receipt.toolUseId}` : `d:${handback.digest || ''}:${handback.agentId || ''}`;
370
+ if (run.handbacks.some((entry) => entry.key === key)) {
371
+ run.duplicates += 1; // repeated delivery of the same result is a no-op
372
+ return run;
373
+ }
374
+ const worker = handback.agentId ? run.workers[handback.agentId] : null;
375
+ run.handbacks = [...run.handbacks, {
376
+ ...handback, key, seq, ts: receipt.ts ?? null,
377
+ changeFp: workerChangeFingerprint(worker),
378
+ workspaceFp: typeof receipt.workspaceFp === 'string' ? receipt.workspaceFp.slice(0, 64) : null,
379
+ }].slice(-MAX_HANDBACKS);
380
+ if (handback.role === 'executor') run.lastExecutorHandbackSeq = seq;
381
+ if (handback.role === 'reviewer') {
382
+ run.reviewer = { verdict: handback.reviewVerdict, findings: handback.findings, seq, agentId: handback.agentId, outcome: handback.outcome };
383
+ }
384
+ } else if (receipt?.kind === 'review') {
385
+ const decision = receipt.review && typeof receipt.review === 'object' ? receipt.review : null;
386
+ if (decision) {
387
+ run.reviewDecision = {
388
+ action: str(decision.action, 32), namedCheck: str(decision.namedCheck, 200),
389
+ signalsHit: Array.isArray(decision.signalsHit) ? decision.signalsHit.filter((s) => typeof s === 'string').slice(0, 8).map((s) => s.slice(0, 60)) : [],
390
+ round: count(decision.round) ?? 0, atSeq: seq,
391
+ };
392
+ }
393
+ }
394
+ return run;
395
+ }
396
+
397
+ // ─── Phase 2/3: worker + integration assessment ─────────────────────────────
398
+ function inScope(file, scope) {
399
+ if (!Array.isArray(scope) || scope.length === 0) return true;
400
+ return scope.some((entry) => file === entry || file.startsWith(`${entry.replace(/\/+$/, '')}/`));
401
+ }
402
+
403
+ /** Per executor-handback: is the worker's work EVIDENCED by attributed receipts, or only claimed? */
404
+ export function assessWorkers(run) {
405
+ const handbacks = (run?.handbacks || []).filter((h) => h.role === 'executor');
406
+ const workers = handbacks.map((h) => {
407
+ const worker = h.agentId ? run.workers?.[h.agentId] : null;
408
+ // Only Edit/Write receipts name the file they changed. A Bash-made change (heredoc, redirect, sed -i,
409
+ // script) is an anonymous workspace delta: it proves SOMETHING changed, never what or by which tool
410
+ // input, so it cannot stand as a worker's attributed write.
411
+ const writes = worker ? Object.keys(worker.files || {}).length : 0;
412
+ const bashWrites = worker ? worker.bash || 0 : 0;
413
+ const passes = worker ? (worker.verifications || []).filter((v) => v.success).length : 0;
414
+ const readOnlySteps = h.assignedSteps.length > 0 && h.assignedSteps.every((id) => id === 'analog-search');
415
+ let state;
416
+ if (h.outcome === 'failed') state = 'failed';
417
+ else if (h.isolated) state = 'isolated-unobserved';
418
+ else if (h.attribution !== 'native') state = 'unobservable';
419
+ else if (writes === 0 && bashWrites > 0) state = 'bash-unattributed';
420
+ else if (writes > 0 || passes > 0 || (readOnlySteps && worker?.sources > 0)) state = 'evidenced';
421
+ else state = 'claim-only';
422
+ const outOfScope = worker ? Object.keys(worker.files || {}).filter((f) => !inScope(f, h.scope)) : [];
423
+ return { agentId: h.agentId, agentType: h.agentType, state, writes, claimedPass: h.claimedPass, outOfScope, failures: worker?.failures || 0 };
424
+ });
425
+ const count = (state) => workers.filter((w) => w.state === state).length;
426
+ return {
427
+ workers,
428
+ total: workers.length,
429
+ evidenced: workers.length > 0 && workers.every((w) => w.state === 'evidenced'),
430
+ claimOnly: count('claim-only'),
431
+ bashUnattributed: count('bash-unattributed'),
432
+ unobservable: count('unobservable') + count('isolated-unobserved'),
433
+ failed: count('failed'),
434
+ outOfScope: workers.flatMap((w) => w.outOfScope),
435
+ retries: workers.filter((w) => w.state === 'failed').length,
436
+ };
437
+ }
438
+
439
+ /** Sequential vs concurrent from handback intervals [ts - durationMs, ts]; 'unknown' without durations. */
440
+ export function executionMode(run) {
441
+ const items = (run?.handbacks || []).filter((h) => h.role === 'executor');
442
+ if (items.length === 0) return 'none';
443
+ if (items.length === 1) return 'single';
444
+ if (items.some((h) => h.durationMs === null || !Number.isFinite(h.ts))) return 'unknown';
445
+ const spans = items.map((h) => [h.ts - h.durationMs, h.ts]).sort((a, b) => a[0] - b[0]);
446
+ for (let i = 1; i < spans.length; i += 1) if (spans[i][0] < spans[i - 1][1]) return 'concurrent';
447
+ return 'sequential';
448
+ }
449
+
450
+ /**
451
+ * Integration state of the run. Worker PASS and Integration PASS are separate
452
+ * facts: integration is `verified` only when a passing, current verification ran
453
+ * in the session root AFTER the last change (any actor) and the last executor
454
+ * handback, with every worker-written file present and un-contested.
455
+ */
456
+ export function assessIntegration(run, { cwd, verdictCurrent = null } = {}) {
457
+ const executors = (run?.handbacks || []).filter((h) => h.role === 'executor');
458
+ const reasons = [];
459
+ if (executors.length === 0) return { delegated: false, status: 'not-applicable', reasons, missing: [], conflicts: [] };
460
+ const writers = new Map();
461
+ for (const [agentId, worker] of Object.entries(run.workers || {})) {
462
+ for (const file of Object.keys(worker.files || {})) writers.set(file, [...(writers.get(file) || []), agentId]);
463
+ }
464
+ const missing = [];
465
+ if (cwd) {
466
+ for (const file of writers.keys()) {
467
+ try { if (!fsSync.statSync(path.resolve(cwd, file)).isFile()) missing.push(file); } catch { missing.push(file); }
468
+ }
469
+ }
470
+ const conflicts = [...writers.entries()].filter(([, ids]) => new Set(ids).size > 1).map(([file]) => file);
471
+ const barrier = Math.max(Number(run.changeSeq || 0), Number(run.lastExecutorHandbackSeq || 0));
472
+ const integration = run.integration;
473
+ let status;
474
+ if (conflicts.length) { status = 'conflict'; reasons.push(`conflicting-worker-writes:${conflicts.slice(0, 3).join(',')}`); }
475
+ else if (missing.length) { status = 'missing'; reasons.push(`missing-from-integration:${missing.slice(0, 3).join(',')}`); }
476
+ else if (!integration) { status = 'pending'; reasons.push('no-integration-verification'); }
477
+ else if (integration.status === 'failed') {
478
+ if (integration.atSeq > barrier) { status = 'failed'; reasons.push('integration-verification-failed'); }
479
+ else { status = 'pending'; reasons.push('failed-verification-predates-latest-change'); }
480
+ } else if (integration.atSeq <= Number(run.lastExecutorHandbackSeq || 0)) {
481
+ status = 'stale'; reasons.push('verification-predates-integrated-changes');
482
+ } else if (integration.atSeq <= Number(run.changeSeq || 0)) {
483
+ status = 'stale'; reasons.push('edited-after-verification');
484
+ } else if (typeof verdictCurrent === 'function' && integration.verdict
485
+ && verdictCurrent(integration.verdict, { cwd }).current !== true) {
486
+ status = 'stale'; reasons.push('source-state-changed-since-verification');
487
+ } else status = 'verified';
488
+ return { delegated: true, status, reasons, missing, conflicts, verifiedAtSeq: integration?.atSeq ?? null, command: integration?.command ?? null };
489
+ }
490
+
491
+ // ─── Phase 4: review inputs and obligation ──────────────────────────────────
492
+ /**
493
+ * Pure mapping from run facts to the EXISTING review policy's inputs. This
494
+ * module decides nothing: stop-coordinator merges these into its single
495
+ * evaluateReviewPolicy call (no second decision plane). Non-delegated runs
496
+ * return {applicable:false} and the policy sees exactly what it saw before.
497
+ */
498
+ export function playbookReviewInputs({ run, workers, integration, riskHigh = false, routeFixLoopCount = 0 } = {}) {
499
+ if (!integration?.delegated) return { applicable: false, signals: {}, doneCriteria: [], fixLoopCount: 0, escalationDepth: null };
500
+ const workerStatus = workers.total === 0 ? 'missing'
501
+ : workers.evidenced ? 'verified'
502
+ : workers.claimOnly > 0 ? 'claimed' : 'missing';
503
+ const integrationStatus = integration.status === 'verified' ? 'verified'
504
+ : integration.status === 'pending' && run?.integration == null ? 'missing' : 'claimed';
505
+ const signals = {};
506
+ if (workers.outOfScope.length > 0) signals['wider-than-goal'] = true;
507
+ if (integration.conflicts.length > 0 || integration.missing.length > 0) signals['unconfirmed-assumptions'] = true;
508
+ if ((run?.workers && Object.values(run.workers).reduce((n, w) => n + (w.failures || 0), 0)) >= 3) signals['tool-error-storm'] = true;
509
+ return {
510
+ applicable: true,
511
+ signals,
512
+ doneCriteria: [
513
+ { name: 'worker-execution', status: workerStatus },
514
+ { name: 'integration-verification', status: integrationStatus },
515
+ ],
516
+ fixLoopCount: Math.max(Number(routeFixLoopCount) || 0, workers.retries),
517
+ escalationDepth: riskHigh ? 'review-round' : null,
518
+ };
519
+ }
520
+
521
+ /** Dispatch hint for the existing reviewer agent. Effort High only with justified evidence + native support. */
522
+ export function reviewDispatchHint({ decision, workers, integration, host = 'claude-code', riskHigh = false } = {}) {
523
+ if (decision?.action !== 'independent-review') return null;
524
+ const suspicious = Array.isArray(decision.signalsHit) && decision.signalsHit.length > 0;
525
+ const justified = workers.retries >= 2 || (suspicious && (riskHigh || integration.status === 'conflict' || integration.status === 'missing'));
526
+ const { effort } = resolveStepEffort({ complexity: justified ? 'high' : 'medium' }, { host });
527
+ return {
528
+ subagent_type: 'code-reviewer',
529
+ effort,
530
+ reason: (decision.namedCheck || 'independent review required by the review policy').slice(0, 240),
531
+ brief: 'Check (1) the original request, (2) the actual diff of the integrated state, (3) the integration verification evidence. Reply with a Verdict line: APPROVED | APPROVED-WITH-MINOR | CHANGES-REQUESTED | CRITICAL.',
532
+ };
533
+ }
534
+
535
+ /**
536
+ * Is the review obligation satisfied? finish → not required; run-targeted-check
537
+ * → pending (a later decision must say finish); independent-review → needs a
538
+ * reviewer handback with an approving verdict AFTER the last change; escalate →
539
+ * unsatisfied; inconclusive (round cap) → unverified, never a pass.
540
+ */
541
+ export function reviewObligation(run, decision) {
542
+ const action = decision?.action ?? null;
543
+ if (!action || action === 'finish') return { required: false, satisfied: true, state: 'not-required' };
544
+ if (action === 'run-targeted-check') return { required: true, satisfied: false, state: 'targeted-check-pending' };
545
+ if (action === 'escalate') return { required: true, satisfied: false, state: 'escalated' };
546
+ if (action === 'inconclusive') return { required: true, satisfied: false, state: 'inconclusive' };
547
+ const reviewer = run?.reviewer;
548
+ if (reviewer && reviewer.outcome === 'completed' && APPROVING_VERDICTS.has(reviewer.verdict)
549
+ && reviewer.seq > Number(run.changeSeq || 0)) {
550
+ return { required: true, satisfied: true, state: 'approved', verdict: reviewer.verdict };
551
+ }
552
+ if (reviewer && reviewer.seq > Number(run?.changeSeq || 0) && reviewer.verdict) {
553
+ return { required: true, satisfied: false, state: 'changes-requested', verdict: reviewer.verdict };
554
+ }
555
+ return { required: true, satisfied: false, state: reviewer ? 'review-stale' : 'review-pending' };
556
+ }
557
+
558
+ // ─── Phase 5: final acceptance ──────────────────────────────────────────────
559
+ /**
560
+ * The final Playbook verdict, one fact per obligation:
561
+ * requiredSteps · workerExecution · integration · review · executor
562
+ * PASS only when every fact holds. A fact the engine cannot observe makes the
563
+ * verdict UNVERIFIED with the gap named in `unsupported` — never a silent PASS.
564
+ * NOT_APPLICABLE for any other playbook: their behaviour is unchanged.
565
+ */
566
+ export function evaluatePlaybookAcceptance({
567
+ playbookId, missingSteps = [], requiredSteps = [], run = null, cwd, verdictCurrent = null,
568
+ reviewDecision = null, delegationRequired = false, host = 'claude-code', contractEvidence = null,
569
+ delegationBlocked = false,
570
+ } = {}) {
571
+ if (playbookId !== FEATURE_PLAYBOOK_ID) return { applicable: false, delegated: false, verdict: 'NOT_APPLICABLE', facts: {}, reasons: [], unsupported: [] };
572
+ const integration = assessIntegration(run || emptyPlaybookRun(), { cwd, verdictCurrent });
573
+ const workers = assessWorkers(run || emptyPlaybookRun());
574
+ const delegated = integration.delegated;
575
+ const reasons = [];
576
+ const unsupported = [];
577
+ const attributionSupported = normalizeHost(host) === 'claude-code';
578
+ const facts = {
579
+ requiredSteps: { ok: missingSteps.length === 0, required: requiredSteps, missing: missingSteps },
580
+ workerExecution: { applicable: delegated, ok: !delegated || workers.evidenced, workers: workers.total, claimOnly: workers.claimOnly, unobservable: workers.unobservable, failed: workers.failed },
581
+ integration: { applicable: delegated, ok: !delegated || integration.status === 'verified', status: integration.status, reasons: integration.reasons },
582
+ review: { required: false, ok: true, state: 'not-required' },
583
+ executor: { required: delegationRequired === true, ok: true, inlineWrites: run?.inlineWrites || 0 },
584
+ };
585
+ if (missingSteps.length) reasons.push(`missing-required-step:${missingSteps.join(',')}`);
586
+ if (delegationBlocked) { facts.executor.ok = false; reasons.push('delegation-violation-blocked'); }
587
+ if (delegationRequired && (!delegated || normalizeHost(host) === 'omp')) {
588
+ // Inline writes are NEGATIVE evidence the orchestrator itself edited, and that holds on omp too:
589
+ // its ledgers are session-keyed and a worker runs in its OWN session (live: the main ledger held
590
+ // only the main's edit/bash calls, the worker's tool calls sat in the worker's ledger). What omp
591
+ // cannot do is prove POSITIVE worker attribution (no agent type / tool-call correlation reaches
592
+ // hooks), so absent inline writes it stays UNVERIFIED, never PASS.
593
+ if ((run?.inlineWrites || 0) > 0 && (attributionSupported || normalizeHost(host) === 'omp')) { facts.executor.ok = false; reasons.push('delegation-required-not-performed'); }
594
+ else if (!attributionSupported) unsupported.push('executor-attribution');
595
+ }
596
+ if (delegated) {
597
+ if (workers.claimOnly > 0) reasons.push('worker-claim-without-evidence');
598
+ if (workers.bashUnattributed > 0) reasons.push('worker-bash-write-unattributed');
599
+ if (workers.failed > 0 && !workers.evidenced) reasons.push('worker-failed');
600
+ if (workers.unobservable > 0) unsupported.push('worker-attribution');
601
+ if (!['verified'].includes(integration.status)) reasons.push(...integration.reasons.length ? integration.reasons : [`integration-${integration.status}`]);
602
+ const analysis = reviewObligation(run, reviewDecision);
603
+ facts.review = { required: analysis.required, ok: analysis.satisfied, state: analysis.state };
604
+ if (analysis.required && !analysis.satisfied) {
605
+ if (analysis.state === 'inconclusive') unsupported.push('review-inconclusive');
606
+ else reasons.push(`review-${analysis.state}`);
607
+ }
608
+ }
609
+ const verdictOf = () => (reasons.length > 0 ? 'NOT_PASS' : unsupported.length > 0 ? 'UNVERIFIED' : 'PASS');
610
+ const runtimeSlice = { verdict: verdictOf(), reasons: [...new Set(reasons)], unsupported: [...new Set(unsupported)] };
611
+ // Full-playbook scope: every contract evidence the route demanded must also be proven, and a
612
+ // conditional md step (6) that is active must be satisfied. An unknown contract = UNVERIFIED.
613
+ const required = Array.isArray(contractEvidence?.required) ? contractEvidence.required : null;
614
+ const missing = Array.isArray(contractEvidence?.missing) ? contractEvidence.missing : [];
615
+ const conditional = FEATURE_UNMAPPED_MD_STEPS.filter((m) => m.class === 'conditional').map((m) => {
616
+ const active = required ? required.includes(m.evidence) : null;
617
+ return { mdStep: m.mdStep, evidence: m.evidence, status: active === null ? 'unknown' : !active ? 'not-applicable' : missing.includes(m.evidence) ? 'missing' : 'verified' };
618
+ });
619
+ if (required === null) unsupported.push('contract-evidence-unknown');
620
+ if (missing.length) reasons.push(`contract-evidence-missing:${missing.join(',')}`);
621
+ facts.contractEvidence = { known: required !== null, required: required || [], missing };
622
+ const coverage = {
623
+ mapped: FEATURE_IMPLEMENTATION_STEPS.map((s) => s.id),
624
+ conditional,
625
+ guidance: FEATURE_UNMAPPED_MD_STEPS.filter((m) => m.class === 'guidance').map((m) => m.mdStep),
626
+ advisory: 'guidance steps carry no receipt; they never block and never count as verified',
627
+ };
628
+ return {
629
+ applicable: true, delegated, scope: 'full-playbook', verdict: verdictOf(), runtimeSlice, coverage, facts,
630
+ reasons: [...new Set(reasons)], unsupported: [...new Set(unsupported)],
631
+ };
632
+ }
633
+
634
+ /** One advisory line for the Stop message / status. Null when nothing to say. */
635
+ export function formatAcceptance(acceptance) {
636
+ if (!acceptance?.applicable) return null;
637
+ if (!acceptance.delegated && !acceptance.reasons.includes('delegation-required-not-performed')) return null;
638
+ const parts = [`playbook acceptance: ${acceptance.verdict}${acceptance.runtimeSlice && acceptance.runtimeSlice.verdict !== acceptance.verdict ? ` (runtime slice: ${acceptance.runtimeSlice.verdict})` : ''}`];
639
+ if (acceptance.reasons.length) parts.push(`reasons: ${acceptance.reasons.slice(0, 4).join('; ')}`);
640
+ if (acceptance.unsupported.length) parts.push(`unsupported: ${acceptance.unsupported.join(', ')}`);
641
+ return parts.join(' — ');
642
+ }
643
+
644
+ // ─── Phase 6: minimal telemetry summary ─────────────────────────────────────
645
+ /**
646
+ * Bounded, non-sensitive facts for the `outcome.observed` record: ids and
647
+ * counts only — no prompts, no file paths, no reply text. `observed` model /
648
+ * effort stay null unless the host response actually carried them.
649
+ */
650
+ export function summarizePlaybookRun({ playbookId, taskInstanceId = null, trace = null, run = null, acceptance = null, reviewDecision = null, host = 'claude-code' } = {}) {
651
+ if (playbookId !== FEATURE_PLAYBOOK_ID || !acceptance?.applicable) return null;
652
+ const workers = assessWorkers(run || emptyPlaybookRun());
653
+ const handbacks = (run?.handbacks || []).filter((h) => h.role === 'executor');
654
+ const sum = (key) => handbacks.reduce((n, h) => n + (h.tokens?.[key] ?? 0), 0);
655
+ const withTokens = handbacks.filter((h) => h.tokens);
656
+ const first = handbacks[0] || null;
657
+ return {
658
+ playbookId,
659
+ taskInstanceId: taskInstanceId ? String(taskInstanceId).slice(0, 96) : null,
660
+ host: normalizeHost(host),
661
+ stepsRequired: Array.isArray(trace?.requiredSteps) ? trace.requiredSteps.length : 0,
662
+ stepsDone: Array.isArray(trace?.stepsDone) ? trace.stepsDone.length : 0,
663
+ workerCount: workers.total,
664
+ executionMode: executionMode(run),
665
+ requested: { tier: first?.logicalTier ?? null, model: first?.requested.model ?? null, effort: first?.requested.effort ?? null },
666
+ // requested = the dispatch parameters the caller passed; resolved = what UKit's step contract
667
+ // resolves for this host (a hint, not an observation); effective = only what the host response
668
+ // reported — UNKNOWN when it did not, UNSUPPORTED effort where the host has no per-dispatch knob.
669
+ resolved: (() => {
670
+ const r = resolveStepEffort(FEATURE_IMPLEMENTATION_STEPS.find((st) => st.id === 'implement'), { host, failures: workers.retries });
671
+ return { tier: 'code', effort: r.effort, effortBinding: r.binding };
672
+ })(),
673
+ observed: { model: first?.observed.model ?? null, effort: first?.observed.effort ?? null },
674
+ effective: {
675
+ model: first?.observed.model ?? 'UNKNOWN',
676
+ effort: first?.observed.effort ?? (HOST_EFFORT_BINDING[normalizeHost(host)] === 'none' ? 'UNSUPPORTED' : 'UNKNOWN'),
677
+ },
678
+ durationMs: handbacks.length && handbacks.every((h) => h.durationMs !== null) ? handbacks.reduce((n, h) => n + h.durationMs, 0) : null,
679
+ retries: workers.retries,
680
+ workerOutcomes: { evidenced: workers.workers.filter((w) => w.state === 'evidenced').length, claimOnly: workers.claimOnly, unobservable: workers.unobservable, failed: workers.failed },
681
+ tokens: withTokens.length ? { input: sum('input'), output: sum('output'), cacheRead: sum('cacheRead'), cacheCreate: sum('cacheCreate'), total: sum('total'), provenance: 'PROVIDER' } : null,
682
+ integration: acceptance.facts.integration?.status ?? 'not-applicable',
683
+ review: reviewDecision?.action ?? null,
684
+ reviewState: acceptance.facts.review?.state ?? null,
685
+ acceptance: acceptance.verdict,
686
+ };
687
+ }
688
+
689
+ /** Cost per VERIFIED completion over a list of `playbook` telemetry summaries (no learning, just arithmetic). */
690
+ export function playbookCostPerVerified(summaries = []) {
691
+ const rows = (Array.isArray(summaries) ? summaries : []).filter((s) => s && typeof s === 'object');
692
+ const verified = rows.filter((s) => s.acceptance === 'PASS');
693
+ const total = (list) => list.reduce((n, s) => n + (s.tokens?.total ?? 0), 0);
694
+ const known = rows.filter((s) => s.tokens);
695
+ return {
696
+ completions: rows.length,
697
+ verified: verified.length,
698
+ notVerified: rows.length - verified.length,
699
+ tokensAll: total(rows),
700
+ tokensVerified: total(verified),
701
+ unknownCost: rows.length - known.length,
702
+ tokensPerVerified: verified.length ? Math.round(total(rows) / verified.length) : null,
703
+ };
704
+ }