@ngockhoale/ukit 3.4.46 → 3.4.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -5
- package/manifests/documentation.yaml +22 -0
- package/manifests/platform.full.yaml +27 -0
- package/package.json +4 -1
- package/scripts/dev/capture-omp-stall.mjs +246 -0
- package/scripts/dev/worktree-preflight.mjs +20 -1
- package/scripts/docs/render-instructions.mjs +29 -5
- package/src/core/agentRuntime/supervisor.js +15 -1
- package/src/core/diffPlan.js +38 -12
- package/src/core/executionContracts.js +67 -16
- package/src/core/metadata.js +4 -0
- package/src/core/runInstallPipeline.js +19 -2
- package/src/core/runtimeConfig.js +1 -1
- package/src/diagnostics/ompStallSnapshot.js +199 -0
- package/src/index/playbookRegistry.js +22 -3
- package/src/index/taskRouting.js +2 -2
- package/template_project/.claude/agents/bug-debugger.md +1 -0
- package/template_project/.claude/agents/code-reviewer.md +1 -0
- package/template_project/.claude/agents/feature-implementer.md +1 -0
- package/template_project/.claude/agents/handoff-planner.md +1 -0
- package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.claude/hooks/completion-gate.sh +1 -1
- package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
- package/template_project/.claude/hooks/skill-router.sh +147 -6
- package/template_project/.claude/settings.json +8 -8
- package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
- package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
- package/template_project/.claude/ukit/index/route-task.mjs +115 -37
- package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
- package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +80 -3
- package/template_project/.omp/agents/bug-debugger.md +1 -0
- package/template_project/.omp/agents/code-reviewer.md +1 -0
- package/template_project/.omp/agents/feature-implementer.md +1 -0
- package/template_project/.omp/agents/handoff-planner.md +1 -0
- package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
- package/template_project/ukit/storage/config.json +1 -1
- package/scripts/bench/data-foundation.mjs +0 -929
- package/scripts/bench/decision-runtime-baseline.mjs +0 -427
- package/scripts/bench/decision-runtime-variant.mjs +0 -626
- package/scripts/bench/goldTasks.json +0 -38
- package/scripts/bench/memory-ablation.mjs +0 -495
- package/scripts/bench/memory-baseline.mjs +0 -596
- package/scripts/bench/memory-bench.mjs +0 -661
- package/scripts/bench/memory-canary.mjs +0 -331
- package/scripts/bench/memory-corpus.mjs +0 -354
- package/scripts/bench/memory-gate.mjs +0 -389
- package/scripts/bench/memory-metrics.mjs +0 -179
- package/scripts/bench/outline-savings.mjs +0 -107
- package/scripts/bench/parallel-agents.mjs +0 -224
- package/scripts/bench/recorder-overhead.mjs +0 -204
- package/scripts/bench/runGold.mjs +0 -238
- package/scripts/bench/sqlite-spike.mjs +0 -451
- package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
- package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
- package/scripts/bench/v3-ceremony.mjs +0 -220
- package/scripts/perf/audit-perf.mjs +0 -1195
- package/scripts/perf/diff-perf-findings.mjs +0 -136
- package/scripts/perf/perf-findings.json +0 -474
- package/scripts/perf/perf-measure.md +0 -271
package/CHANGELOG.md
CHANGED
|
@@ -2,13 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
-
##
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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) {
|
package/src/core/diffPlan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|