@ngockhoale/ukit 3.4.46 → 3.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +13 -5
  2. package/manifests/documentation.yaml +22 -0
  3. package/manifests/platform.full.yaml +27 -0
  4. package/package.json +4 -1
  5. package/scripts/dev/capture-omp-stall.mjs +246 -0
  6. package/scripts/dev/worktree-preflight.mjs +20 -1
  7. package/scripts/docs/render-instructions.mjs +29 -5
  8. package/src/core/agentRuntime/supervisor.js +15 -1
  9. package/src/core/diffPlan.js +38 -12
  10. package/src/core/executionContracts.js +67 -16
  11. package/src/core/metadata.js +4 -0
  12. package/src/core/runInstallPipeline.js +19 -2
  13. package/src/core/runtimeConfig.js +1 -1
  14. package/src/diagnostics/ompStallSnapshot.js +199 -0
  15. package/src/index/playbookRegistry.js +22 -3
  16. package/src/index/taskRouting.js +2 -2
  17. package/template_project/.claude/agents/bug-debugger.md +1 -0
  18. package/template_project/.claude/agents/code-reviewer.md +1 -0
  19. package/template_project/.claude/agents/feature-implementer.md +1 -0
  20. package/template_project/.claude/agents/handoff-planner.md +1 -0
  21. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  22. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  23. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  24. package/template_project/.claude/hooks/skill-router.sh +147 -6
  25. package/template_project/.claude/settings.json +8 -8
  26. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  27. package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
  28. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  29. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  30. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  31. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
  32. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  33. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  34. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  35. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  36. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +80 -3
  37. package/template_project/.omp/agents/bug-debugger.md +1 -0
  38. package/template_project/.omp/agents/code-reviewer.md +1 -0
  39. package/template_project/.omp/agents/feature-implementer.md +1 -0
  40. package/template_project/.omp/agents/handoff-planner.md +1 -0
  41. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  42. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  43. package/template_project/ukit/storage/config.json +1 -1
  44. package/scripts/bench/data-foundation.mjs +0 -929
  45. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  46. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  47. package/scripts/bench/goldTasks.json +0 -38
  48. package/scripts/bench/memory-ablation.mjs +0 -495
  49. package/scripts/bench/memory-baseline.mjs +0 -596
  50. package/scripts/bench/memory-bench.mjs +0 -661
  51. package/scripts/bench/memory-canary.mjs +0 -331
  52. package/scripts/bench/memory-corpus.mjs +0 -354
  53. package/scripts/bench/memory-gate.mjs +0 -389
  54. package/scripts/bench/memory-metrics.mjs +0 -179
  55. package/scripts/bench/outline-savings.mjs +0 -107
  56. package/scripts/bench/parallel-agents.mjs +0 -224
  57. package/scripts/bench/recorder-overhead.mjs +0 -204
  58. package/scripts/bench/runGold.mjs +0 -238
  59. package/scripts/bench/sqlite-spike.mjs +0 -451
  60. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  61. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  62. package/scripts/bench/v3-ceremony.mjs +0 -220
  63. package/scripts/perf/audit-perf.mjs +0 -1195
  64. package/scripts/perf/diff-perf-findings.mjs +0 -136
  65. package/scripts/perf/perf-findings.json +0 -474
  66. package/scripts/perf/perf-measure.md +0 -271
package/CHANGELOG.md CHANGED
@@ -2,13 +2,21 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
- ## Unreleased
5
+ ## 3.4.48 - 2026-10-19
6
+
7
+ **Completion evidence closure and reproducible multi-host installation.**
8
+ - Recognize standalone `git diff --check` as native Git verification rather than a dry-run mutation. Failed/masked mutations, piped verification, foreign-task evidence and release remote/tag divergence still block.
9
+ - Preserve task ownership through host-consumed coordination notices and continuations; bound Stop diagnostics without accepting incomplete work. Explicit no-publication lists and quoted/report-only references do not authorize a release.
10
+ - Integrate verified OMP child-session/model-ownership safeguards, delegation runtime, installer defaults migration, runtime package dependencies and reproducible scaffolds.
11
+ - Verified locally on macOS/Node 22. Claude/Codex/OMP asset installation is covered; OMP Full Playbook live correctness and trusted worker/dispatch attribution remain UNVERIFIED. No paid OMP smoke or cross-platform live PASS is claimed.
6
12
 
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).
13
+ ## 3.4.47 - 2026-10-18
11
14
 
15
+ **Supervisor subagent usage attribution and rendered-instruction parity checks.**
16
+ - 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.
17
+ - Add read-only `render-instructions.mjs --parity` to byte-compare rendered root instructions with existing root files, including fixture-target support and drift diagnostics.
18
+ - Add manual-only `/ukit:ci-check` cross-platform CI tooling; it remains opt-in and never pushes, merges, or publishes.
19
+ - 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
20
  ## 3.4.46 - 2026-10-18
13
21
 
14
22
  **Completion contract P0: forensic intent, attributable release evidence and conditional NO-GO.**
@@ -517,6 +517,28 @@ entries:
517
517
  validation: [none]
518
518
  archive_policy: immutable
519
519
  notes: 2026-10-10 orchestration regression test inventory
520
+ - id: docs-analysis-completion-task-ownership
521
+ path: docs/analysis/COMPLETION-TASK-OWNERSHIP.md
522
+ class: archive
523
+ audience: [maintainer]
524
+ owner: product
525
+ source_of_truth: docs/analysis/COMPLETION-TASK-OWNERSHIP.md
526
+ merge_strategy: none
527
+ load_policy: never
528
+ validation: [none]
529
+ archive_policy: immutable
530
+ notes: 2026-10-19 task boundary reproduction and bounded Stop verification
531
+ - id: docs-analysis-omp-runtime-closure
532
+ path: docs/analysis/OMP-RUNTIME-CLOSURE.md
533
+ class: archive
534
+ audience: [maintainer]
535
+ owner: product
536
+ source_of_truth: docs/analysis/OMP-RUNTIME-CLOSURE.md
537
+ merge_strategy: none
538
+ load_policy: never
539
+ validation: [none]
540
+ archive_policy: immutable
541
+ notes: 2026-10-19 captured OMP lifecycle closure and offline verification boundaries
520
542
  - id: docs-analysis-orchestration-final-verification
521
543
  path: docs/analysis/ORCHESTRATION-FINAL-VERIFICATION.md
522
544
  class: archive
@@ -1222,6 +1222,7 @@ items:
1222
1222
  - hook-auto-allow-bash
1223
1223
  - hook-block-dangerous
1224
1224
  - hook-handoff-model-guard
1225
+ - hook-delegation-gate-module
1225
1226
  - hook-auto-prune-bash
1226
1227
  - hook-project-important
1227
1228
  # Env block is post-merged by applyGatewayResilienceEnv (TASK-011) to add managed
@@ -1422,6 +1423,20 @@ items:
1422
1423
  packs:
1423
1424
  - core
1424
1425
 
1426
+ # P0 delegation enforcement: in-proc PreToolUse chain step (Edit|Write, MultiEdit, Bash). Claude Code
1427
+ # only (agent_id main/worker identity); reads route state + ledger from ukit-runtime-scripts.
1428
+ - id: hook-delegation-gate-module
1429
+ type: hook
1430
+ sourceTemplate: .claude/hooks/delegation-gate.mjs
1431
+ targetPath: .claude/hooks/delegation-gate.mjs
1432
+ requires:
1433
+ - ukit-runtime-scripts
1434
+ mergeStrategy: overwrite_with_backup
1435
+ variables: []
1436
+ enabledByDefault: true
1437
+ packs:
1438
+ - core
1439
+
1425
1440
  - id: hook-handoff-model-guard
1426
1441
  type: hook
1427
1442
  sourceTemplate: .claude/hooks/handoff-model-guard.sh
@@ -1442,6 +1457,7 @@ items:
1442
1457
  - ukit-runtime-scripts
1443
1458
  - ukit-index-route-resolver-script
1444
1459
  - ukit-index-tier-selection-script
1460
+ - ukit-index-tier-lane-script
1445
1461
  mergeStrategy: overwrite_with_backup
1446
1462
  variables: []
1447
1463
  enabledByDefault: true
@@ -1865,6 +1881,16 @@ items:
1865
1881
  packs:
1866
1882
  - core
1867
1883
 
1884
+ - id: ukit-index-tier-lane-script
1885
+ type: config
1886
+ sourceTemplate: .claude/ukit/index/tier-lane.mjs
1887
+ targetPath: .claude/ukit/index/tier-lane.mjs
1888
+ mergeStrategy: overwrite_with_backup
1889
+ variables: []
1890
+ enabledByDefault: true
1891
+ packs:
1892
+ - core
1893
+
1868
1894
  - id: ukit-index-session-history-extractor-script
1869
1895
  type: config
1870
1896
  sourceTemplate: .claude/ukit/index/session-history-extractor.mjs
@@ -2012,6 +2038,7 @@ items:
2012
2038
  - ukit-index-session-history-extractor-script
2013
2039
  - ukit-index-fix-loop-escalation-script
2014
2040
  - ukit-index-tier-selection-script
2041
+ - ukit-index-tier-lane-script
2015
2042
  mergeStrategy: overwrite_with_backup
2016
2043
  variables: []
2017
2044
  enabledByDefault: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.46",
3
+ "version": "3.4.48",
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",
@@ -36,6 +36,9 @@
36
36
  "!scripts/audit/c100/usage-provenance.mjs",
37
37
  "!scripts/audit/decision-coverage.mjs",
38
38
  "!scripts/audit/subagent-cost/**",
39
+ "!scripts/bench/**",
40
+ "scripts/bench/decision-runtime-metrics.mjs",
41
+ "!scripts/perf/**",
39
42
  "template_project/",
40
43
  "template_user/",
41
44
  "!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
+ }
@@ -7,6 +7,8 @@
7
7
  // - already bootstrapped → no-op
8
8
  // - missing prerequisites → run `ukit install` once, re-check
9
9
  // - not a linked worktree → refuse (the primary checkout's .claude/ is the maintainer runtime)
10
+ // Install can also create template-owned scaffolds missing from the checkout (docs/AI_HANDOFF/PLAN.md, SPEC.md);
11
+ // those are not gitignored, so they are reported as `visibleWrites` + a warning, never hidden or deleted.
10
12
  // `ukit install` writes the install flavour of root CLAUDE.md/AGENTS.md, but those files are
11
13
  // gitignored *render outputs* of template_project/instructions (docs:render). The preflight
12
14
  // re-renders them when they drift — never a tracked render target, which is only reported.
@@ -57,6 +59,15 @@ function isTracked(root, rel) {
57
59
 
58
60
  const RENDER_CLI = 'scripts/docs/render-instructions.mjs';
59
61
 
62
+ // Visible (non-ignored) paths in `git status`. `ukit install` creates template-owned scaffolds that
63
+ // are missing from the checkout (docs/AI_HANDOFF/PLAN.md, SPEC.md — mergeStrategy skip, handoff-clear
64
+ // restores them to template, never deletes them). They are not gitignored, so a HEAD that lost them
65
+ // turns the bootstrap into a visible write. Report it truthfully instead of hiding or deleting it.
66
+ function visibleStatus(root) {
67
+ const res = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
68
+ return res.status === 0 ? new Set(res.stdout.split('\n').filter(Boolean)) : null;
69
+ }
70
+
60
71
  // → rel paths whose rendered bytes differ from disk (`DRIFT <target>` lines of --check).
61
72
  export function renderDrift(root) {
62
73
  const res = spawnSync(process.execPath, [path.join(root, RENDER_CLI), '--check'], { cwd: root, encoding: 'utf8', timeout: RENDER_TIMEOUT_MS });
@@ -71,7 +82,7 @@ export function missingPrerequisites(root) {
71
82
 
72
83
  export function preflight({ root, check = false } = {}) {
73
84
  const projectRoot = fs.realpathSync(root);
74
- const result = { root: projectRoot, ok: false, bootstrapped: false, missing: [], rendered: [], warnings: [], home: null, error: null };
85
+ const result = { root: projectRoot, ok: false, bootstrapped: false, missing: [], rendered: [], visibleWrites: [], warnings: [], home: null, error: null };
75
86
 
76
87
  if (!fs.existsSync(path.join(projectRoot, 'bin/ukit'))) {
77
88
  return { ...result, exitCode: 2, error: `not a UKit checkout (no bin/ukit): ${projectRoot}` };
@@ -91,6 +102,7 @@ export function preflight({ root, check = false } = {}) {
91
102
  // worktree's ignored .cache/ so a preflight never mutates the real home or leaks from it.
92
103
  result.home = path.join(projectRoot, '.cache/worktree-preflight-home');
93
104
  fs.mkdirSync(result.home, { recursive: true });
105
+ const visibleBefore = visibleStatus(projectRoot);
94
106
  const install = spawnSync(process.execPath, [path.join(projectRoot, 'bin/ukit'), 'install'], {
95
107
  cwd: projectRoot,
96
108
  encoding: 'utf8',
@@ -98,6 +110,13 @@ export function preflight({ root, check = false } = {}) {
98
110
  timeout: 120_000,
99
111
  });
100
112
  result.bootstrapped = true;
113
+ const visibleAfter = visibleStatus(projectRoot);
114
+ if (visibleBefore && visibleAfter) {
115
+ result.visibleWrites = [...visibleAfter].filter((l) => !visibleBefore.has(l)).map((l) => l.slice(3));
116
+ if (result.visibleWrites.length > 0) {
117
+ result.warnings.push(`ukit install created non-ignored file(s) missing from this checkout: ${result.visibleWrites.join(', ')} — template-owned scaffolds; restore/commit them (not deleted)`);
118
+ }
119
+ }
101
120
  result.missing = missingPrerequisites(projectRoot);
102
121
  if (install.status !== 0 || result.missing.length > 0) {
103
122
  const tail = `${install.stdout || ''}${install.stderr || ''}`.trim().split('\n').slice(-5).join('\n');
@@ -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) {
@@ -62,6 +62,15 @@ async function checkLinkStatus(targetPath, linkTarget) {
62
62
  // Template-filled: keys the user does not have take the template value.
63
63
  // Gateway-resilience env keys are post-apply managed (applyGatewayResilienceEnv) and are
64
64
  // ignored by the drift comparison.
65
+ //
66
+ // One-time defaults migration (orchestrator tier + effort): UKit used to ship `model: "sonnet"`
67
+ // and `effortLevel: "high"`; it now ships `opus` / `medium` (main orchestrator = Tier 3, effort
68
+ // Medium unless the task is high-risk). A key is migrated ONLY when the file is UKit-tracked, the
69
+ // user's value still equals the old shipped default, and the migration has not run before
70
+ // (install.json `settingsDefaultsMigration`) — so a later deliberate choice is never touched
71
+ // again, and settings.local.json / user settings (never edited by UKit) always win at runtime.
72
+ export const SETTINGS_DEFAULTS_MIGRATION_ID = 'orchestrator-opus-medium-v1';
73
+ const LEGACY_SHIPPED_SETTINGS = { model: 'sonnet', effortLevel: 'high' };
65
74
  const UKIT_OWNED_TOP_LEVEL_KEYS = new Set(['hooks']);
66
75
  const PERMISSION_LIST_KEYS = new Set(['allow', 'deny', 'ask', 'additionalDirectories']);
67
76
  const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -94,7 +103,22 @@ function unionList(templateList, userList) {
94
103
  return out;
95
104
  }
96
105
 
97
- function mergeSettingsObjects(existing, rendered) {
106
+ // Keys whose old shipped default is still in place and should take the new template value.
107
+ function legacyDefaultKeys(existing, rendered, migrate) {
108
+ if (!migrate) return [];
109
+ return Object.keys(LEGACY_SHIPPED_SETTINGS).filter((key) => (
110
+ existing[key] === LEGACY_SHIPPED_SETTINGS[key] && key in rendered && rendered[key] !== existing[key]
111
+ ));
112
+ }
113
+
114
+ export function settingsDefaultsMigrationKeys(existingContent, renderedContent) {
115
+ const existing = parseSettingsJson(existingContent);
116
+ const rendered = parseSettingsJson(renderedContent);
117
+ return existing && rendered ? legacyDefaultKeys(existing, rendered, true) : [];
118
+ }
119
+
120
+ function mergeSettingsObjects(existing, rendered, { migrateDefaults = false } = {}) {
121
+ const migrated = new Set(legacyDefaultKeys(existing, rendered, migrateDefaults));
98
122
  const merged = {};
99
123
  for (const key of Object.keys(rendered)) {
100
124
  if (BLOCKED_KEYS.has(key)) continue;
@@ -124,7 +148,7 @@ function mergeSettingsObjects(existing, rendered) {
124
148
  // user value has the wrong shape (e.g. permissions: "x") — template shape wins
125
149
  merged[key] = templateValue;
126
150
  } else {
127
- merged[key] = userValue; // user-owned scalar/section (model, effortLevel, …)
151
+ merged[key] = migrated.has(key) ? templateValue : userValue; // user-owned scalar/section (model, effortLevel, …)
128
152
  }
129
153
  }
130
154
  return merged;
@@ -133,11 +157,11 @@ function mergeSettingsObjects(existing, rendered) {
133
157
  // Merge rendered settings.json with the user's existing file per the ownership model above.
134
158
  // Returns the merged JSON as a string, or the rendered content unchanged when either side is
135
159
  // unparseable (the caller still takes a timestamped backup before writing).
136
- export function mergeSettingsJson(existingContent, renderedContent) {
160
+ export function mergeSettingsJson(existingContent, renderedContent, options = {}) {
137
161
  const existing = parseSettingsJson(existingContent);
138
162
  const rendered = parseSettingsJson(renderedContent);
139
163
  if (!existing || !rendered) return renderedContent;
140
- return `${JSON.stringify(mergeSettingsObjects(existing, rendered), null, 2)}\n`;
164
+ return `${JSON.stringify(mergeSettingsObjects(existing, rendered, options), null, 2)}\n`;
141
165
  }
142
166
 
143
167
  function withoutGatewayEnv(settings) {
@@ -147,11 +171,11 @@ function withoutGatewayEnv(settings) {
147
171
  return { ...settings, env };
148
172
  }
149
173
 
150
- function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
174
+ function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent, options = {}) {
151
175
  const existing = parseSettingsJson(existingContent);
152
176
  const rendered = parseSettingsJson(renderedContent);
153
177
  if (!existing || !rendered) return false;
154
- const merged = mergeSettingsObjects(existing, rendered);
178
+ const merged = mergeSettingsObjects(existing, rendered, options);
155
179
  return JSON.stringify(canonicalize(withoutGatewayEnv(merged)))
156
180
  === JSON.stringify(canonicalize(withoutGatewayEnv(existing)));
157
181
  }
@@ -208,7 +232,7 @@ function ukitInstalledText(mergedText, renderedText) {
208
232
  }
209
233
  }
210
234
 
211
- function resolveFileAction(entry, existingContent, ompOwnership = null) {
235
+ function resolveFileAction(entry, existingContent, ompOwnership = null, settingsMigration = null) {
212
236
  const exists = existingContent !== null;
213
237
  const binaryEntry = Buffer.isBuffer(entry.renderedContent);
214
238
 
@@ -273,9 +297,11 @@ function resolveFileAction(entry, existingContent, ompOwnership = null) {
273
297
  // User-owned permission keys (permissions.defaultMode) are excluded from the drift
274
298
  // check AND merged back into the content that gets written, so install/update can
275
299
  // never flip the user's chosen permission mode.
276
- const merged = mergeSettingsJson(existingContent, entry.renderedContent);
277
- action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent) ? 'unchanged' : 'update';
278
- return { ...entry, renderedContent: merged, exists, action, existingContent };
300
+ const mergeOptions = { migrateDefaults: settingsMigration?.pending === true };
301
+ const merged = mergeSettingsJson(existingContent, entry.renderedContent, mergeOptions);
302
+ action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent, mergeOptions) ? 'unchanged' : 'update';
303
+ const migratedKeys = mergeOptions.migrateDefaults ? settingsDefaultsMigrationKeys(existingContent, entry.renderedContent) : [];
304
+ return { ...entry, renderedContent: merged, exists, action, existingContent, ...(migratedKeys.length ? { settingsDefaultsMigrated: migratedKeys } : {}) };
279
305
  } else if (entry.id === 'runtime-config' && typeof existingContent === 'string' && typeof entry.renderedContent === 'string') {
280
306
  // U2: .ukit/storage/config.json is user-owned state seeded from the template — merge
281
307
  // by ownership instead of overwriting (see runtimeConfigMerge.js).
@@ -332,7 +358,7 @@ async function resolveSkipEntryAction(entry, ompOwnership = null) {
332
358
  return resolveFileAction(entry, read.content, ompOwnership);
333
359
  }
334
360
 
335
- export async function diffInstallPlan(plan, { ompOwnership = null } = {}) {
361
+ export async function diffInstallPlan(plan, { ompOwnership = null, settingsMigration = null } = {}) {
336
362
  // Check all entries in parallel
337
363
  const results = await Promise.all(
338
364
  plan.entries.map(async (entry) => {
@@ -352,7 +378,7 @@ export async function diffInstallPlan(plan, { ompOwnership = null } = {}) {
352
378
  if (read.status === 'unreadable') {
353
379
  return conflictResult(entry, read.error);
354
380
  }
355
- return resolveFileAction(entry, read.status === 'ok' ? read.content : null, ompOwnership);
381
+ return resolveFileAction(entry, read.status === 'ok' ? read.content : null, ompOwnership, settingsMigration);
356
382
  }),
357
383
  );
358
384