@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.
package/CHANGELOG.md CHANGED
@@ -2,12 +2,21 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
- ## Unreleased
6
-
7
- **`/ukit:ci-check`: manual-only cross-platform CI (not yet released; no version bump).**
8
- - `cross-platform-ci.yml` is now `workflow_dispatch` only (push trigger removed). No push/pull_request/schedule/workflow_run path can start CI.
9
- - New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
10
- - Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
5
+ ## 3.4.47 - 2026-10-18
6
+
7
+ **Supervisor subagent usage attribution and rendered-instruction parity checks.**
8
+ - Record subagent usage at the supervisor's terminal completion/cancellation boundary, using typed operation metadata and existing exactly-once guards. Provider-authenticated usage remains blocked; host transcript values are not relabeled as provider data.
9
+ - Add read-only `render-instructions.mjs --parity` to byte-compare rendered root instructions with existing root files, including fixture-target support and drift diagnostics.
10
+ - Add manual-only `/ukit:ci-check` cross-platform CI tooling; it remains opt-in and never pushes, merges, or publishes.
11
+ - Locally verified on macOS/Node 22. Q-C98-1 and Q-C98-3 remain blocked on provider-authenticated data/privacy approval; no provider identity/pricing/cache model is claimed.
12
+ ## 3.4.46 - 2026-10-18
13
+
14
+ **Completion contract P0: forensic intent, attributable release evidence and conditional NO-GO.**
15
+ - Correct negated/quoted forensic requests and Vietnamese status questions; explicit report artifacts require owned writes and nonempty output. External scratch writes do not count as product changes.
16
+ - Conditional release contracts with a named `release:verify` preflight accept only current-task attributable native failure as terminal `RELEASE_NO_GO`, never publication success. Missing implementation writes still block; passing preflight retains publication obligations. Masked commands and stale receipts remain unproven.
17
+ - Accept standalone `npm view <package> version --prefer-online` evidence without republishing; improve task/obligation diagnostics. OMP artifact checks use the installed project cwd.
18
+ - Exclude only repository coverage/transcript audit scripts from npm; retain runtime and customer assets and the strict artifact budget.
19
+ - Verified locally on macOS/Node 22 with real isolated OMP child-hook chains; no provider calls or GitHub Actions dispatch. Historical release ledger replay remains partial; OMP mid-task freeze remains open. UNIC Decision stays unchanged/off.
11
20
 
12
21
  ## 3.4.45 - 2026-10-17
13
22
 
@@ -955,6 +955,18 @@ entries:
955
955
  archive_policy: never
956
956
  notes: C69 G7 operator doc — timeline render, support bundle contents/exclusions, replay limits
957
957
 
958
+ - id: docs-completion-decision-assessment
959
+ path: docs/COMPLETION_DECISION_ASSESSMENT.md
960
+ class: canonical
961
+ audience: [maintainer]
962
+ owner: runtime
963
+ source_of_truth: docs/COMPLETION_DECISION_ASSESSMENT.md
964
+ merge_strategy: none
965
+ load_policy: on-demand
966
+ validation: [manual]
967
+ archive_policy: never
968
+ notes: P0 completion contract corrections and offline semantic decision architecture assessment
969
+
958
970
  - id: docs-unic-decision-integration-p1-fix
959
971
  path: docs/UNIC-DECISION-INTEGRATION-P1-FIX.md
960
972
  class: canonical
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.45",
3
+ "version": "3.4.47",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,6 +34,8 @@
34
34
  "!scripts/audit/c100/implementer-attribution.mjs",
35
35
  "!scripts/audit/c100/trees-lifetimes.mjs",
36
36
  "!scripts/audit/c100/usage-provenance.mjs",
37
+ "!scripts/audit/decision-coverage.mjs",
38
+ "!scripts/audit/subagent-cost/**",
37
39
  "template_project/",
38
40
  "template_user/",
39
41
  "!template_project/**/.smbdelete*",
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ // One-shot development capture. Every subprocess is bounded; OMP is never signalled or modified.
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { spawn } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { readBoundedTail, summarizeSessionTail, buildStallSnapshot, associateSessionEvidence, classifySessionActivity, parseProcessInventory, processInventoryCommand, parseOwnerPids, runBoundedProcess } from '../../src/diagnostics/ompStallSnapshot.js';
9
+ const ownPath = fileURLToPath(import.meta.url);
10
+ const args = process.argv.slice(2);
11
+ const option = (key) => { const at = args.indexOf(key); return at < 0 ? null : args[at + 1]; };
12
+ const worker = process.env.UKIT_STALL_CAPTURE_WORKER === '1';
13
+
14
+ if (!worker) {
15
+ const child = spawn(process.execPath, [ownPath, '--worker', ...args], { stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, UKIT_STALL_CAPTURE_WORKER: '1' } });
16
+ let output = '';
17
+ let failed = false;
18
+ const timer = setTimeout(() => { failed = true; child.kill('SIGKILL'); }, 5000);
19
+ child.stdout.on('data', (chunk) => {
20
+ output += chunk;
21
+ if (Buffer.byteLength(output) > 131072) { failed = true; child.kill('SIGKILL'); }
22
+ });
23
+ child.on('error', () => { clearTimeout(timer); console.error('Capture unavailable'); process.exitCode = 1; });
24
+ child.on('close', async (code) => {
25
+ clearTimeout(timer);
26
+ if (code !== 0 || failed) { console.error('Capture incomplete: collector deadline or bounded I/O failure; OMP untouched.'); process.exitCode = 1; return; }
27
+ try {
28
+ const report = JSON.parse(output);
29
+ const dir = path.resolve(option('--output-dir') || path.join(os.tmpdir(), 'ukit-stall-captures'));
30
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
31
+ const dirStat = await fs.lstat(dir);
32
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) throw new Error('unsafe output directory');
33
+ await fs.chmod(dir, 0o700);
34
+ let file = null;
35
+ let handle = null;
36
+ const startSlot = Date.now() % 8;
37
+ for (let attempt = 0; attempt < 8 && !file; attempt += 1) {
38
+ const slot = (startSlot + attempt) % 8;
39
+ const candidate = path.join(dir, `snapshot-${slot}.json`);
40
+ try {
41
+ const existing = await fs.lstat(candidate);
42
+ if (!existing.isFile() || existing.isSymbolicLink()) continue;
43
+ await fs.unlink(candidate);
44
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
45
+ file = candidate;
46
+ const temporary = path.join(dir, `snapshot-${slot}-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
47
+ try {
48
+ handle = await fs.open(temporary, 'wx', 0o600);
49
+ await handle.chmod(0o600);
50
+ await handle.writeFile(`${JSON.stringify(report, null, 2)}\n`);
51
+ await handle.close();
52
+ handle = null;
53
+ await fs.rename(temporary, file);
54
+ } catch (error) {
55
+ if (handle) await handle.close();
56
+ await fs.unlink(temporary).catch(() => {});
57
+ file = null;
58
+ if (attempt === 7) throw error;
59
+ }
60
+ }
61
+ if (!file) throw new Error('no safe snapshot slot available');
62
+ console.log(`${report.captureStatus} — snapshot saved: ${file}`);
63
+ } catch {
64
+ console.error('Capture incomplete: private snapshot could not be saved; OMP untouched.');
65
+ process.exitCode = 1;
66
+ }
67
+ });
68
+ } else {
69
+ const maxSessions = 32;
70
+ const maxLogs = 8;
71
+ const home = option('--omp-home') || path.join(os.homedir(), '.omp');
72
+ const sessionsRoot = option('--sessions-root') || path.join(home, 'agent', 'sessions');
73
+ const ownersRoot = option('--owners-root') || path.join(home, 'run', 'session-owners');
74
+ const logsRoot = option('--logs-root') || path.join(home, 'logs');
75
+ const explicitSession = option('--session');
76
+ const packageInfo = JSON.parse(await fs.readFile(new URL('../../package.json', import.meta.url), 'utf8'));
77
+
78
+ async function discoverTranscripts(root) {
79
+ const candidates = [];
80
+ let truncated = false;
81
+ try {
82
+ const rootDir = await fs.opendir(root);
83
+ let visited = 0;
84
+ for await (const entry of rootDir) {
85
+ if (++visited > 128) { truncated = true; break; }
86
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) candidates.push(path.join(root, entry.name));
87
+ if (!entry.isDirectory()) continue;
88
+ let childCount = 0;
89
+ try {
90
+ const childDir = await fs.opendir(path.join(root, entry.name));
91
+ for await (const child of childDir) {
92
+ if (++childCount > 128 || candidates.length >= maxSessions) { truncated = true; break; }
93
+ if (child.isFile() && child.name.endsWith('.jsonl')) candidates.push(path.join(root, entry.name, child.name));
94
+ }
95
+ } catch { truncated = true; }
96
+ if (candidates.length >= maxSessions) { truncated = true; break; }
97
+ }
98
+ return { state: 'OBSERVED', paths: candidates.slice(0, maxSessions), truncated };
99
+ } catch { return { state: 'NOT_OBSERVABLE', paths: [], truncated: true }; }
100
+ }
101
+
102
+ async function discoverOwnerIds(root) {
103
+ try {
104
+ const dir = await fs.opendir(root);
105
+ const ids = [];
106
+ let visited = 0;
107
+ let truncated = false;
108
+ for await (const entry of dir) {
109
+ if (++visited > 128) { truncated = true; break; }
110
+ if (!entry.isFile() || !entry.name.endsWith('.lock')) continue;
111
+ const sessionId = entry.name.slice(0, -5);
112
+ if (/^[\w.-]{1,120}$/.test(sessionId)) ids.push(sessionId);
113
+ if (ids.length >= maxSessions) { truncated = true; break; }
114
+ }
115
+ return { state: 'OBSERVED', ids, truncated };
116
+ } catch { return { state: 'NOT_OBSERVABLE', ids: [], truncated: true }; }
117
+ }
118
+
119
+ async function runBounded(command, commandArgs, timeout = 650) {
120
+ return runBoundedProcess(command, commandArgs, { timeoutMs: timeout, maxBuffer: 65536 });
121
+ }
122
+
123
+
124
+ async function probeOwnerLock(sessionId, rootExists) {
125
+ if (!rootExists || !/^[\w.-]{1,120}$/.test(sessionId)) return { state: 'NOT_OBSERVABLE', pids: [] };
126
+ const file = path.join(ownersRoot, `${sessionId}.lock`);
127
+ try {
128
+ const stat = await fs.lstat(file);
129
+ if (!stat.isFile()) return { state: 'NOT_OBSERVABLE', pids: [] };
130
+ } catch (error) {
131
+ return error.code === 'ENOENT' ? { state: 'UNHELD', pids: [] } : { state: 'NOT_OBSERVABLE', pids: [] };
132
+ }
133
+ if (process.platform === 'win32') {
134
+ const handles = await runBounded('handle.exe', ['-nobanner', file], 400);
135
+ if (!handles.ok) return { state: 'NOT_OBSERVABLE', pids: [] };
136
+ const expected = path.resolve(file).replaceAll('/', '\\').toLowerCase();
137
+ const matches = handles.stdout.split(/\r?\n/).flatMap((line) => {
138
+ const match = line.match(/\bpid:\s*(\d+)\s+type:\s*File\b.*?:\s*(.+)$/i);
139
+ if (!match || match[2].trim().replaceAll('/', '\\').toLowerCase() !== expected) return [];
140
+ return [Number(match[1])];
141
+ });
142
+ const pids = [...new Set(matches)];
143
+ return pids.length === 1 ? { state: 'OBSERVED', pids } : { state: 'NOT_OBSERVABLE', pids: [] };
144
+ }
145
+ const lsof = await runBounded('lsof', ['-t', '--', file], 250);
146
+ if (lsof.ok) return { state: 'OBSERVED', pids: parseOwnerPids('lsof', lsof.stdout) };
147
+ if (lsof.exitCode === 1) return /permission|denied|error/i.test(lsof.stderr) ? { state: 'NOT_OBSERVABLE', pids: [] } : { state: 'OBSERVED', pids: [] };
148
+ if (lsof.errorCode !== 'ENOENT') return { state: 'NOT_OBSERVABLE', pids: [] };
149
+ const fuser = await runBounded('fuser', ['-f', file], 250);
150
+ if (fuser.ok) return { state: 'OBSERVED', pids: parseOwnerPids('fuser', `${fuser.stdout}\n${fuser.stderr}`) };
151
+ if (fuser.exitCode === 1) return /permission|denied|error/i.test(fuser.stderr) ? { state: 'NOT_OBSERVABLE', pids: [] } : { state: 'OBSERVED', pids: [] };
152
+ return { state: 'NOT_OBSERVABLE', pids: [] };
153
+ }
154
+
155
+
156
+ async function readProcessInventory() {
157
+ const [command, commandArgs] = processInventoryCommand(process.platform);
158
+ const result = await runBounded(command, commandArgs);
159
+ if (!result.ok) return { state: 'NOT_OBSERVABLE', processes: [], ompProcessCount: null };
160
+ const processes = parseProcessInventory(process.platform, result.stdout);
161
+ return { state: 'OBSERVED', processes, ompProcessCount: processes.filter((entry) => /^omp(?:\.exe)?$/i.test(entry.name)).length };
162
+ }
163
+
164
+ async function readOmpLog(pid) {
165
+ try {
166
+ const names = [];
167
+ const directory = await fs.opendir(logsRoot);
168
+ let visited = 0;
169
+ for await (const entry of directory) {
170
+ if (++visited > 256) break;
171
+ if (entry.isFile() && new RegExp(`^omp\\.\\d{4}-\\d{2}-\\d{2}\\.${pid}\\.log$`).test(entry.name)) names.push(entry.name);
172
+ }
173
+ names.sort();
174
+ const name = names.at(-1);
175
+ if (!name) return null;
176
+ const tail = await readBoundedTail(path.join(logsRoot, name), { maxBytes: 32768 });
177
+ const rows = [];
178
+ for (const line of tail.text.split('\n')) {
179
+ try {
180
+ const row = JSON.parse(line);
181
+ // Never copy message text, URLs, account fields, headers or response bodies.
182
+ const timestamp = typeof row.timestamp === 'string' && Number.isFinite(Date.parse(row.timestamp)) ? new Date(row.timestamp).toISOString() : null;
183
+ rows.push({ timestamp,
184
+ level: ['debug', 'info', 'warn', 'error'].includes(row.level) ? row.level : null,
185
+ status: Number.isInteger(row.status) ? row.status : null });
186
+ } catch { /* Partial log row. */ }
187
+ }
188
+ return { pid, bytesRead: tail.bytesRead, rows: rows.slice(-16) };
189
+ } catch { return null; }
190
+ }
191
+
192
+ let discovery;
193
+ if (explicitSession) discovery = { state: 'OBSERVED', paths: [explicitSession] };
194
+ else discovery = await discoverTranscripts(sessionsRoot);
195
+ const transcriptSessions = [];
196
+ for (const transcriptPath of discovery.paths.slice(0, maxSessions)) {
197
+ try {
198
+ const tail = await readBoundedTail(transcriptPath);
199
+ const summary = summarizeSessionTail(tail.text);
200
+ const filenameId = path.basename(transcriptPath).replace(/\.jsonl$/i, '');
201
+ const uuidFromFilename = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(filenameId) ? filenameId : null;
202
+ const sessionId = summary.sessionId || uuidFromFilename;
203
+ const summarySessionId = summary.sessionId;
204
+ summary.sessionId = sessionId;
205
+ transcriptSessions.push({ sessionId, transcriptPath, summary, sourceCoverage: { sessionTailBytes: tail.bytesRead, truncated: tail.truncated, sessionIdentityObserved: summarySessionId !== null || uuidFromFilename !== null } });
206
+ } catch { transcriptSessions.push({ sessionId: null, transcriptPath, summary: null, sourceCoverage: { state: 'NOT_OBSERVABLE' } }); }
207
+ }
208
+ const ownerDiscovery = await discoverOwnerIds(ownersRoot);
209
+ const sessions = [...transcriptSessions];
210
+ for (const sessionId of ownerDiscovery.ids) if (!sessions.some((entry) => entry.sessionId === sessionId)) sessions.push({ sessionId, transcriptPath: null, summary: null, sourceCoverage: { state: 'NOT_OBSERVABLE' } });
211
+ const boundedSessions = sessions.slice(0, maxSessions);
212
+ const ownerRootExists = ownerDiscovery.state === 'OBSERVED';
213
+ const [ownerResults, processInventory] = await Promise.all([
214
+ Promise.all(boundedSessions.filter((entry) => entry.sessionId).map(async (entry) => [entry.sessionId, await probeOwnerLock(entry.sessionId, ownerRootExists)])),
215
+ readProcessInventory(),
216
+ ]);
217
+ const ownerLocks = Object.fromEntries(ownerResults);
218
+ const logs = (await Promise.all([...new Set(ownerResults.flatMap(([, lock]) => lock.state === 'OBSERVED' ? lock.pids : []))].slice(0, maxLogs).map(readOmpLog))).filter(Boolean);
219
+ const associations = associateSessionEvidence({ sessions: boundedSessions, ownerLocks, processInventory, logs });
220
+ const snapshots = associations.map((association, index) => {
221
+ const source = boundedSessions[index];
222
+ const summary = source.summary;
223
+ const sessionActivity = classifySessionActivity({ association: association.sessionAssociation, summary });
224
+ return { ...association, sessionActivity, ...(summary || {}), sourceCoverage: source.sourceCoverage };
225
+ });
226
+ const activeOwners = snapshots.filter((entry) => entry.sessionAssociation === 'ACTIVE_OWNER');
227
+ const allLocksUnheld = ownerResults.length === boundedSessions.filter((entry) => entry.sessionId).length
228
+ && ownerResults.every(([, lock]) => lock.state === 'UNHELD');
229
+ const coverageComplete = !discovery.truncated && !ownerDiscovery.truncated && sessions.length <= maxSessions;
230
+ const transcriptsFullyIdentified = transcriptSessions.every((entry) => entry.sessionId !== null);
231
+ const executionState = activeOwners.length ? 'EXECUTING'
232
+ : coverageComplete && transcriptsFullyIdentified && ownerDiscovery.state === 'OBSERVED' && processInventory.state === 'OBSERVED'
233
+ && processInventory.ompProcessCount === 0 && allLocksUnheld ? 'NO_EXECUTION' : 'NOT_OBSERVABLE';
234
+ const activity = activeOwners.length === 1 ? activeOwners[0].sessionActivity
235
+ : activeOwners.length > 1 ? 'MULTIPLE_ACTIVE_SESSIONS' : executionState === 'NO_EXECUTION' ? 'NO_EXECUTION' : 'NOT_OBSERVABLE';
236
+ const captureStatus = activeOwners.length > 0 && activeOwners.every((entry) => entry.sourceCoverage && entry.summary)
237
+ && discovery.state === 'OBSERVED' && !discovery.truncated && ownerDiscovery.state === 'OBSERVED' && !ownerDiscovery.truncated
238
+ && processInventory.state === 'OBSERVED' && ownerResults.every(([, lock]) => lock.state === 'OBSERVED' || lock.state === 'UNHELD')
239
+ && snapshots.every((entry) => entry.sessionId !== null && entry.identityVerified) ? 'CAPTURE_READY' : 'CAPTURE_PARTIAL';
240
+ const snapshot = buildStallSnapshot({ executionState, activity, captureStatus,
241
+ sessionDiscovery: discovery.state, ownerLockDiscovery: ownerDiscovery.state,
242
+ processInventory: { state: processInventory.state, ompProcessCount: processInventory.ompProcessCount },
243
+ sessions: snapshots, ompVersion: 'NOT_OBSERVABLE', ukitVersion: packageInfo.version,
244
+ limitations: ['Persisted transcript events do not prove live stream or gateway state.', 'Unobservable ownership and operation states remain NOT_OBSERVABLE.', 'No OMP freeze root cause is inferred.'] });
245
+ process.stdout.write(JSON.stringify(snapshot) + '\n');
246
+ }
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // DOC-102 renderer CLI (SPEC §8.2).
3
3
  // node scripts/docs/render-instructions.mjs --write (default) regenerate all outputs
4
- // node scripts/docs/render-instructions.mjs --check print DRIFT <path> per mismatch, exit 1
4
+ // node scripts/docs/render-instructions.mjs --parity check installed root instructions read-only
5
+ // `--parity` takes precedence over `--check`/`--write`; `--parity-root` overrides only the
6
+ // comparison target directory for fixture/testing use, never the source/layout root.
5
7
  import path from 'node:path';
8
+ import fs from 'node:fs';
6
9
  import { fileURLToPath } from 'node:url';
7
10
 
8
11
  import {
@@ -12,18 +15,39 @@ import {
12
15
 
13
16
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
14
17
 
15
- const USAGE = 'usage: render-instructions.mjs [--write|--check]';
16
18
 
19
+ const USAGE = 'usage: render-instructions.mjs [--write|--check|--parity] [--parity-root <dir>]';
17
20
  const args = process.argv.slice(2);
18
- const unknown = args.filter((a) => a !== '--write' && a !== '--check');
19
- if (unknown.length > 0) {
20
- console.error(`${USAGE}\nunknown flag: ${unknown[0]}`);
21
+ const parityRootArg = args.indexOf('--parity-root');
22
+ const parityRoot = parityRootArg === -1 ? repoRoot : path.resolve(args[parityRootArg + 1] || '');
23
+ const unknown = args.filter((a, i) => a !== '--write' && a !== '--check' && a !== '--parity'
24
+ && a !== '--parity-root' && !(parityRootArg !== -1 && i === parityRootArg + 1));
25
+ if (unknown.length > 0 || (parityRootArg !== -1 && !args[parityRootArg + 1])) {
26
+ console.error(`${USAGE}\nunknown flag or missing value: ${unknown[0] || '--parity-root'}`);
27
+ process.exit(2);
28
+ }
29
+ if (parityRootArg !== -1 && !args.includes('--parity')) {
30
+ console.error(`${USAGE}\n--parity-root requires --parity`);
21
31
  process.exit(2);
22
32
  }
23
33
 
24
34
  const mode = args.includes('--check') ? 'check' : 'write';
35
+ const parity = args.includes('--parity');
25
36
 
26
37
  try {
38
+ if (parity) {
39
+ const rendered = await renderInstructionsFromDisk({ repoRoot, write: false });
40
+ const targets = [...rendered].filter(([target]) => target === 'CLAUDE.md' || target === 'AGENTS.md');
41
+ const drift = targets.filter(([target, content]) => {
42
+ const file = path.join(parityRoot, target);
43
+ return !fs.existsSync(file) || !fs.readFileSync(file).equals(Buffer.from(content));
44
+ });
45
+ for (const [target] of drift) console.log(`DRIFT ${target}`);
46
+ if (drift.length > 0) process.exit(1);
47
+ console.log(`PARITY OK ${targets.length} files`);
48
+ process.exit(0);
49
+ }
50
+
27
51
  if (mode === 'check') {
28
52
  const drift = await checkRenderedInstructions({ repoRoot });
29
53
  for (const target of drift) {
@@ -47,8 +47,8 @@ import {
47
47
  durationMs,
48
48
  reasonCodeForFailure,
49
49
  projectRootFromRuntimeDir,
50
+ recordSubagentUsage,
50
51
  } from './telemetry.js';
51
-
52
52
  const SUPERVISOR_FLAG = 'decisionRuntime.supervisor.stage';
53
53
  // G6 (DR-09): `decisionRuntime.promotion.stage` — absent/malformed → 'off';
54
54
  // 'shadow' consults selectWaitPolicy at the caller-wait decision and records a
@@ -396,6 +396,13 @@ export function createSupervisor(opts = {}) {
396
396
  errorCode: reasonCodeForFailure({ cancelReason: op.cancelReason ?? 'cancel_requested' }),
397
397
  exitCode: op.exitInfo?.code,
398
398
  });
399
+ if (op.spansClosed) {
400
+ recordSubagentUsage(telemetry, op.telemetryCtx, {
401
+ components: op.spec?.subagentUsage?.components,
402
+ parentRunId: op.spec?.parentRunId,
403
+ usage: op.spec?.subagentUsage?.usage,
404
+ });
405
+ }
399
406
  });
400
407
  }
401
408
  }, limits.killGraceMs));
@@ -521,6 +528,13 @@ export function createSupervisor(opts = {}) {
521
528
  exitCode: op.exitInfo?.code,
522
529
  });
523
530
  }
531
+ if (op.spansClosed) {
532
+ recordSubagentUsage(telemetry, op.telemetryCtx, {
533
+ components: op.spec?.subagentUsage?.components,
534
+ parentRunId: op.spec?.parentRunId,
535
+ usage: op.spec?.subagentUsage?.usage,
536
+ });
537
+ }
524
538
  }
525
539
 
526
540
  function onExit(op, code, signal) {
@@ -194,25 +194,39 @@ function nativeOperationGoal(domain, actionable, text) {
194
194
  // FIRST item of an enumeration ("Do not implement fixes, modify source code, restart services, ...") and
195
195
  // the later items became actionable orders (MIXED + service-runtime). English negators now scope to the
196
196
  // sentence end (', and'/', or' continue the list; a bare 'and <verb>' or but/then starts a new clause);
197
- // Vietnamese negators stay clause-local. Noun uses of verbs ("write/verification advisory", "each proposed
197
+ // Vietnamese negators scope to the sentence end as well. Noun uses of verbs ("write/verification advisory", "each proposed
198
198
  // fix", "state change") name things, not orders. Neutralised spans become blanks, never new orders.
199
199
  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';
200
200
  function neutralizeNonOrders(text) {
201
201
  return String(text)
202
202
  .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'), ' ')
203
+ .replace(new RegExp(`\\b(?:khong|chua)\\s+[^.;\\n]*?(?=\\s+(?:nhung|tuy nhien)\\b|[.;\\n]|$)`, 'g'), ' ')
204
+ .replace(/"[^"\n]*\s[^"\n]*"|`[^`\n]*\s[^`\n]*`|\u201c[^\u201d\n]*\u201d/g, ' ')
205
+ .replace(/\b(?:neu\s+(?:phai|can|thieu|khong du|bang chung chua du)|if\s+(?:needed|necessary|required|evidence is insufficient))\b[^.;\n]*/g, ' ')
206
+ .replace(/[^.;\n]*\b(?:nao|gi|sao|the nao|bao nhieu)\b[^.;\n]*\?/g, ' ')
207
+ .replace(/\bchinh\s+xac\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b/g, ' ')
203
208
  .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
204
209
  .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
205
210
  .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
206
211
  }
207
212
 
208
213
  export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
209
- const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase());
214
+ // A named preflight and both explicit branches are required; a failed arbitrary test is not a release policy.
215
+ const releaseText = String(promptText).normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
216
+ const preflight = releaseText.match(/\b(?:yarn\s+release:verify|npm\s+run\s+release:verify)\b/)?.[0];
217
+ const releasePolicy = preflight && /\b(?:if|neu|only when|only if)\b[^\n.;]*(?:pass|go)\b[^\n.;]*(?:release|publish)\b/.test(releaseText)
218
+ && /\b(?:fail|no-go)\b[^\n.;]*(?:do not publish|don't publish|no publish|khong publish|khong npm publish)\b/.test(releaseText)
219
+ ? { kind: 'conditional-release', preflight } : null;
220
+ const text = neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase());
210
221
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
211
222
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
212
223
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
213
224
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
214
225
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
215
226
  const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
227
+ const reportArtifact = String(promptText).match(/(?:^|\s)((?:docs\/|reports\/)[A-Za-z0-9_./-]+\.(?:md|txt))(?=\s|[.,;:]|$)/)?.[1];
228
+ 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))
229
+ && /\b(?:report|bao cao|analysis|phan tich|document)\b/.test(text);
216
230
  const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
217
231
  // A cron/job MENTION is not an operation: with a code-change verb in the request, an
218
232
  // operation needs a concrete target id; otherwise it is a coding task about scheduling.
@@ -237,13 +251,13 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
237
251
  // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
238
252
  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);
239
253
  const statusReport = !mutationWord && !operation && !config
240
- && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
241
- const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
254
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text);
255
+ 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)
242
256
  && !sourceOrder && !codeChangeVerb && !operation && !config;
243
257
  const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
244
- : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
258
+ : config ? 'CONFIGURATION' : documentation ? 'DOCUMENTATION_OUTPUT' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
245
259
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
246
- return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
260
+ return { taskKind, ...(releasePolicy ? { releasePolicy } : {}), ...(documentation ? { reportArtifact } : {}), ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
247
261
  ...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
248
262
  ...(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',
249
263
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
@@ -254,7 +268,7 @@ export function applyTaskSemantics(contract, semantic) {
254
268
  const operationOnly = semantic.taskKind === 'OPERATIONAL'
255
269
  || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
256
270
  return { ...(contract || {}), ...semantic,
257
- completionEvidence: operationOnly ? ['operation-verification']
271
+ completionEvidence: semantic.taskKind === 'DOCUMENTATION_OUTPUT' ? ['report-artifact'] : operationOnly ? ['operation-verification']
258
272
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
259
273
  : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
260
274
  : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };