@ngockhoale/ukit 3.4.27 → 3.4.29

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 (33) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/manifests/documentation.yaml +59 -0
  3. package/manifests/platform.full.yaml +24 -0
  4. package/package.json +6 -1
  5. package/scripts/audit/subagent-cost/metrics.mjs +14 -2
  6. package/scripts/audit/subagent-cost/reader.mjs +15 -3
  7. package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
  8. package/src/cli/commands/update.js +59 -2
  9. package/src/core/agentRuntime/context.js +78 -0
  10. package/src/core/buildPlan.js +11 -1
  11. package/src/core/diffPlan.js +100 -102
  12. package/src/core/observability/emit/sessionBoot.js +14 -0
  13. package/src/core/runtimeConfigMerge.js +125 -0
  14. package/src/core/status.js +3 -1
  15. package/src/core/updateSafety.js +174 -0
  16. package/src/index/contextPack.js +285 -0
  17. package/src/index/sensitivePath.js +21 -0
  18. package/src/index/taskRouting.js +3 -0
  19. package/template_project/.claude/commands/ukit/ci-check.md +53 -0
  20. package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
  21. package/template_project/.claude/hooks/handoff-resume.sh +13 -0
  22. package/template_project/.claude/hooks/record-execution.mjs +9 -0
  23. package/template_project/.claude/hooks/skill-router.sh +3 -0
  24. package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
  25. package/template_project/.claude/ukit/index/route-task.mjs +3 -0
  26. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +26 -12
  27. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +8 -0
  28. package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
  29. package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
  30. package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
  31. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +33 -1
  32. package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
  33. package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
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).
11
+
12
+ ## 3.4.29 - 2026-10-09
13
+
14
+ **Fixes (no new features).**
15
+ - `ukit install` inside the UKit source repo no longer overwrites the hand-curated root `CLAUDE.md` / `AGENTS.md` (repo-only Release Policy was lost). Self-install now seeds but never overwrites them, like `docs/`; downstream projects still get them refreshed. Regression: `tests/core/buildPlan.test.js`.
16
+ - `ukit status` no longer reads the ambient project's `context-capacity.json` (CLAUDE_PROJECT_DIR/cwd); the capacity record is scoped to the reported project root, so `statusCommand.test.js` passes with or without a live runtime cache.
17
+ - Completion gate: `yarn release:verify; echo "exit=$?"` (and `&&`/`|` variants without a space) is now recognised as a verification command, so the green run records `verification-evidence`. Failed runs still record as failed. Regression: `tests/hooks/executionLedgerReleaseVerify.test.js`.
18
+
19
+ ## 3.4.28 - 2026-10-09
20
+
21
+ **Release-safety close-out: config preservation, safe `ukit update`, OMP eval/Bash hardening (local RC; not yet published).**
22
+ - **U1 `.claude/settings.json` ownership merge.** User `model`, `permissions.allow|deny|ask`, `permissions.defaultMode`, user environment entries, `statusLine`, plugins and unknown keys survive install/update. UKit-owned: `hooks` (safety wiring) and the template's own environment keys. Permission lists are a union (template entries always present). Behaviour change: user-added top-level keys no longer count as drift.
23
+ - **U2 `.ukit/storage/config.json` ownership merge** (`src/core/runtimeConfigMerge.js`). User values (model routing, `autonomy`, `orchestration.permissionMode`, thresholds, unknown keys) win; new template keys are filled; `version`/`agent`/`_help` follow the release. The opt-in `orchestration.allowUnsafeEval` is reset to `false` on every upgrade and reported. A merge that would produce an invalid config leaves the file untouched; a corrupt file is replaced (timestamped backup first).
24
+ - **F5 safe `ukit update`.** Preflight (npm runnable, install metadata readable, project writable), project snapshot with sha256 manifest before `npm install -g`, automatic restore + hash verification when the project refresh fails. The global package is **never** rolled back automatically: the output says so and prints `npm install -g @ngockhoale/ukit@<previous>`. Last 3 snapshots kept under `.claude/ukit/.ukit/update-snapshots/`.
25
+ - **F1 OMP `eval` payload** (`{language, code}`) is now seen by the Bash guard chain (was an empty command).
26
+ - **F2 OMP `eval` default-deny** in the bridge itself (independent of `.omp/config.yml`): allowed only with `orchestration.allowUnsafeEval=true`. The guard is best-effort for eval; threat model in `docs/release/MULTI-ENGINE-RELEASE-GATE.md`.
27
+ - **F4 OMP Bash/eval fail closed** on hook-runner transport failure (no verdict = no execution). Read/Grep/Glob stay fail-open. User escape hatch: `UKIT_OMP_BASH_FAIL_OPEN=1`. The Stop lane is unaffected (no Stop loop).
28
+ - Known limits: OMP tools `task`, MCP, `browser`, `user_bash`, `user_python` have no UKit guard chain; the Read/Grep secret guard is advisory on a runner outage.
29
+
5
30
  ## 3.4.27 - 2026-10-09
6
31
 
7
32
  **Stop-gate evidence loop hardening and linked-worktree isolation (follow-up to 3.4.26).**
@@ -418,6 +418,28 @@ entries:
418
418
  validation: [none]
419
419
  archive_policy: immutable
420
420
  notes: C99 measurement-only sub-agent cost audit snapshot; registered in 3.4.25 (was unclassified on 3.4.24)
421
+ - id: docs-analysis-c100-frag-a-d
422
+ path: docs/analysis/c100/frag-A-D.md
423
+ class: archive
424
+ audience: [maintainer]
425
+ owner: product
426
+ source_of_truth: docs/analysis/c100/frag-A-D.md
427
+ merge_strategy: none
428
+ load_policy: never
429
+ validation: [none]
430
+ archive_policy: immutable
431
+ notes: C100 report fragment A+D (cache-accounting trace, model/provider confidence, telemetry gaps); landed pre-integration, registered during MB-1
432
+ - id: docs-analysis-c100-frag-b-c
433
+ path: docs/analysis/c100/frag-B-C.md
434
+ class: archive
435
+ audience: [maintainer]
436
+ owner: product
437
+ source_of_truth: docs/analysis/c100/frag-B-C.md
438
+ merge_strategy: none
439
+ load_policy: never
440
+ validation: [none]
441
+ archive_policy: immutable
442
+ notes: C100 report fragment B+C (127-agent fan-out findings, root cause, productive vs avoidable); landed pre-integration, registered during MB-1
421
443
  - id: docs-skill-audits
422
444
  path: docs/skill-audits/
423
445
  class: archive
@@ -754,6 +776,7 @@ entries:
754
776
  load_policy: on-demand
755
777
  validation: [manual]
756
778
  archive_policy: never
779
+ local_only: true
757
780
  - id: mirror-omp-dir
758
781
  path: .omp/
759
782
  class: derived
@@ -774,6 +797,7 @@ entries:
774
797
  load_policy: on-demand
775
798
  validation: [manual]
776
799
  archive_policy: never
800
+ local_only: true
777
801
 
778
802
  # ── Same-wave additions (DOC-201 batch; files authored by TASK-208/212) ────
779
803
  - id: docs-host-capability-matrix
@@ -821,6 +845,41 @@ entries:
821
845
  archive_policy: never
822
846
  notes: C69 G7 operator doc — timeline render, support bundle contents/exclusions, replay limits
823
847
 
848
+ - id: docs-design-ci-cross-platform-matrix
849
+ path: docs/design/CI-CROSS-PLATFORM-MATRIX.md
850
+ class: canonical
851
+ audience: [maintainer]
852
+ owner: product
853
+ source_of_truth: docs/design/CI-CROSS-PLATFORM-MATRIX.md
854
+ merge_strategy: none
855
+ load_policy: on-demand
856
+ validation: [manual]
857
+ archive_policy: never
858
+ notes: C100 native CI matrix design (macOS/Windows/Linux); maintainer-only, not shipped to user projects
859
+
860
+ - id: docs-release-multi-engine-release-gate
861
+ path: docs/release/MULTI-ENGINE-RELEASE-GATE.md
862
+ class: canonical
863
+ audience: [maintainer]
864
+ owner: product
865
+ source_of_truth: docs/release/MULTI-ENGINE-RELEASE-GATE.md
866
+ merge_strategy: none
867
+ load_policy: on-demand
868
+ validation: [manual]
869
+ archive_policy: never
870
+ notes: OMP-first multi-engine release gate report (3.4.28 candidate); maintainer-only, not shipped to user projects
871
+
872
+ - id: docs-release-3-4-28-team-upgrade-notice
873
+ path: docs/release/3.4.28-TEAM-UPGRADE-NOTICE.md
874
+ class: canonical
875
+ audience: [maintainer]
876
+ owner: product
877
+ source_of_truth: docs/release/3.4.28-TEAM-UPGRADE-NOTICE.md
878
+ merge_strategy: none
879
+ load_policy: on-demand
880
+ validation: [manual]
881
+ archive_policy: never
882
+ notes: Team upgrade notice for 3.4.28 (local RC); maintainer-only, not shipped to user projects
824
883
  # Declared complexity→docs mapping consumed by the router's `docs=` summary segment
825
884
  # (src/index/taskRouting.js CONTEXT_LAYER_DOCS mirrors this block; DOC-201 FR-001/002).
826
885
  # queued-task intent omitted v1: docs/TASKS.md is optional per-repo (SPEC §14).
@@ -1174,6 +1174,18 @@ items:
1174
1174
  packs:
1175
1175
  - core
1176
1176
 
1177
+ - id: command-ci-check
1178
+ type: command
1179
+ sourceTemplate: .claude/commands/ukit/ci-check.md
1180
+ targetPath: .claude/commands/ukit/ci-check.md
1181
+ requires:
1182
+ - ukit-ci-check-script
1183
+ mergeStrategy: overwrite_with_backup
1184
+ variables: []
1185
+ enabledByDefault: true
1186
+ packs:
1187
+ - core
1188
+
1177
1189
  - id: command-handoff-fullstack
1178
1190
  type: command
1179
1191
  sourceTemplate: .claude/commands/ukit/handoff-fullstack.md
@@ -1629,6 +1641,18 @@ items:
1629
1641
  packs:
1630
1642
  - core
1631
1643
 
1644
+ - id: ukit-ci-check-script
1645
+ type: config
1646
+ sourceTemplate: .claude/ukit/index/ci-check.mjs
1647
+ targetPath: .claude/ukit/index/ci-check.mjs
1648
+ requires:
1649
+ - ukit-index-safe-patch-script
1650
+ mergeStrategy: overwrite_with_backup
1651
+ variables: []
1652
+ enabledByDefault: true
1653
+ packs:
1654
+ - core
1655
+
1632
1656
  - id: ukit-index-provision-worktree-script
1633
1657
  type: config
1634
1658
  sourceTemplate: .claude/ukit/index/provision-worktree.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.27",
3
+ "version": "3.4.29",
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",
@@ -30,6 +30,10 @@
30
30
  "bin/",
31
31
  "src/",
32
32
  "scripts/",
33
+ "!scripts/audit/c100/attribution-gap.mjs",
34
+ "!scripts/audit/c100/implementer-attribution.mjs",
35
+ "!scripts/audit/c100/trees-lifetimes.mjs",
36
+ "!scripts/audit/c100/usage-provenance.mjs",
33
37
  "template_project/",
34
38
  "template_user/",
35
39
  "!template_project/**/.smbdelete*",
@@ -59,6 +63,7 @@
59
63
  "worktree:preflight": "node ./scripts/dev/worktree-preflight.mjs",
60
64
  "docs:render": "node scripts/docs/render-instructions.mjs --write",
61
65
  "docs:render:check": "node scripts/docs/render-instructions.mjs --check",
66
+ "test:python:setup": "node tests/python/setup-test-venv.mjs",
62
67
  "test:artifact": "vitest run tests/integration/packageArtifact.test.js tests/integration/artifactReferenceIntegrity.test.js",
63
68
  "test:release-core": "vitest run --exclude tests/integration/packageArtifact.test.js",
64
69
  "release:verify": "node ./scripts/release/verify-release.mjs",
@@ -39,9 +39,21 @@ const totalFor = (calls) => ({
39
39
  uncached: calls.reduce((n, c) => n + uncached(c), 0),
40
40
  inputSide: calls.reduce((n, c) => n + inputSide(c), 0),
41
41
  });
42
+ // Cache metadata is only trustworthy when the read key was recorded (present_zero/present_positive).
43
+ // Absent/invalid calls are excluded from the hit-rate denominator — they are UNKNOWN, not a miss.
44
+ // Calls without a status (historical corpora built before MB-1) are treated as recorded.
45
+ const cacheKnown = (call) => call.cacheMetadataStatus == null
46
+ || call.cacheMetadataStatus === 'present_zero' || call.cacheMetadataStatus === 'present_positive';
42
47
  const cacheFor = (calls) => {
43
- const totals = totalFor(calls);
44
- return { hitRate: ratio(totals.cacheRead, totals.inputSide), cacheRead: totals.cacheRead, inputSide: totals.inputSide };
48
+ const known = calls.filter(cacheKnown);
49
+ const totals = totalFor(known);
50
+ return {
51
+ hitRate: ratio(totals.cacheRead, totals.inputSide),
52
+ cacheRead: totals.cacheRead,
53
+ inputSide: totals.inputSide,
54
+ knownCalls: known.length,
55
+ unknownCalls: calls.length - known.length,
56
+ };
45
57
  };
46
58
  const sortedTop = (items, getValue, getKey) => [...items]
47
59
  .map((item) => ({ ...item, uncached: getValue(item), inputSide: getKey(item) }))
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import readline from 'node:readline';
4
4
  import { createHash } from 'node:crypto';
5
+ import { classifyCacheRead, classifyCacheWrite, cacheMetadataStatus, extractIdentity } from './usage-fields.mjs';
5
6
 
6
7
  const sha1 = (value) => createHash('sha1').update(value).digest('hex').slice(0, 12);
7
8
  const timestampMs = (value) => {
@@ -82,12 +83,15 @@ async function readMeta(file) {
82
83
  }
83
84
  }
84
85
 
85
- function makeCall(record, sessionId, agentId, agentType, depth, ts, priorTs) {
86
+ function makeCall(record, sessionId, agentId, agentType, depth, ts, priorTs, meta = null) {
86
87
  const message = record.message ?? {};
87
88
  const usage = message.usage ?? {};
88
89
  const input = Number(usage.input_tokens) || 0;
89
- const cacheRead = Number(usage.cache_read_input_tokens ?? usage.cache_read_tokens) || 0;
90
- const cacheWrite = Number(usage.cache_creation_input_tokens ?? usage.cache_creation_tokens) || 0;
90
+ const readMetric = classifyCacheRead(usage);
91
+ const writeMetric = classifyCacheWrite(usage);
92
+ const cacheRead = readMetric.value;
93
+ const cacheWrite = writeMetric.value;
94
+ const identity = extractIdentity(record, meta);
91
95
  const output = Number(usage.output_tokens) || 0;
92
96
  const key = message.id ?? record.requestId ?? record.uuid;
93
97
  return {
@@ -102,6 +106,13 @@ function makeCall(record, sessionId, agentId, agentType, depth, ts, priorTs) {
102
106
  input,
103
107
  cacheRead,
104
108
  cacheWrite,
109
+ cacheReadStatus: readMetric.status,
110
+ cacheWriteStatus: writeMetric.status,
111
+ cacheMetadataStatus: cacheMetadataStatus(readMetric, writeMetric),
112
+ providerId: identity.providerId,
113
+ gatewayId: identity.gatewayId,
114
+ taskId: identity.taskId,
115
+ spawnPrefixHash: identity.spawnPrefixHash,
105
116
  output,
106
117
  zero: input === 0 && cacheRead === 0 && cacheWrite === 0 && output === 0,
107
118
  depth,
@@ -181,6 +192,7 @@ export async function loadCorpus(root, { since = null, until = null } = {}) {
181
192
  depth,
182
193
  ts,
183
194
  previousTs,
195
+ meta,
184
196
  );
185
197
  previousTs = ts;
186
198
  const key = call._dedupeKey ?? `line:${stats.lines}`;
@@ -0,0 +1,82 @@
1
+ // MB-1 (C100) — telemetry correctness helpers shared by the sub-agent cost reader.
2
+ // Pure, local, no LLM calls. Rules: a missing/invalid usage field is NEVER a measured zero;
3
+ // an explicit 0 stays 0; provider/gateway/task/prefix identity is read only from explicit
4
+ // allowlisted keys (the model name is never a signal) and unknown stays null.
5
+
6
+ export const CACHE_STATUS = Object.freeze({
7
+ PRESENT_ZERO: 'present_zero',
8
+ PRESENT_POSITIVE: 'present_positive',
9
+ ABSENT: 'absent',
10
+ INVALID: 'invalid',
11
+ });
12
+
13
+ const READ_KEYS = ['cache_read_input_tokens', 'cache_read_tokens'];
14
+ const WRITE_KEYS = ['cache_creation_input_tokens', 'cache_creation_tokens'];
15
+ const PROVIDER_KEYS = ['providerId', 'provider_id', 'provider', 'apiProvider'];
16
+ const GATEWAY_KEYS = ['gatewayId', 'gateway_id', 'gatewayName', 'gateway'];
17
+ const MAX_ID_LENGTH = 128;
18
+ const SAFE_ID = /^[A-Za-z0-9._:/@-]{1,128}$/;
19
+ const SECRET_LIKE = /sk-[A-Za-z0-9]{8,}|bearer|secret|passw|token=|key=|api[_-]?key/i;
20
+
21
+ function numericOrNull(raw) {
22
+ if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null;
23
+ if (typeof raw === 'string' && raw.trim() !== '') {
24
+ const value = Number(raw);
25
+ return Number.isFinite(value) ? value : null;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /** Classify one cache metric. `value` is only the measured number; 0 for absent/invalid is a
31
+ * summing convenience — consumers must read `status`, never infer a miss from value 0. */
32
+ export function classifyCacheMetric(usage, keys) {
33
+ if (!usage || typeof usage !== 'object') return { status: CACHE_STATUS.ABSENT, value: 0 };
34
+ for (const key of keys) {
35
+ const raw = usage[key];
36
+ if (raw === undefined || raw === null) continue;
37
+ const value = numericOrNull(raw);
38
+ if (value === null || value < 0) return { status: CACHE_STATUS.INVALID, value: 0 };
39
+ return { status: value === 0 ? CACHE_STATUS.PRESENT_ZERO : CACHE_STATUS.PRESENT_POSITIVE, value };
40
+ }
41
+ return { status: CACHE_STATUS.ABSENT, value: 0 };
42
+ }
43
+
44
+ export const classifyCacheRead = (usage) => classifyCacheMetric(usage, READ_KEYS);
45
+ export const classifyCacheWrite = (usage) => classifyCacheMetric(usage, WRITE_KEYS);
46
+
47
+ /** Combined status for a call: a known read status wins; otherwise the worse of the pair. */
48
+ export function cacheMetadataStatus(read, write) {
49
+ if (read.status === CACHE_STATUS.INVALID || write.status === CACHE_STATUS.INVALID) return CACHE_STATUS.INVALID;
50
+ if (read.status === CACHE_STATUS.ABSENT) return CACHE_STATUS.ABSENT;
51
+ return read.status;
52
+ }
53
+
54
+ function safeId(raw) {
55
+ if (typeof raw !== 'string') return null;
56
+ const value = raw.trim();
57
+ if (value.length === 0 || value.length > MAX_ID_LENGTH) return null;
58
+ if (!SAFE_ID.test(value) || SECRET_LIKE.test(value)) return null;
59
+ return value;
60
+ }
61
+
62
+ function firstId(sources, keys) {
63
+ for (const source of sources) {
64
+ if (!source || typeof source !== 'object') continue;
65
+ for (const key of keys) {
66
+ const id = safeId(source[key]);
67
+ if (id) return id;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /** Identity metadata from explicit keys only; null means UNKNOWN (never a guessed value). */
74
+ export function extractIdentity(record, meta = null) {
75
+ const sources = [record, record?.message, meta];
76
+ return {
77
+ providerId: firstId(sources, PROVIDER_KEYS),
78
+ gatewayId: firstId(sources, GATEWAY_KEYS),
79
+ taskId: firstId(sources, ['taskId', 'task_id']),
80
+ spawnPrefixHash: firstId(sources, ['spawnPrefixHash', 'spawn_prefix_hash']),
81
+ };
82
+ }
@@ -1,8 +1,14 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { preflightUpdate, createUpdateSnapshot, restoreUpdateSnapshot } from '../../core/updateSafety.js';
1
4
  import { updateUkit, runInstallAfterUpdate, readInstalledGlobalVersion, UKIT_PACKAGE_NAME } from '../../core/update.js';
2
5
 
3
6
  const KNOWN_UPDATE_FLAGS = new Set(['--help', '-h', '--no-install']);
4
7
 
5
- export async function runUpdate({ packageVersion, argv = [], cwd = process.cwd() }) {
8
+ const projectHasUkit = (cwd) => ['.ukit', path.join('.claude', 'ukit')].some((m) => fs.existsSync(path.join(cwd, m)));
9
+ const INSTALL_FAILURE = /^install-(failed|exited)/;
10
+
11
+ export async function runUpdate({ packageVersion, argv = [], cwd = process.cwd(), safety = { preflightUpdate, createUpdateSnapshot, restoreUpdateSnapshot } }) {
6
12
  if (argv.includes('--help') || argv.includes('-h')) {
7
13
  console.log('Usage: ukit update [--no-install]');
8
14
  console.log('');
@@ -28,9 +34,39 @@ export async function runUpdate({ packageVersion, argv = [], cwd = process.cwd()
28
34
  }
29
35
 
30
36
  console.log(`[UKit] Version before update: ${packageVersion}`);
37
+
38
+ // F5: preflight + project snapshot BEFORE anything changes. A failed preflight aborts
39
+ // with nothing modified; the snapshot is what makes a failed project refresh recoverable.
40
+ const refreshWanted = !argv.includes('--no-install') && projectHasUkit(cwd);
41
+ let snapshot = null;
42
+ if (refreshWanted) {
43
+ const pre = await safety.preflightUpdate({ projectRoot: cwd });
44
+ if (!pre.ok) {
45
+ console.log('[UKit] Update NOT started — preflight failed, nothing was changed:');
46
+ for (const problem of pre.problems) console.log(` - ${problem}`);
47
+ process.exitCode = 1;
48
+ return;
49
+ }
50
+ try {
51
+ snapshot = await safety.createUpdateSnapshot({ projectRoot: cwd, previousGlobalVersion: packageVersion });
52
+ console.log(`[UKit] Project snapshot saved: ${path.relative(cwd, snapshot.dir) || snapshot.dir}`);
53
+ } catch (error) {
54
+ console.log(`[UKit] Update NOT started — could not snapshot the project (${error.message}). Nothing was changed.`);
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ }
59
+
31
60
  console.log(`[UKit] Running: npm install -g ${UKIT_PACKAGE_NAME}`);
32
61
 
33
- updateUkit();
62
+ try {
63
+ updateUkit();
64
+ } catch (error) {
65
+ console.log(`[UKit] npm update failed: ${error.message}`);
66
+ console.log('[UKit] The project was not touched. The global package may be partially installed — run `ukit --version` to check, then retry `ukit update`.');
67
+ process.exitCode = 1;
68
+ return;
69
+ }
34
70
 
35
71
  // This process still runs the OLD code, so report the version the update actually
36
72
  // installed (read from disk), never the stale `packageVersion`.
@@ -72,5 +108,26 @@ export async function runUpdate({ packageVersion, argv = [], cwd = process.cwd()
72
108
  return;
73
109
  }
74
110
  console.log(`[UKit] CLI upgraded, but the project refresh did not finish (${reason}).`);
111
+ process.exitCode = 1;
112
+ if (snapshot && INSTALL_FAILURE.test(reason || '')) {
113
+ let result;
114
+ try {
115
+ result = await safety.restoreUpdateSnapshot({ projectRoot: cwd, snapshotDir: snapshot.dir });
116
+ } catch (error) {
117
+ result = { ok: false, failures: [error.message], mismatches: [] };
118
+ }
119
+ if (result.ok) {
120
+ console.log(`[UKit] PROJECT files restored from the snapshot and verified (${result.restored} files).`);
121
+ } else {
122
+ console.log('[UKit] PROJECT restore was incomplete — snapshot kept for manual recovery:');
123
+ for (const item of [...result.failures, ...result.mismatches]) console.log(` - ${item}`);
124
+ console.log(`[UKit] Snapshot: ${snapshot.dir}`);
125
+ }
126
+ // Never imply a full rollback: only project files were restored.
127
+ console.log(`[UKit] The GLOBAL package was NOT rolled back — it is still ${installedVersion ?? 'the new version'}, while this project is on the previous files.`);
128
+ console.log(`[UKit] To return to the old CLI as well: npm install -g ${UKIT_PACKAGE_NAME}@${packageVersion}`);
129
+ console.log('[UKit] To retry the migration with the new CLI: ukit install');
130
+ return;
131
+ }
75
132
  console.log('[UKit] Run `ukit install` to retry.');
76
133
  }
@@ -15,6 +15,7 @@ import crypto from 'node:crypto';
15
15
 
16
16
  import { validateTaskContract } from './contract.js';
17
17
  import { eligible } from '../memory/policy.js';
18
+ import { isSensitivePath } from '../../index/sensitivePath.js';
18
19
 
19
20
  const STALE_MS = 15 * 60 * 1000; // default freshness window for evidence
20
21
 
@@ -491,3 +492,80 @@ export async function buildSubagentContext({
491
492
  contextRefs,
492
493
  };
493
494
  }
495
+
496
+ // --- TASK-B3 (PLAN-ENDUR-B §B3): context pack → delegation candidates -------
497
+ // Pure mapping from a buildContextPack result (TASK-B2, plain data) into
498
+ // candidates for buildSubagentContext. No fs/config/network/LLM access here —
499
+ // the pack is supplied by the caller; this module stays I/O-free.
500
+ //
501
+ // Freshness honesty (documented choice, TASK-B3 Discussion): the pack carries
502
+ // no generation timestamp (B2 is clock-free), so "not fresh" is expressed via
503
+ // the EXISTING observedAt/ttlMs staleness path of buildSubagentContext —
504
+ // no new exclusion machinery, no change to its output shape:
505
+ // - fresh pack (stale:false, fallback:null): observedAt = mapping `now`,
506
+ // ttlMs = STALE_MS → included while within the default evidence window.
507
+ // - degraded pack (stale:true OR fallback set): observedAt = 0, ttlMs = 0 →
508
+ // excluded 'stale' by any real downstream clock (nowMs > 0). Candidates are
509
+ // still mapped (visible as excluded manifest items), never fresh-labeled.
510
+ // Sensitive paths use the shared pure predicate src/index/sensitivePath.js
511
+ // (same one contextPack.js uses — no mirrored copy) and map with
512
+ // sensitivity:'sensitive' so the existing redaction path excludes them.
513
+
514
+ // Deterministic content text over path/reason/outline only. Outline order is
515
+ // authoritative from the pack (B2 emits line-sorted outlines).
516
+ function packEntryText(entry) {
517
+ const lines = [
518
+ `path: ${entry.path}`,
519
+ `reason: ${isNonEmptyStr(entry.reason) ? entry.reason : 'unknown'}`,
520
+ ];
521
+ for (const symbol of Array.isArray(entry.outline) ? entry.outline : []) {
522
+ if (symbol == null || typeof symbol !== 'object' || !isNonEmptyStr(symbol.name)) {
523
+ continue;
524
+ }
525
+ const type = isNonEmptyStr(symbol.type) ? symbol.type : 'symbol';
526
+ const signature = isNonEmptyStr(symbol.signature) ? ` ${symbol.signature}` : '';
527
+ lines.push(`symbol: ${symbol.name} (${type})${signature}`);
528
+ }
529
+ return lines.join('\n');
530
+ }
531
+
532
+ /**
533
+ * Map a buildContextPack result to buildSubagentContext candidates (pure).
534
+ *
535
+ * @param {object|null} pack buildContextPack result; malformed input maps to [].
536
+ * @param {object} [options]
537
+ * @param {number} [options.now] Mapping clock; anchors fresh candidates'
538
+ * observedAt (default Date.now(), matching module convention).
539
+ * @returns {Array<object>} One candidate per pack file: ref `pack:<path>`,
540
+ * kind 'evidence', provenance 'context-pack', required:false, deterministic
541
+ * content; degraded packs (stale/fallback) carry observedAt:0/ttlMs:0 so the
542
+ * existing staleness path excludes them; sensitive paths carry
543
+ * sensitivity:'sensitive' for the existing redaction path.
544
+ */
545
+ export function contextPackToCandidates(pack, { now } = {}) {
546
+ const nowMs = typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
547
+ const files = pack != null && typeof pack === 'object' && Array.isArray(pack.files)
548
+ ? pack.files
549
+ : [];
550
+ const degraded = pack?.stale === true || pack?.fallback != null;
551
+ const observedAt = degraded ? 0 : nowMs;
552
+ const ttlMs = degraded ? 0 : STALE_MS;
553
+
554
+ const candidates = [];
555
+ for (const entry of files) {
556
+ if (entry == null || typeof entry !== 'object' || !isNonEmptyStr(entry.path)) {
557
+ continue;
558
+ }
559
+ candidates.push({
560
+ ref: `pack:${entry.path}`,
561
+ content: packEntryText(entry),
562
+ required: false,
563
+ kind: 'evidence',
564
+ provenance: 'context-pack',
565
+ observedAt,
566
+ ttlMs,
567
+ ...(isSensitivePath(entry.path) ? { sensitivity: 'sensitive' } : {}),
568
+ });
569
+ }
570
+ return candidates;
571
+ }
@@ -45,6 +45,16 @@ function safeResolve(basePath, relativePath) {
45
45
  return resolvedPath;
46
46
  }
47
47
 
48
+ // Root instruction files are hand-curated canonical sources in the UKit repo itself (they carry
49
+ // the repo-only Release Policy, rendered from instructions/ + the repo overlay), so a
50
+ // self-install must seed them but never overwrite them.
51
+ const SELF_INSTALL_PROTECTED_ROOT_FILES = new Set(['CLAUDE.md', 'AGENTS.md']);
52
+
53
+ function isSelfInstallProtectedTarget(targetPath, projectRoot) {
54
+ return isDocsTarget(targetPath, projectRoot)
55
+ || SELF_INSTALL_PROTECTED_ROOT_FILES.has(path.relative(projectRoot, targetPath));
56
+ }
57
+
48
58
  function isDocsTarget(targetPath, projectRoot) {
49
59
  const relative = path.relative(projectRoot, targetPath);
50
60
  return relative === 'docs' || relative.startsWith(`docs${path.sep}`);
@@ -88,7 +98,7 @@ async function downgradeSelfInstallDocOverwrites(planEntries, { templatesRoot, p
88
98
  if (entry.type === 'link' || entry.mergeStrategy !== 'overwrite_with_backup') {
89
99
  continue;
90
100
  }
91
- if (isDocsTarget(entry.targetPath, projectRoot)) {
101
+ if (isSelfInstallProtectedTarget(entry.targetPath, projectRoot)) {
92
102
  entry.mergeStrategy = 'skip';
93
103
  }
94
104
  }