@ngockhoale/ukit 3.4.45 → 3.4.47

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.
@@ -0,0 +1,199 @@
1
+ // Development-only incident capture: bounded metadata, no session control or payload persistence.
2
+ import fs from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { spawn } from 'node:child_process';
5
+
6
+ const ID_PATTERN = /^[\w.:/-]{1,120}$/;
7
+ const id = (value) => typeof value === 'string' && ID_PATTERN.test(value) ? value : null;
8
+ const time = (value) => Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
9
+
10
+
11
+ export async function runBoundedProcess(command, args = [], { timeoutMs = 650, maxBuffer = 65536 } = {}) {
12
+ return new Promise((resolve) => {
13
+ let settled = false;
14
+ let timedOut = false;
15
+ let oversized = false;
16
+ let stdoutBytes = 0;
17
+ let stderrBytes = 0;
18
+ const stdout = [];
19
+ const stderr = [];
20
+ let timer;
21
+ const finish = (result) => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } };
22
+ let child;
23
+ try { child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); }
24
+ catch (error) { resolve({ ok: false, errorCode: error.code || 'UNKNOWN', stdout: '', stderr: '' }); return; }
25
+ timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, Math.max(1, timeoutMs));
26
+ const collect = (chunks, field) => (chunk) => {
27
+ const next = field === 'stdout' ? stdoutBytes + chunk.length : stderrBytes + chunk.length;
28
+ if (next + (field === 'stdout' ? stderrBytes : stdoutBytes) > maxBuffer) { oversized = true; child.kill('SIGKILL'); return; }
29
+ chunks.push(chunk);
30
+ if (field === 'stdout') stdoutBytes = next; else stderrBytes = next;
31
+ };
32
+ child.stdout.on('data', collect(stdout, 'stdout'));
33
+ child.stderr.on('data', collect(stderr, 'stderr'));
34
+ child.on('error', (error) => finish({ ok: false, errorCode: error.code || 'UNKNOWN', stdout: '', stderr: '' }));
35
+ child.on('close', (code) => finish({ ok: !timedOut && !oversized && code === 0,
36
+ errorCode: timedOut ? 'ETIMEDOUT' : oversized ? 'MAXBUFFER' : code === 0 ? null : 'EXIT_NONZERO',
37
+ exitCode: code, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') }));
38
+ });
39
+ }
40
+
41
+ export async function readBoundedTail(file, { maxBytes = 262144 } = {}) {
42
+ const cap = Math.max(1, Math.min(1048576, maxBytes));
43
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NONBLOCK || 0) | (constants.O_NOFOLLOW || 0));
44
+ try {
45
+ const stat = await handle.stat();
46
+ if (!stat.isFile()) throw new Error('capture requires a regular file');
47
+ const size = Math.min(cap, stat.size);
48
+ const buffer = Buffer.alloc(size);
49
+ const { bytesRead } = await handle.read(buffer, 0, size, Math.max(0, stat.size - size));
50
+ let text = buffer.subarray(0, bytesRead).toString('utf8');
51
+ const truncated = stat.size > size;
52
+ if (truncated) text = text.includes('\n') ? text.slice(text.indexOf('\n') + 1) : '';
53
+ return { text, bytesRead, truncated, size: stat.size, modifiedAt: stat.mtimeMs };
54
+ } finally { await handle.close(); }
55
+ }
56
+
57
+ const operationKinds = ['model', 'tool', 'hook', 'subagent'];
58
+ const activeStates = new Set(['started', 'running', 'pending', 'waiting']);
59
+ const terminalStates = new Set(['completed', 'finished', 'failed', 'stopped', 'cancelled']);
60
+ export function processInventoryCommand(platform) {
61
+ return platform === 'win32' ? ['tasklist', ['/FO', 'CSV', '/NH']]
62
+ : ['ps', ['-A', '-o', 'pid=,ppid=,stat=,etime=,comm=']];
63
+ }
64
+
65
+ export function parseProcessInventory(platform, text) {
66
+ if (platform === 'win32') return text.split(/\r?\n/).flatMap((line) => {
67
+ const fields = line.match(/"(?:[^"]|"")*"|[^,]+/g) || [];
68
+ const name = fields[0]?.replace(/^"|"$/g, '').replace(/""/g, '"');
69
+ const pid = Number(fields[1]?.replaceAll('"', ''));
70
+ return Number.isSafeInteger(pid) && pid > 0 ? [{ pid, name: (name || '').split(/[\\/]/).at(-1) }] : [];
71
+ });
72
+ return text.split('\n').flatMap((line) => {
73
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+)$/);
74
+ return match ? [{ pid: Number(match[1]), name: match[5].trim().split(/[\\/]/).at(-1) }] : [];
75
+ });
76
+ }
77
+
78
+ export function parseOwnerPids(command, text) {
79
+ const values = command === 'fuser' ? (text.match(/:\s*([^\r\n]+)/g) || []).flatMap((line) => line.slice(line.indexOf(':') + 1).trim().split(/\s+/).map((token) => token.match(/^(\d+)[A-Za-z]*$/)?.[1]).filter(Boolean))
80
+ : (text.match(/\b\d+\b/g) || []);
81
+ return [...new Set(values.map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0))];
82
+ }
83
+
84
+ export function classifySessionActivity({ association = 'NOT_OBSERVABLE', summary = null, now = Date.now(), idleMs = 120000 } = {}) {
85
+ if (summary?.operatorBlock === 'OBSERVED_BLOCK') return 'OPERATOR_BLOCK';
86
+ if (summary?.sessionState === 'STOPPED') return 'STOPPED';
87
+ if (association !== 'ACTIVE_OWNER' || !summary) return 'NOT_OBSERVABLE';
88
+ const progressAge = Number.isFinite(summary.lastProgressAt) ? Math.max(0, now - summary.lastProgressAt) : null;
89
+ const outputAge = Number.isFinite(summary.lastOutputAt) ? Math.max(0, now - summary.lastOutputAt) : null;
90
+ const pending = summary.pendingOperations || {};
91
+ if (progressAge === null) return 'NOT_OBSERVABLE';
92
+ if (progressAge >= idleMs) return 'SUSPECTED_STALL';
93
+ if (Object.values(pending).some((items) => Array.isArray(items) && items.length > 0)
94
+ && Object.entries(pending).some(([kind, items]) => items.length > 0 && summary.operationObservability?.[kind] === 'OBSERVED')) return 'WAITING';
95
+ if (outputAge !== null && outputAge >= idleMs) return 'NO_OUTPUT';
96
+ return 'ACTIVE_PROGRESS';
97
+ }
98
+
99
+
100
+ export function summarizeSessionTail(text) {
101
+ const result = { sessionId: null, modelAlias: 'NOT_OBSERVABLE', resolvedRoute: 'NOT_OBSERVABLE', currentTurn: 'NOT_OBSERVABLE', lastCompletedTurn: 'NOT_OBSERVABLE',
102
+ pendingModelRequest: 'NOT_OBSERVABLE', lastChunk: 'NOT_OBSERVABLE', terminalEvent: 'NOT_OBSERVABLE', pendingTools: [], unmatchedToolCallCandidates: [], pendingSubagent: 'NOT_OBSERVABLE',
103
+ pendingOperations: { model: [], tool: [], hook: [], subagent: [] }, operationObservability: { model: 'NOT_OBSERVABLE', tool: 'NOT_OBSERVABLE', hook: 'NOT_OBSERVABLE', subagent: 'NOT_OBSERVABLE' },
104
+ operatorBlock: 'NOT_OBSERVABLE', turnState: 'NOT_OBSERVABLE', sessionState: 'NOT_OBSERVABLE', compaction: 'NOT_OBSERVABLE', stopContinuation: 'NOT_OBSERVABLE',
105
+ lastProgressAt: 'NOT_OBSERVABLE', lastOutputAt: 'NOT_OBSERVABLE', outputState: 'NOT_OBSERVABLE' };
106
+ const operations = Object.fromEntries(operationKinds.map((kind) => [kind, new Map()]));
107
+ const tools = new Map();
108
+ const observedOperations = new Set();
109
+ const operatorBlocks = new Map();
110
+ for (const line of text.split('\n')) {
111
+ let row;
112
+ try { row = JSON.parse(line); } catch { continue; }
113
+ const ts = time(row.timestamp);
114
+ if (ts) result.lastProgressAt = Date.parse(ts);
115
+ if (row.type === 'session' && id(row.id)) { result.sessionId = id(row.id); result.sessionIdentityObserved = true; }
116
+ if (row.type === 'compaction') result.compaction = { timestamp: ts, id: id(row.id), tokensBefore: Number.isFinite(row.tokensBefore) ? row.tokensBefore : null };
117
+ if (row.type === 'session_end' || row.type === 'session_stopped') result.sessionState = 'STOPPED';
118
+
119
+ if (row.type === 'operation' && operationKinds.includes(row.operation) && id(row.id)) {
120
+ observedOperations.add(row.operation);
121
+ const state = typeof row.state === 'string' ? row.state.toLowerCase() : '';
122
+ if (activeStates.has(state)) operations[row.operation].set(row.id, { id: row.id, ...(id(row.name) ? { name: id(row.name) } : {}) });
123
+ if (terminalStates.has(state)) operations[row.operation].delete(row.id);
124
+ }
125
+ const eventState = String(row.state || '').toLowerCase();
126
+ if (row.type === 'operator_block' || row.operation === 'operator') {
127
+ const key = id(row.id) || `block-${operatorBlocks.size}`;
128
+ if (['pending', 'blocked', 'waiting'].includes(eventState)) operatorBlocks.set(key, { id: id(row.id), state: 'BLOCKED' });
129
+ if (terminalStates.has(eventState) || ['unblocked', 'cleared'].includes(eventState)) operatorBlocks.delete(key);
130
+ }
131
+
132
+ const message = row.message;
133
+ if (!message) continue;
134
+ if (message.role === 'toolResult' && id(message.toolCallId)) tools.delete(message.toolCallId);
135
+ if (message.role !== 'assistant') continue;
136
+ result.modelAlias = { provider: id(message.provider), model: id(message.model) };
137
+ if (message.stopReason) {
138
+ const stopReason = ['stop', 'toolUse', 'error', 'aborted', 'length'].includes(message.stopReason) ? message.stopReason : 'other';
139
+ result.lastCompletedTurn = { id: id(row.id), timestamp: ts, stopReason };
140
+ result.terminalEvent = stopReason;
141
+ result.turnState = message.stopReason === 'aborted' ? 'TURN_ABORTED' : message.stopReason === 'error' ? 'TURN_ERROR' : 'TURN_COMPLETED';
142
+ }
143
+ if (Array.isArray(message.content)) {
144
+ for (const part of message.content) {
145
+ if (part.type === 'toolCall' && id(part.id)) tools.set(part.id, { id: id(part.id), name: id(part.name) });
146
+ if (part.type === 'text' && ts) {
147
+ result.lastOutputAt = Date.parse(ts);
148
+ result.outputState = 'OBSERVED_OUTPUT';
149
+ }
150
+ }
151
+ }
152
+ }
153
+ result.unmatchedToolCallCandidates = [...tools.values()].slice(-32);
154
+ result.pendingOperations = Object.fromEntries(operationKinds.map((kind) => [kind, [...operations[kind].values()].slice(-32)]));
155
+ result.pendingTools = result.pendingOperations.tool;
156
+ result.operationObservability = Object.fromEntries(operationKinds.map((kind) => [kind, observedOperations.has(kind) || (kind === 'tool' && tools.size > 0) ? 'OBSERVED' : 'NOT_OBSERVABLE']));
157
+ result.pendingModelRequest = result.operationObservability.model === 'OBSERVED' ? (result.pendingOperations.model.length ? 'OBSERVED_PENDING_EVENT' : 'NO_PENDING_EVENT_OBSERVED') : 'NOT_OBSERVABLE';
158
+ result.pendingSubagent = result.operationObservability.subagent === 'OBSERVED' ? (result.pendingOperations.subagent.length ? 'OBSERVED_PENDING_EVENT' : 'NO_PENDING_EVENT_OBSERVED') : 'NOT_OBSERVABLE';
159
+ result.operatorBlock = operatorBlocks.size ? 'OBSERVED_BLOCK' : 'NOT_OBSERVABLE';
160
+ return result;
161
+ }
162
+
163
+ export function associateSessionEvidence({ sessions = [], ownerLocks = {}, processInventory = { state: 'NOT_OBSERVABLE', processes: [] }, logs = [] } = {}) {
164
+ const sessionCounts = new Map();
165
+ for (const session of sessions) if (id(session.sessionId)) sessionCounts.set(session.sessionId, (sessionCounts.get(session.sessionId) || 0) + 1);
166
+ return sessions.map((session) => {
167
+ const sessionId = id(session.sessionId);
168
+ const lock = sessionId ? ownerLocks[sessionId] : null;
169
+ const lockState = lock?.state || 'NOT_OBSERVABLE';
170
+ const pids = lockState === 'OBSERVED' && Array.isArray(lock.pids) ? [...new Set(lock.pids.filter((pid) => Number.isSafeInteger(pid) && pid > 0))] : [];
171
+ const uniqueOwnerPid = pids.length === 1 ? pids[0] : null;
172
+ const duplicateSession = sessionId !== null && sessionCounts.get(sessionId) > 1;
173
+ const process = uniqueOwnerPid !== null && processInventory.state === 'OBSERVED'
174
+ ? processInventory.processes.find((entry) => entry.pid === uniqueOwnerPid && /^omp(?:\.exe)?$/i.test(entry.name)) || null : null;
175
+ const processAssociation = duplicateSession ? 'AMBIGUOUS_TRANSCRIPT' : pids.length > 1 ? 'AMBIGUOUS_OWNER_LOCK'
176
+ : uniqueOwnerPid === null ? (lockState === 'NOT_OBSERVABLE' ? 'NOT_OBSERVABLE' : 'UNHELD')
177
+ : processInventory.state !== 'OBSERVED' ? 'NOT_OBSERVABLE' : process ? 'VERIFIED' : 'PID_NOT_FOUND';
178
+ const ownerPid = duplicateSession ? null : uniqueOwnerPid;
179
+ const identityVerified = session?.sessionIdentityObserved === true || session?.sourceCoverage?.sessionIdentityObserved === true;
180
+ const ompLog = ownerPid === null ? null : logs.find((entry) => entry.pid === ownerPid) || null;
181
+ const logAssociation = ownerPid === null ? 'NOT_OBSERVABLE' : ompLog ? 'VERIFIED' : 'NOT_OBSERVABLE';
182
+ return { sessionId, transcript: typeof session.transcriptPath === 'string' ? session.transcriptPath.split(/[\\/]/).at(-1) : null,
183
+ sessionAssociation: identityVerified && processAssociation === 'VERIFIED' && logAssociation === 'VERIFIED' ? 'ACTIVE_OWNER' : 'NOT_OBSERVABLE',
184
+ ownerLockState: duplicateSession ? 'AMBIGUOUS_TRANSCRIPT' : lockState, ownerPid, processAssociation, logAssociation, identityVerified,
185
+ process: process ? { pid: process.pid, name: id(process.name) } : null, ompLog };
186
+ });
187
+ }
188
+
189
+ export function buildStallSnapshot({ now = Date.now(), lastProgressAt = null, lastOutputAt = null, idleMs = 120000, executionState = 'NOT_OBSERVABLE', ...evidence } = {}) {
190
+ const age = (ts) => Number.isFinite(ts) ? Math.max(0, now - ts) : null;
191
+ const progressAgeMs = age(lastProgressAt);
192
+ const outputAgeMs = age(lastOutputAt);
193
+ const activity = evidence.activity || (executionState === 'NO_EXECUTION' ? 'NO_EXECUTION'
194
+ : executionState !== 'EXECUTING' ? 'NOT_OBSERVABLE'
195
+ : progressAgeMs === null ? 'NOT_OBSERVABLE' : progressAgeMs >= idleMs ? 'NO_OBSERVED_PROGRESS'
196
+ : outputAgeMs !== null && outputAgeMs >= idleMs ? 'NO_OUTPUT' : 'RECENT_PROGRESS');
197
+ return { schemaVersion: 1, capturedAt: new Date(now).toISOString(), verdict: 'INSUFFICIENT_EVIDENCE', activity,
198
+ executionState, progressAgeMs, outputAgeMs, ...evidence };
199
+ }
@@ -1818,25 +1818,39 @@ function nativeOperationGoal(domain, actionable, text) {
1818
1818
  // FIRST item of an enumeration ("Do not implement fixes, modify source code, restart services, ...") and
1819
1819
  // the later items became actionable orders (MIXED + service-runtime). English negators now scope to the
1820
1820
  // sentence end (', and'/', or' continue the list; a bare 'and <verb>' or but/then starts a new clause);
1821
- // Vietnamese negators stay clause-local. Noun uses of verbs ("write/verification advisory", "each proposed
1821
+ // Vietnamese negators scope to the sentence end as well. Noun uses of verbs ("write/verification advisory", "each proposed
1822
1822
  // fix", "state change") name things, not orders. Neutralised spans become blanks, never new orders.
1823
1823
  const NON_ORDER_VERBS = 'implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|restart|sua|viet|chinh|them|tao|xoa';
1824
1824
  function neutralizeNonOrders(text) {
1825
1825
  return String(text)
1826
1826
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|shall\\s+not)\\s+[^.;\\n]*?(?=(?<!,)\\s+(?:and|va)\\s+(?:${NON_ORDER_VERBS})\\b|\\s+(?:but|nhung|then|however)\\b|[.;\\n]|$)`, 'g'), ' ')
1827
+ .replace(new RegExp(`\\b(?:khong|chua)\\s+[^.;\\n]*?(?=\\s+(?:nhung|tuy nhien)\\b|[.;\\n]|$)`, 'g'), ' ')
1828
+ .replace(/"[^"\n]*\s[^"\n]*"|`[^`\n]*\s[^`\n]*`|\u201c[^\u201d\n]*\u201d/g, ' ')
1829
+ .replace(/\b(?:neu\s+(?:phai|can|thieu|khong du|bang chung chua du)|if\s+(?:needed|necessary|required|evidence is insufficient))\b[^.;\n]*/g, ' ')
1830
+ .replace(/[^.;\n]*\b(?:nao|gi|sao|the nao|bao nhieu)\b[^.;\n]*\?/g, ' ')
1831
+ .replace(/\bchinh\s+xac\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b/g, ' ')
1827
1832
  .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1828
1833
  .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1829
1834
  .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
1830
1835
  }
1831
1836
 
1832
1837
  function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1833
- const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase());
1838
+ // A named preflight and both explicit branches are required; a failed arbitrary test is not a release policy.
1839
+ const releaseText = String(promptText).normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
1840
+ const preflight = releaseText.match(/\b(?:yarn\s+release:verify|npm\s+run\s+release:verify)\b/)?.[0];
1841
+ const releasePolicy = preflight && /\b(?:if|neu|only when|only if)\b[^\n.;]*(?:pass|go)\b[^\n.;]*(?:release|publish)\b/.test(releaseText)
1842
+ && /\b(?:fail|no-go)\b[^\n.;]*(?:do not publish|don't publish|no publish|khong publish|khong npm publish)\b/.test(releaseText)
1843
+ ? { kind: 'conditional-release', preflight } : null;
1844
+ const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase());
1834
1845
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1835
1846
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1836
1847
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1837
1848
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1838
1849
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1839
1850
  const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
1851
+ const reportArtifact = String(promptText).match(/(?:^|\s)((?:docs\/|reports\/)[A-Za-z0-9_./-]+\.(?:md|txt))(?=\s|[.,;:]|$)/)?.[1];
1852
+ const documentation = !sourceOrder && !/\b(?:implement|fix|refactor|modify|edit|update|sua|chinh)\b/.test(actionable.join(' ')) && reportArtifact && actionable.some((clause) => /\b(?:write|create|save|viet|tao|luu)\b/.test(clause))
1853
+ && /\b(?:report|bao cao|analysis|phan tich|document)\b/.test(text);
1840
1854
  const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
1841
1855
  // A cron/job MENTION is not an operation: with a code-change verb in the request, an
1842
1856
  // operation needs a concrete target id; otherwise it is a coding task about scheduling.
@@ -1861,13 +1875,13 @@ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1861
1875
  // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
1862
1876
  const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
1863
1877
  const statusReport = !mutationWord && !operation && !config
1864
- && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
1865
- const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
1878
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text);
1879
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|debug|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text) || statusReport)
1866
1880
  && !sourceOrder && !codeChangeVerb && !operation && !config;
1867
1881
  const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
1868
- : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1882
+ : config ? 'CONFIGURATION' : documentation ? 'DOCUMENTATION_OUTPUT' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1869
1883
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1870
- return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1884
+ return { taskKind, ...(releasePolicy ? { releasePolicy } : {}), ...(documentation ? { reportArtifact } : {}), ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1871
1885
  ...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
1872
1886
  ...(operation && nativeDomain === 'scheduled-job' ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
1873
1887
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
@@ -1878,7 +1892,7 @@ function applyTaskSemantics(contract, semantic) {
1878
1892
  const operationOnly = semantic.taskKind === 'OPERATIONAL'
1879
1893
  || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
1880
1894
  return { ...(contract || {}), ...semantic,
1881
- completionEvidence: operationOnly ? ['operation-verification']
1895
+ completionEvidence: semantic.taskKind === 'DOCUMENTATION_OUTPUT' ? ['report-artifact'] : operationOnly ? ['operation-verification']
1882
1896
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1883
1897
  : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
1884
1898
  : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
@@ -2238,7 +2252,7 @@ function applyTaskSemantics(contract, semantic) {
2238
2252
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
2239
2253
  .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
2240
2254
  .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
2241
- .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
2255
+ .replace(new RegExp(`\\b(?:khong|chua)\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
2242
2256
  .replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
2243
2257
  // C89-008: a waiting clause narrates what the TURN waits on, not what
2244
2258
  // the request orders — verbs inside it ("chờ sửa xong", "waiting for
@@ -2312,7 +2326,7 @@ function applyTaskSemantics(contract, semantic) {
2312
2326
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|not|khong(?:\\s+duoc)?|tuyet\\s+doi\\s+khong)\\s+(?:${MUT_VERBS})(?![A-Za-z0-9_])(?:\\s*(?:,|,?\\s+(?:or|and|nor|hay|hoac|va))\\s*(?:(?:${MUT_VERBS})(?![A-Za-z0-9_])|(?:${MUT_VERBS})\\s))*[^.,;\\n\u2014]*?(?=\\s+(?:va|and|then|roi|nhung|but)\\s|[.,;\u2014\\n]|\\s*$)`, 'g'), ' ')
2313
2327
  .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
2314
2328
  .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
2315
- .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
2329
+ .replace(new RegExp(`\\b(?:khong|chua)\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
2316
2330
  .replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
2317
2331
  // C89-008: a waiting clause narrates what the TURN waits on, not what
2318
2332
  // the request orders — verbs inside it belong to the wait.
@@ -1090,25 +1090,39 @@ function nativeOperationGoal(domain, actionable, text) {
1090
1090
  // FIRST item of an enumeration ("Do not implement fixes, modify source code, restart services, ...") and
1091
1091
  // the later items became actionable orders (MIXED + service-runtime). English negators now scope to the
1092
1092
  // sentence end (', and'/', or' continue the list; a bare 'and <verb>' or but/then starts a new clause);
1093
- // Vietnamese negators stay clause-local. Noun uses of verbs ("write/verification advisory", "each proposed
1093
+ // Vietnamese negators scope to the sentence end as well. Noun uses of verbs ("write/verification advisory", "each proposed
1094
1094
  // fix", "state change") name things, not orders. Neutralised spans become blanks, never new orders.
1095
1095
  const NON_ORDER_VERBS = 'implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|restart|sua|viet|chinh|them|tao|xoa';
1096
1096
  function neutralizeNonOrders(text) {
1097
1097
  return String(text)
1098
1098
  .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|must\\s+not|never|shall\\s+not)\\s+[^.;\\n]*?(?=(?<!,)\\s+(?:and|va)\\s+(?:${NON_ORDER_VERBS})\\b|\\s+(?:but|nhung|then|however)\\b|[.;\\n]|$)`, 'g'), ' ')
1099
+ .replace(new RegExp(`\\b(?:khong|chua)\\s+[^.;\\n]*?(?=\\s+(?:nhung|tuy nhien)\\b|[.;\\n]|$)`, 'g'), ' ')
1100
+ .replace(/"[^"\n]*\s[^"\n]*"|`[^`\n]*\s[^`\n]*`|\u201c[^\u201d\n]*\u201d/g, ' ')
1101
+ .replace(/\b(?:neu\s+(?:phai|can|thieu|khong du|bang chung chua du)|if\s+(?:needed|necessary|required|evidence is insufficient))\b[^.;\n]*/g, ' ')
1102
+ .replace(/[^.;\n]*\b(?:nao|gi|sao|the nao|bao nhieu)\b[^.;\n]*\?/g, ' ')
1103
+ .replace(/\bchinh\s+xac\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b/g, ' ')
1099
1104
  .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1100
1105
  .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1101
1106
  .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
1102
1107
  }
1103
1108
 
1104
1109
  export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1105
- const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase());
1110
+ // A named preflight and both explicit branches are required; a failed arbitrary test is not a release policy.
1111
+ const releaseText = String(promptText).normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
1112
+ const preflight = releaseText.match(/\b(?:yarn\s+release:verify|npm\s+run\s+release:verify)\b/)?.[0];
1113
+ const releasePolicy = preflight && /\b(?:if|neu|only when|only if)\b[^\n.;]*(?:pass|go)\b[^\n.;]*(?:release|publish)\b/.test(releaseText)
1114
+ && /\b(?:fail|no-go)\b[^\n.;]*(?:do not publish|don't publish|no publish|khong publish|khong npm publish)\b/.test(releaseText)
1115
+ ? { kind: 'conditional-release', preflight } : null;
1116
+ const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase());
1106
1117
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1107
1118
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1108
1119
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1109
1120
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1110
1121
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1111
1122
  const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
1123
+ const reportArtifact = String(promptText).match(/(?:^|\s)((?:docs\/|reports\/)[A-Za-z0-9_./-]+\.(?:md|txt))(?=\s|[.,;:]|$)/)?.[1];
1124
+ const documentation = !sourceOrder && !/\b(?:implement|fix|refactor|modify|edit|update|sua|chinh)\b/.test(actionable.join(' ')) && reportArtifact && actionable.some((clause) => /\b(?:write|create|save|viet|tao|luu)\b/.test(clause))
1125
+ && /\b(?:report|bao cao|analysis|phan tich|document)\b/.test(text);
1112
1126
  const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
1113
1127
  // A cron/job MENTION is not an operation: with a code-change verb in the request, an
1114
1128
  // operation needs a concrete target id; otherwise it is a coding task about scheduling.
@@ -1133,13 +1147,13 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1133
1147
  // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
1134
1148
  const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
1135
1149
  const statusReport = !mutationWord && !operation && !config
1136
- && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
1137
- const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
1150
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text);
1151
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|debug|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text) || statusReport)
1138
1152
  && !sourceOrder && !codeChangeVerb && !operation && !config;
1139
1153
  const taskKind = operation ? (sourceOrder || codeChangeVerb ? 'MIXED' : 'OPERATIONAL')
1140
- : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1154
+ : config ? 'CONFIGURATION' : documentation ? 'DOCUMENTATION_OUTPUT' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1141
1155
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1142
- return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1156
+ return { taskKind, ...(releasePolicy ? { releasePolicy } : {}), ...(documentation ? { reportArtifact } : {}), ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1143
1157
  ...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
1144
1158
  ...(operation && nativeDomain === 'scheduled-job' ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
1145
1159
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text)
@@ -1153,7 +1167,7 @@ export function applyTaskSemantics(contract, semantic) {
1153
1167
  const operationOnly = semantic.taskKind === 'OPERATIONAL'
1154
1168
  || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
1155
1169
  return { ...(contract || {}), ...semantic,
1156
- completionEvidence: operationOnly ? ['operation-verification']
1170
+ completionEvidence: semantic.taskKind === 'DOCUMENTATION_OUTPUT' ? ['report-artifact'] : operationOnly ? ['operation-verification']
1157
1171
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1158
1172
  : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
1159
1173
  : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
@@ -140,7 +140,7 @@ export function bashOperationReceipt(rawCommand, response, transportSuccess, goa
140
140
  // the registry actually printed a semver. Agent prose is never evidence; the completion gate compares the
141
141
  // recorded version with package.json and live Git state before it treats a release as published.
142
142
  const NPM_PUBLISH = /^npm\s+publish(?:\s|$)/;
143
- const NPM_VIEW = /^npm\s+view\s+(\S+?)(?:@\S+)?\s+version(?:\s+2>&1)?\s*$/;
143
+ const NPM_VIEW = /^npm\s+view\s+(\S+?)(?:@\S+)?\s+version(?:\s+--prefer-online)?(?:\s+2>&1)?\s*$/;
144
144
  const SEMVER_LINE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
145
145
  export function releaseReceipt(rawCommand, response, transportSuccess) {
146
146
  if (typeof rawCommand !== 'string') return null;
@@ -2225,6 +2225,8 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
2225
2225
  if (receipt.kind === 'operation') {
2226
2226
  next.operationBank = foldOperationEvidence(ledger.operationBank, receipt, ledger);
2227
2227
  }
2228
+ if (receipt.releasePreflight) next.releasePreflight = { ...receipt.releasePreflight, sequence: next.receiptSequence };
2229
+ if (receipt.kind === 'write' && receipt.success) next.releasePreflight = null;
2228
2230
  if (receipt.kind === 'source') {
2229
2231
  next.sourceSucceeded = next.sourceSucceeded || receipt.success;
2230
2232
  if (receipt.success && receipt.file) {
@@ -2848,6 +2850,12 @@ export async function recordExecutionReceipt({
2848
2850
  exitCode,
2849
2851
  };
2850
2852
 
2853
+ const policy = routeState?.routeSummary?.executionContract?.releasePolicy;
2854
+ if (toolName === 'Bash' && !bashScope.foreign && policy?.kind === 'conditional-release'
2855
+ && String(toolInput.command || '').trim() === policy.preflight && Number.isInteger(exitCode)) {
2856
+ receipt.releasePreflight = { command: policy.preflight, failed: exitCode !== 0, exitCode,
2857
+ taskInstanceId: routeState.taskInstanceId, workspaceFp };
2858
+ }
2851
2859
  const toolResponse = payload.tool_response ?? payload.tool_output ?? payload.tool_result;
2852
2860
  const nativeGoal = routeState?.routeSummary?.executionContract?.operationGoal;
2853
2861
  const operationReceipt = cronOperationReceipt(toolName, toolInput, toolResponse, success, { sessionId: explicitSessionId(payload), harness })
@@ -2863,6 +2871,14 @@ export async function recordExecutionReceipt({
2863
2871
  } else if (toolName === 'Edit' || toolName === 'Write') {
2864
2872
  receipt.kind = 'write';
2865
2873
  receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
2874
+ // External scratch artifacts are not product mutations. Keep them out of write debt/proof entirely;
2875
+ // otherwise a /tmp report resurrects implementation obligations on a read-only investigation.
2876
+ if (typeof receipt.file === 'string') {
2877
+ const relative = path.relative(path.resolve(projectRoot), path.resolve(projectRoot, receipt.file));
2878
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2879
+ return { rejected: true, eventId: null, reason: 'outside-product-workspace' };
2880
+ }
2881
+ }
2866
2882
  } else if (toolName === 'Bash' && !bashScope.foreign && (isVerificationCommand(toolInput.command) || evidence)) {
2867
2883
  // TASK-009 (BL-011): a Bash command carrying an evidence attestation
2868
2884
  // records as a verification receipt even when the command is not a known
@@ -3004,6 +3020,14 @@ function requiredEvidence(state = {}) {
3004
3020
 
3005
3021
  function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
3006
3022
  const routeSummary = state?.routeSummary || {};
3023
+ if (evidence === 'report-artifact') {
3024
+ const artifact = routeSummary.executionContract?.reportArtifact;
3025
+ if (!cwd || typeof artifact !== 'string') return false;
3026
+ const target = path.resolve(cwd, artifact);
3027
+ const bank = evidenceBank(ledger, 'bankedWrites');
3028
+ if (bank[evidenceBankKey(artifact)] !== true && bank[evidenceBankKey(target)] !== true) return false;
3029
+ try { return fsSync.statSync(target).isFile() && fsSync.statSync(target).size > 0; } catch { return false; }
3030
+ }
3007
3031
  if (evidence === 'operation-verification') {
3008
3032
  return operationCompletionPolicy(scopedContract(state, ledger), null, withGitAuthority(ledger, cwd))?.complete === true;
3009
3033
  }
@@ -3513,6 +3537,23 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3513
3537
  const at = missingEvidence.indexOf('impact-evidence');
3514
3538
  if (at !== -1) missingEvidence.splice(at, 1);
3515
3539
  }
3540
+ const releasePolicy = routeSummary.executionContract?.releasePolicy;
3541
+ const preflight = effectiveLedger?.releasePreflight;
3542
+ if (['MIXED', 'OPERATIONAL'].includes(routeSummary.executionContract?.taskKind)
3543
+ && releasePolicy?.kind === 'conditional-release' && preflight?.failed === true
3544
+ && preflight.command === releasePolicy.preflight && preflight.taskInstanceId === state.taskInstanceId
3545
+ && (!preflight.workspaceFp || preflight.workspaceFp === workspaceFingerprint(cwd))
3546
+ && !missingEvidence.some((item) => item !== 'operation-verification' && item !== 'verification-evidence')) {
3547
+ return { continue: false, notify: true, complete: false, outcome: 'blocked',
3548
+ releaseOutcome: 'RELEASE_NO_GO', missingEvidence: [],
3549
+ reason: `RELEASE_NO_GO: owned preflight ${preflight.command} failed (exit ${preflight.exitCode}); publication is prohibited by this task's conditional release policy. No release was completed.` };
3550
+ }
3551
+ if (releasePolicy?.kind === 'conditional-release'
3552
+ && !(preflight?.failed === false && preflight.command === releasePolicy.preflight
3553
+ && preflight.taskInstanceId === state.taskInstanceId
3554
+ && (!preflight.workspaceFp || preflight.workspaceFp === workspaceFingerprint(cwd)))) {
3555
+ missingEvidence.push('release-preflight');
3556
+ }
3516
3557
  const releaseMissing = releasePublicationMissing(state, effectiveLedger, cwd);
3517
3558
  if (releaseMissing) missingEvidence.push(releaseMissing);
3518
3559
  // TASK-009 (BL-011): the bug-fix floor. On a `playbookId: bug-fix` route a
@@ -3731,7 +3772,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3731
3772
 
3732
3773
  const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
3733
3774
  const instruction = missingEvidence.includes('release-publication')
3734
- ? 'The release is not proven published: run `npm publish` once if the registry does not yet serve this version, then confirm with `npm view <package> version` (must equal package.json) and that the release tag dereferences to HEAD (or to an ancestor of HEAD with no shipped change since). Never move the tag. Do not republish a version the registry already serves.'
3775
+ ? 'The release is not proven published: run `npm publish` once if the registry does not yet serve this version, then confirm with `npm view <package> version` (must equal package.json) and that the release tag dereferences to HEAD (or to an ancestor of HEAD with no shipped change since). Never move the tag. Do not republish a version the registry already serves. Accepted evidence: a Bash result of exactly `npm view <package> version` (optionally `2>&1`, optionally after `cd <dir> &&`) that exited 0 and printed a bare semver. Rejected as unproven: the same command behind `| tail/head/cat`, `;`, `||` or inside a compound (a pipeline hides the exit status), dry runs, prose claims. Re-run the bare command once; do not republish.'
3735
3776
  : operationalTask
3736
3777
  ? 'Inspect native mutation receipts and verify the requested operational state; do not fabricate source edits or source-test evidence.'
3737
3778
  : recoveryInstruction(missingEvidence, effectiveLedger, routeSummary, { cwd });
@@ -3770,6 +3811,20 @@ function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
3770
3811
 
3771
3812
  export function evaluateCompletion(args = {}) {
3772
3813
  const result = evaluateCompletionContract(args);
3814
+ // Explain the verdict from the SAME task-scoped snapshot used by the verifier. These are proof summaries,
3815
+ // not an alternative evidence source; a diagnostic must never satisfy or relax an obligation.
3816
+ const owned = effectiveRequestLedger(args.state, args.ledger) || {};
3817
+ const diagnostics = {
3818
+ taskInstanceId: args.state?.taskInstanceId || null,
3819
+ contract: args.state?.routeSummary?.executionContract?.taskKind || args.state?.routeSummary?.taskKind || args.state?.routeSummary?.executionMode || null,
3820
+ unmetObligations: result.missingEvidence || [],
3821
+ acceptedEvidence: result.complete === true || result.missingEvidence?.length
3822
+ ? requiredEvidence(args.state).filter((item) => !(result.missingEvidence || []).includes(item)) : [],
3823
+ observedEvidence: [owned.sourceSucceeded && 'source-read', owned.writeSucceeded && 'write-receipt', owned.verificationSucceeded && 'verification-receipt'].filter(Boolean),
3824
+ rejectedEvidence: args.ledger && owned !== args.ledger ? ['ledger-task-ownership-mismatch'] : [],
3825
+ };
3826
+ result.diagnostics = diagnostics;
3827
+ if (result.reason) result.reason += ` [task=${diagnostics.taskInstanceId || 'unknown'} contract=${diagnostics.contract || 'unknown'} accepted-count=${diagnostics.acceptedEvidence.length} rejected=${diagnostics.rejectedEvidence.join(',') || 'none'}]`;
3773
3828
  try {
3774
3829
  const advisory = playbookStopAdvisory(args, result);
3775
3830
  if (advisory) return { ...result, playbookAdvisory: advisory };
@@ -1966,6 +1966,7 @@ export async function runStopCoordinator({
1966
1966
  await captureUsageShadow({ projectRoot, payload });
1967
1967
  } catch { /* advisory */ }
1968
1968
 
1969
+
1969
1970
  // TASK-005 (FR-005): the coordinator's terminal verdict lands ONE
1970
1971
  // `outcome.observed` record per coordinated stop — the merged evaluator
1971
1972
  // decision is the session's honest outcome at this stop. Fire-and-forget:
@@ -1176,7 +1176,7 @@ export async function runSessionStop(
1176
1176
  // Command authority (RC-A): same shared phase signal the Claude Stop path uses, so a
1177
1177
  // handoff-create/review session is never pushed toward Edit/Write on omp either.
1178
1178
  const phase = await resolveStopPhase(payload.transcript_path);
1179
- evaluation = evaluateCompletion({ state, ledger, phase });
1179
+ evaluation = evaluateCompletion({ state, ledger, phase, cwd: projectRoot });
1180
1180
  } catch (error) {
1181
1181
  // BUG-C22-05: fail-closed like the shell gate (continue the turn), but only up
1182
1182
  // to the configured consecutive-crash cap — a persistently broken evaluator