@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.
- package/CHANGELOG.md +30 -0
- package/manifests/documentation.yaml +61 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +7 -1
- package/scripts/audit/subagent-cost/metrics.mjs +14 -2
- package/scripts/audit/subagent-cost/reader.mjs +15 -3
- package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
- package/scripts/dev/worktree-preflight.mjs +147 -0
- package/src/cli/commands/update.js +59 -2
- package/src/core/agentRuntime/context.js +78 -0
- package/src/core/diffPlan.js +100 -102
- package/src/core/docContracts.js +3 -0
- package/src/core/observability/emit/sessionBoot.js +14 -0
- package/src/core/runtimeConfigMerge.js +125 -0
- package/src/core/updateSafety.js +174 -0
- package/src/index/contextPack.js +285 -0
- package/src/index/sensitivePath.js +21 -0
- package/src/index/taskRouting.js +3 -0
- package/template_project/.claude/commands/ukit/ci-check.md +53 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +20 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +25 -1
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +3 -0
- package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
- package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
- package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
- package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- 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.
|
|
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
|
|
44
|
-
|
|
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
|
|
90
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|