@ngockhoale/ukit 3.4.27 → 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 +18 -0
- package/manifests/documentation.yaml +59 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +6 -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/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/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/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +13 -0
- 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/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +19 -7
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +8 -0
- 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/stop-coordinator.mjs +33 -1
- 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,24 @@
|
|
|
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
|
+
|
|
5
23
|
## 3.4.27 - 2026-10-09
|
|
6
24
|
|
|
7
25
|
**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.
|
|
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*",
|
|
@@ -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
|
|
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|