@ngockhoale/ukit 3.4.26 → 3.4.28

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 (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/manifests/documentation.yaml +61 -0
  3. package/manifests/platform.full.yaml +24 -0
  4. package/package.json +7 -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/scripts/dev/worktree-preflight.mjs +147 -0
  9. package/src/cli/commands/update.js +59 -2
  10. package/src/core/agentRuntime/context.js +78 -0
  11. package/src/core/diffPlan.js +100 -102
  12. package/src/core/docContracts.js +3 -0
  13. package/src/core/observability/emit/sessionBoot.js +14 -0
  14. package/src/core/runtimeConfigMerge.js +125 -0
  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/auto-allow-bash.sh +20 -2
  21. package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
  22. package/template_project/.claude/hooks/handoff-resume.sh +25 -1
  23. package/template_project/.claude/hooks/record-execution.mjs +9 -0
  24. package/template_project/.claude/hooks/skill-router.sh +3 -0
  25. package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
  26. package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
  27. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
  28. package/template_project/.claude/ukit/index/route-task.mjs +3 -0
  29. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
  30. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
  31. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
  32. package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
  33. package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
  34. package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
  35. package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
  36. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
  37. package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
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.28 - 2026-10-09
13
+
14
+ **Release-safety close-out: config preservation, safe `ukit update`, OMP eval/Bash hardening (local RC; not yet published).**
15
+ - **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.
16
+ - **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).
17
+ - **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/`.
18
+ - **F1 OMP `eval` payload** (`{language, code}`) is now seen by the Bash guard chain (was an empty command).
19
+ - **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`.
20
+ - **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).
21
+ - 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.
22
+
23
+ ## 3.4.27 - 2026-10-09
24
+
25
+ **Stop-gate evidence loop hardening and linked-worktree isolation (follow-up to 3.4.26).**
26
+ - `execution-ledger`: `yarn`/`npm`/`pnpm release:verify` is recorded as a verification receipt. A verification command piped through `| tail`/`| grep` no longer journals as a pass unless `pipefail`/`PIPESTATUS` proves the real exit status. Verification verdicts are scoped to the checkout they ran in, and path-qualified runners are recognised; Bash work in sibling worktrees is attributed to the right checkout.
27
+ - `stop-coordinator` / `handoff-resume`: the Stop/SessionStart handoff cursor is scoped to the git-verified linked worktree, so a run in one worktree no longer blocks or resumes another.
28
+ - `safe-patch` / `pre-edit-backup`: Edit/Write targets inside git-listed linked worktrees of the same repo are accepted; backups are isolated per worktree even when two worktrees share one relative path.
29
+ - `hook-chain-runner --emit-verdict`: multi-document JSON on stdout from chained hooks is coalesced into one valid document.
30
+ - `auto-allow-bash`: a burst of concurrent hooks no longer silently loses allow rules. Dropped rules are journaled and the bounded lock slice is widened. Trade-off: worst-case lock wait under contention rises from ~1.2 s to ~3 s.
31
+ - Dev tooling: `scripts/dev/worktree-preflight.mjs` bootstraps the gitignored runtime in a fresh worktree via `ukit install`, re-renders drifted gitignored `CLAUDE.md`/`AGENTS.md`, warns on a symlinked `node_modules`, and bounds its git/render children with explicit timeouts. `manifests/documentation.yaml` declares `WORKLOG_ARCHIVE.md` as `local_only`.
32
+ - Test-only: route-audit `tmpDir` cleanup retries so a detached decision-shadow child cannot fail `afterEach` with `ENOTEMPTY`.
33
+ - Known limits: 1 of 165 stress iterations of the concurrent auto-allow burst still lost a rule (cause not yet identified). Existing checkouts must re-run `ukit install` to pick up the new runtime.
34
+
5
35
  ## 3.4.26 - 2026-10-08
6
36
 
7
37
  **P1 fix: completion gate no longer reports `missing write-evidence` for tracked-file changes made through Bash.**
@@ -282,6 +282,8 @@ entries:
282
282
  load_policy: on-demand
283
283
  validation: [manual]
284
284
  archive_policy: never
285
+ local_only: true
286
+ notes: gitignored machine-local rotation target (created by the WORKLOG archive workflow); absent on a clean checkout is valid, validated normally when present
285
287
 
286
288
  # ── docs/ runtime + dir families ─────────────────────────────────────────
287
289
  - id: docs-status
@@ -416,6 +418,28 @@ entries:
416
418
  validation: [none]
417
419
  archive_policy: immutable
418
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
419
443
  - id: docs-skill-audits
420
444
  path: docs/skill-audits/
421
445
  class: archive
@@ -752,6 +776,7 @@ entries:
752
776
  load_policy: on-demand
753
777
  validation: [manual]
754
778
  archive_policy: never
779
+ local_only: true
755
780
  - id: mirror-omp-dir
756
781
  path: .omp/
757
782
  class: derived
@@ -772,6 +797,7 @@ entries:
772
797
  load_policy: on-demand
773
798
  validation: [manual]
774
799
  archive_policy: never
800
+ local_only: true
775
801
 
776
802
  # ── Same-wave additions (DOC-201 batch; files authored by TASK-208/212) ────
777
803
  - id: docs-host-capability-matrix
@@ -819,6 +845,41 @@ entries:
819
845
  archive_policy: never
820
846
  notes: C69 G7 operator doc — timeline render, support bundle contents/exclusions, replay limits
821
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
822
883
  # Declared complexity→docs mapping consumed by the router's `docs=` summary segment
823
884
  # (src/index/taskRouting.js CONTEXT_LAYER_DOCS mirrors this block; DOC-201 FR-001/002).
824
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.26",
3
+ "version": "3.4.28",
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*",
@@ -56,8 +60,10 @@
56
60
  "index:query": "node ./scripts/index/query-index.mjs",
57
61
  "bug:triage": "node ./scripts/bug/triage.mjs",
58
62
  "skill:audit": "node ./scripts/skill/audit-skill.mjs",
63
+ "worktree:preflight": "node ./scripts/dev/worktree-preflight.mjs",
59
64
  "docs:render": "node scripts/docs/render-instructions.mjs --write",
60
65
  "docs:render:check": "node scripts/docs/render-instructions.mjs --check",
66
+ "test:python:setup": "node tests/python/setup-test-venv.mjs",
61
67
  "test:artifact": "vitest run tests/integration/packageArtifact.test.js tests/integration/artifactReferenceIntegrity.test.js",
62
68
  "test:release-core": "vitest run --exclude tests/integration/packageArtifact.test.js",
63
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
+ }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // Bootstrap the gitignored UKit runtime in a fresh `git worktree` so targeted tests can run.
3
+ //
4
+ // A new worktree only has tracked files. `.claude/`, `.ukit/`, `CLAUDE.md`, … are generated by
5
+ // `ukit install` and gitignored, so mirror/runtime tests fail there for environment reasons.
6
+ // This reuses `bin/ukit install` (no new generator), idempotently:
7
+ // - already bootstrapped → no-op
8
+ // - missing prerequisites → run `ukit install` once, re-check
9
+ // - not a linked worktree → refuse (the primary checkout's .claude/ is the maintainer runtime)
10
+ // `ukit install` writes the install flavour of root CLAUDE.md/AGENTS.md, but those files are
11
+ // gitignored *render outputs* of template_project/instructions (docs:render). The preflight
12
+ // re-renders them when they drift — never a tracked render target, which is only reported.
13
+ // It copies nothing from another checkout (no sessions, secrets, handoff state) and does not
14
+ // link node_modules — run `yarn install` in the worktree (a symlinked node_modules is warned:
15
+ // realpath-sensitive hooks resolve it outside the project and misjudge delete targets).
16
+ //
17
+ // Usage: node scripts/dev/worktree-preflight.mjs [--root <dir>] [--check] [--json]
18
+ // Exit: 0 ready · 1 prerequisites missing (--check, or bootstrap failed) · 2 refused
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ // Generated by `ukit install`; each is required by a mirror/runtime test family.
26
+ export const PREREQUISITES = [
27
+ 'node_modules',
28
+ '.claude/settings.json',
29
+ '.claude/hooks',
30
+ '.claude/ukit/runtime/execution-ledger.mjs',
31
+ '.ukit/storage/config.json',
32
+ 'CLAUDE.md',
33
+ 'AGENTS.md',
34
+ ];
35
+
36
+ // Explicit bounds so a wedged git/render child can never hang the preflight. Normal runs take
37
+ // milliseconds; these sit far above that yet below `ukit install`'s own 120s budget.
38
+ const GIT_TIMEOUT_MS = 30_000;
39
+ const RENDER_TIMEOUT_MS = 60_000;
40
+
41
+ function git(root, args) {
42
+ const res = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
43
+ return res.status === 0 ? res.stdout.trim() : null;
44
+ }
45
+
46
+ export function isLinkedWorktree(root) {
47
+ const gitDir = git(root, ['rev-parse', '--path-format=absolute', '--git-dir']);
48
+ const common = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
49
+ return Boolean(gitDir && common && path.resolve(gitDir) !== path.resolve(common));
50
+ }
51
+
52
+ function isTracked(root, rel) {
53
+ const res = spawnSync('git', ['ls-files', '--error-unmatch', '--', rel], { cwd: root, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
54
+ // A timed-out probe proves nothing: treat as tracked so the file is reported, never overwritten.
55
+ return res.status === 0 || Boolean(res.error);
56
+ }
57
+
58
+ const RENDER_CLI = 'scripts/docs/render-instructions.mjs';
59
+
60
+ // → rel paths whose rendered bytes differ from disk (`DRIFT <target>` lines of --check).
61
+ export function renderDrift(root) {
62
+ const res = spawnSync(process.execPath, [path.join(root, RENDER_CLI), '--check'], { cwd: root, encoding: 'utf8', timeout: RENDER_TIMEOUT_MS });
63
+ // A killed check prints nothing, which would read as "no drift" — fail loudly instead.
64
+ if (res.error) throw new Error(`render check did not complete (${res.error.code || res.error.message})`);
65
+ return `${res.stdout || ''}`.split('\n').map((l) => l.match(/^DRIFT (.+)$/)?.[1]).filter(Boolean);
66
+ }
67
+
68
+ export function missingPrerequisites(root) {
69
+ return PREREQUISITES.filter((rel) => !fs.existsSync(path.join(root, rel)));
70
+ }
71
+
72
+ export function preflight({ root, check = false } = {}) {
73
+ const projectRoot = fs.realpathSync(root);
74
+ const result = { root: projectRoot, ok: false, bootstrapped: false, missing: [], rendered: [], warnings: [], home: null, error: null };
75
+
76
+ if (!fs.existsSync(path.join(projectRoot, 'bin/ukit'))) {
77
+ return { ...result, exitCode: 2, error: `not a UKit checkout (no bin/ukit): ${projectRoot}` };
78
+ }
79
+ if (!isLinkedWorktree(projectRoot)) {
80
+ return { ...result, exitCode: 2, error: 'refusing: not a linked worktree (git worktree add …); the primary checkout keeps its own runtime' };
81
+ }
82
+
83
+ result.missing = missingPrerequisites(projectRoot);
84
+ if (result.missing.length > 0) {
85
+ if (check) return { ...result, exitCode: 1 };
86
+ if (result.missing.includes('node_modules')) {
87
+ return { ...result, exitCode: 1, error: 'node_modules missing — run `yarn install` in the worktree first (not linked from another checkout)' };
88
+ }
89
+
90
+ // `ukit install` also writes user-level state (~/.ukit playbooks). Point HOME inside the
91
+ // worktree's ignored .cache/ so a preflight never mutates the real home or leaks from it.
92
+ result.home = path.join(projectRoot, '.cache/worktree-preflight-home');
93
+ fs.mkdirSync(result.home, { recursive: true });
94
+ const install = spawnSync(process.execPath, [path.join(projectRoot, 'bin/ukit'), 'install'], {
95
+ cwd: projectRoot,
96
+ encoding: 'utf8',
97
+ env: { ...process.env, HOME: result.home, USERPROFILE: result.home },
98
+ timeout: 120_000,
99
+ });
100
+ result.bootstrapped = true;
101
+ result.missing = missingPrerequisites(projectRoot);
102
+ if (install.status !== 0 || result.missing.length > 0) {
103
+ const tail = `${install.stdout || ''}${install.stderr || ''}`.trim().split('\n').slice(-5).join('\n');
104
+ return { ...result, exitCode: 1, error: `ukit install did not produce all prerequisites (exit ${install.status})\n${tail}` };
105
+ }
106
+ }
107
+
108
+ if (fs.lstatSync(path.join(projectRoot, 'node_modules')).isSymbolicLink()) {
109
+ result.warnings.push('node_modules is a symlink — realpath-sensitive tests (block-dangerous) fail; run `yarn install` in this worktree for a real copy');
110
+ }
111
+
112
+ const drift = renderDrift(projectRoot);
113
+ if (drift.length > 0) {
114
+ if (check) return { ...result, missing: drift.map((t) => `render:${t}`), exitCode: 1 };
115
+ const tracked = drift.filter((t) => isTracked(projectRoot, t));
116
+ if (tracked.length > 0) {
117
+ return { ...result, exitCode: 1, error: `tracked render target(s) drifted, not overwriting: ${tracked.join(', ')} — commit or revert the source edit` };
118
+ }
119
+ const write = spawnSync(process.execPath, [path.join(projectRoot, RENDER_CLI), '--write'], { cwd: projectRoot, encoding: 'utf8', timeout: RENDER_TIMEOUT_MS });
120
+ const still = renderDrift(projectRoot);
121
+ if (write.status !== 0 || still.length > 0) {
122
+ return { ...result, exitCode: 1, error: `docs:render did not clear drift: ${still.join(', ') || (write.stderr || '').trim()}` };
123
+ }
124
+ result.rendered = drift;
125
+ }
126
+ return { ...result, ok: true, exitCode: 0 };
127
+ }
128
+
129
+ function main(argv) {
130
+ const args = { root: process.cwd(), check: false, json: false };
131
+ for (let i = 0; i < argv.length; i += 1) {
132
+ if (argv[i] === '--root') args.root = path.resolve(argv[++i] || '');
133
+ else if (argv[i] === '--check') args.check = true;
134
+ else if (argv[i] === '--json') args.json = true;
135
+ }
136
+ const { exitCode, ...result } = preflight(args);
137
+ if (args.json) {
138
+ process.stdout.write(`${JSON.stringify(result)}\n`);
139
+ } else if (result.ok) {
140
+ console.log(`[worktree-preflight] ready${result.bootstrapped ? ' (bootstrapped via ukit install)' : ' (already bootstrapped)'}: ${result.root}`);
141
+ } else {
142
+ console.error(`[worktree-preflight] ${result.error || `missing: ${result.missing.join(', ')}`}`);
143
+ }
144
+ process.exit(exitCode);
145
+ }
146
+
147
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
@@ -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
  }