@ngockhoale/ukit 3.4.46 → 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.
- package/CHANGELOG.md +13 -5
- package/manifests/documentation.yaml +22 -0
- package/manifests/platform.full.yaml +27 -0
- package/package.json +4 -1
- package/scripts/dev/capture-omp-stall.mjs +246 -0
- package/scripts/dev/worktree-preflight.mjs +20 -1
- package/scripts/docs/render-instructions.mjs +29 -5
- package/src/core/agentRuntime/supervisor.js +15 -1
- package/src/core/diffPlan.js +38 -12
- package/src/core/executionContracts.js +67 -16
- package/src/core/metadata.js +4 -0
- package/src/core/runInstallPipeline.js +19 -2
- package/src/core/runtimeConfig.js +1 -1
- package/src/diagnostics/ompStallSnapshot.js +199 -0
- 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 +147 -6
- 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 +67 -15
- 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 +306 -20
- 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 +80 -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
|
@@ -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
|
+
}
|