@ngockhoale/ukit 3.4.46 → 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,13 +2,13 @@
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
11
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
12
  ## 3.4.46 - 2026-10-18
13
13
 
14
14
  **Completion contract P0: forensic intent, attributable release evidence and conditional NO-GO.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.46",
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",
@@ -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) {
@@ -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
+ }
@@ -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: