@ngockhoale/ukit 1.6.4 → 1.6.6
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 +28 -0
- package/manifests/platform.full.yaml +59 -0
- package/package.json +1 -1
- package/src/core/applyPlan.js +6 -1
- package/src/core/buildPlan.js +16 -2
- package/src/core/ensureGitignore.js +136 -60
- package/src/core/runInstallPipeline.js +19 -11
- package/src/core/runtimeConfig.js +37 -1
- package/src/core/uninstall.js +10 -5
- package/templates/.claude/agents/ukit-vision-analyst.md +101 -0
- package/templates/.claude/hooks/handoff-model-guard.sh +13 -0
- package/templates/.claude/hooks/vision-gate.sh +230 -0
- package/templates/.claude/hooks/vision-router.sh +155 -0
- package/templates/.claude/settings.json +10 -0
- package/templates/.claude/ukit/index/extract-image.mjs +434 -0
- package/templates/.claude/ukit/index/route-task.mjs +290 -7
- package/templates/.claude/ukit/index/unic-gateway.mjs +271 -0
- package/templates/CLAUDE.md +8 -0
- package/templates/ukit/storage/config.json +33 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 1.6.6 - 2026-08-03
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`ensureGitignore`** no longer silently no-ops on a damaged UKit block (missing/duplicate end marker). Marker detection is now a single source of truth (`parseGitignore` + `locateBlock`, exact-line matching, shared with `removeGitignoreBlock`), fixing data loss on substring-collision lines, content between two legitimate blocks, and CRLF round-tripping, while staying idempotent.
|
|
10
|
+
- **Install summary counts** no longer go negative or misattribute a skip. `applyDiffResults` now tracks skips per planned action (`create`/`update`), and `runInstallPipeline` subtracts each skip from its own bucket instead of always assuming `update`.
|
|
11
|
+
- **Runtime config default version** is now read from `package.json` instead of a hardcoded, drifting literal (was stuck at `1.5.2`).
|
|
12
|
+
- **`mergeObjects` (runtime config)** now refuses `__proto__`, `constructor`, and `prototype` keys at every merge depth, closing a prototype-pollution path via a hand-edited `.ukit/storage/config.json`.
|
|
13
|
+
- **`buildInstallPlan`** no longer aborts the entire install when a template is missing for a *non-selected/excluded* item — it skips quietly. A missing template for a *selected* item still fails loudly with a clear error.
|
|
14
|
+
- **`uninstallUkit`** now reports out-of-scope/forged tracked paths on real runs, not only `--dry-run`, while routine link-child paths stay silent.
|
|
15
|
+
|
|
16
|
+
## 1.6.5 - 2026-08-02
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Enforced `unic-vision` image lane.** Images are now mechanically routed to a vision-capable model instead of being guessed at by `unic-code`/`unic-smart`, which cannot read images on the UNIC gateway.
|
|
21
|
+
- `.claude/ukit/index/extract-image.mjs` — decodes pasted images out of the session transcript to disk (a spawned subagent receives text only and does not inherit image blocks), and writes content-addressed `pending-<sha>.json` markers. Single owner of the sha256; both the marker and the image filename come from the same hash.
|
|
22
|
+
- `.claude/hooks/vision-router.sh` (`UserPromptSubmit`) — detects all three input forms (pasted, local path, image URL) and arms a marker for each. Fails OPEN: a failure here degrades to no hint and no markers, never a wedged prompt.
|
|
23
|
+
- `.claude/hooks/vision-gate.sh` (`PreToolUse`, `Edit|Write` only) — hard-blocks the write (exit 2) until an `analyzed-<sha>.json` receipt exists whose self-reported model is vision-capable. A receipt from `unic-code`, `unic-smart`, `sonnet`, or `opus` is treated as ABSENT and still blocks. Fails CLOSED, and gateway detection that is missing or broken DENIES the fallback-model exception rather than granting it. `Read`/`Grep`/`Glob`/`Bash` are never gated — the vision analyst needs them to clear the gate.
|
|
24
|
+
- `ukit-vision-analyst` agent — pinned to `unic-vision`, the only lane permitted to interpret images. Reports findings only; it has no `Edit`/`Write`.
|
|
25
|
+
- `.claude/ukit/index/unic-gateway.mjs` — detects a `unicjsc.com` gateway across Claude Code env, Codex config, Kilo config, and `OPENAI_BASE_URL`. Only ever tests for substring presence; never reads credential file contents into output.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `orchestration.escalation` now has real consumers. `route-task.mjs` reads `debugLoopThreshold`, `tierOrder`, and `cap` from config instead of hardcoded literals, and emits `escalatedTier` alongside `modelTier` — one rung up, capped at `smart`. The same literals remain as fallbacks, so projects that never opted in behave identically.
|
|
30
|
+
- `orchestration.modelTiers` documents all four UNIC models. `vision` is a capability lane orthogonal to the lite/code/smart cost tiers, never a fourth tier, and is stripped from any configured `tierOrder`.
|
|
31
|
+
- `handoff-model-guard.sh`: `tierOf()` hardened against vision/unknown model names.
|
|
32
|
+
|
|
5
33
|
## 1.6.4 - 2026-07-27
|
|
6
34
|
|
|
7
35
|
### Added
|
|
@@ -819,6 +819,17 @@ items:
|
|
|
819
819
|
packs:
|
|
820
820
|
- core
|
|
821
821
|
|
|
822
|
+
- id: agent-ukit-vision-analyst
|
|
823
|
+
type: agent
|
|
824
|
+
sourceTemplate: .claude/agents/ukit-vision-analyst.md
|
|
825
|
+
targetPath: .claude/agents/ukit-vision-analyst.md
|
|
826
|
+
requires: []
|
|
827
|
+
mergeStrategy: overwrite_with_backup
|
|
828
|
+
variables: []
|
|
829
|
+
enabledByDefault: true
|
|
830
|
+
packs:
|
|
831
|
+
- core
|
|
832
|
+
|
|
822
833
|
- id: command-handoff-create
|
|
823
834
|
type: command
|
|
824
835
|
sourceTemplate: .claude/commands/ukit/handoff-create.md
|
|
@@ -1037,6 +1048,32 @@ items:
|
|
|
1037
1048
|
packs:
|
|
1038
1049
|
- core
|
|
1039
1050
|
|
|
1051
|
+
- id: hook-vision-router
|
|
1052
|
+
type: hook
|
|
1053
|
+
sourceTemplate: .claude/hooks/vision-router.sh
|
|
1054
|
+
targetPath: .claude/hooks/vision-router.sh
|
|
1055
|
+
requires:
|
|
1056
|
+
- ukit-index-extract-image-script
|
|
1057
|
+
- ukit-index-unic-gateway-script
|
|
1058
|
+
mergeStrategy: overwrite_with_backup
|
|
1059
|
+
variables: []
|
|
1060
|
+
enabledByDefault: true
|
|
1061
|
+
packs:
|
|
1062
|
+
- core
|
|
1063
|
+
|
|
1064
|
+
- id: hook-vision-gate
|
|
1065
|
+
type: hook
|
|
1066
|
+
sourceTemplate: .claude/hooks/vision-gate.sh
|
|
1067
|
+
targetPath: .claude/hooks/vision-gate.sh
|
|
1068
|
+
requires:
|
|
1069
|
+
- ukit-index-extract-image-script
|
|
1070
|
+
- ukit-index-unic-gateway-script
|
|
1071
|
+
mergeStrategy: overwrite_with_backup
|
|
1072
|
+
variables: []
|
|
1073
|
+
enabledByDefault: true
|
|
1074
|
+
packs:
|
|
1075
|
+
- core
|
|
1076
|
+
|
|
1040
1077
|
- id: hook-compress-output
|
|
1041
1078
|
type: hook
|
|
1042
1079
|
sourceTemplate: .claude/hooks/compress-output.sh
|
|
@@ -1266,6 +1303,26 @@ items:
|
|
|
1266
1303
|
packs:
|
|
1267
1304
|
- core
|
|
1268
1305
|
|
|
1306
|
+
- id: ukit-index-unic-gateway-script
|
|
1307
|
+
type: config
|
|
1308
|
+
sourceTemplate: .claude/ukit/index/unic-gateway.mjs
|
|
1309
|
+
targetPath: .claude/ukit/index/unic-gateway.mjs
|
|
1310
|
+
mergeStrategy: overwrite_with_backup
|
|
1311
|
+
variables: []
|
|
1312
|
+
enabledByDefault: true
|
|
1313
|
+
packs:
|
|
1314
|
+
- core
|
|
1315
|
+
|
|
1316
|
+
- id: ukit-index-extract-image-script
|
|
1317
|
+
type: config
|
|
1318
|
+
sourceTemplate: .claude/ukit/index/extract-image.mjs
|
|
1319
|
+
targetPath: .claude/ukit/index/extract-image.mjs
|
|
1320
|
+
mergeStrategy: overwrite_with_backup
|
|
1321
|
+
variables: []
|
|
1322
|
+
enabledByDefault: true
|
|
1323
|
+
packs:
|
|
1324
|
+
- core
|
|
1325
|
+
|
|
1269
1326
|
- id: ukit-index-route-task-script
|
|
1270
1327
|
type: config
|
|
1271
1328
|
sourceTemplate: .claude/ukit/index/route-task.mjs
|
|
@@ -1274,6 +1331,8 @@ items:
|
|
|
1274
1331
|
- ukit-index-core-lib
|
|
1275
1332
|
- ukit-index-cache-utils-script
|
|
1276
1333
|
- ukit-index-route-catalog-script
|
|
1334
|
+
- ukit-index-unic-gateway-script
|
|
1335
|
+
- ukit-index-extract-image-script
|
|
1277
1336
|
mergeStrategy: overwrite_with_backup
|
|
1278
1337
|
variables: []
|
|
1279
1338
|
enabledByDefault: true
|
package/package.json
CHANGED
package/src/core/applyPlan.js
CHANGED
|
@@ -15,6 +15,7 @@ function isTccProtectedPath(filePath) {
|
|
|
15
15
|
export async function applyDiffResults(diffResults, { backupRoot, projectRoot } = {}) {
|
|
16
16
|
const writes = [];
|
|
17
17
|
let skippedUpdates = 0;
|
|
18
|
+
const skippedByAction = { create: 0, update: 0 };
|
|
18
19
|
|
|
19
20
|
for (const entry of diffResults) {
|
|
20
21
|
if (entry.action !== 'create' && entry.action !== 'update') {
|
|
@@ -36,6 +37,7 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
|
|
|
36
37
|
`[UKit] Warning: skipping link update for ${entry.targetPath} — exists as a ${existingKind} (possible user content). Remove it manually if intended.`,
|
|
37
38
|
);
|
|
38
39
|
skippedUpdates += 1;
|
|
40
|
+
skippedByAction[entry.action] += 1;
|
|
39
41
|
continue;
|
|
40
42
|
}
|
|
41
43
|
}
|
|
@@ -47,6 +49,7 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
|
|
|
47
49
|
`[UKit] Warning: skipping link creation for ${entry.targetPath} — permission denied. Run 'ukit install' from within the target tool to create it.`,
|
|
48
50
|
);
|
|
49
51
|
skippedUpdates += 1;
|
|
52
|
+
skippedByAction[entry.action] += 1;
|
|
50
53
|
continue;
|
|
51
54
|
}
|
|
52
55
|
throw linkError;
|
|
@@ -78,6 +81,7 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
|
|
|
78
81
|
`[UKit] Warning: skipping backup for ${entry.targetPath} — permission denied.`,
|
|
79
82
|
);
|
|
80
83
|
skippedUpdates += 1;
|
|
84
|
+
skippedByAction.update += 1;
|
|
81
85
|
continue;
|
|
82
86
|
}
|
|
83
87
|
throw backupError;
|
|
@@ -111,6 +115,7 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
|
|
|
111
115
|
`[UKit] Warning: skipping write for ${entry.targetPath} — permission denied (macOS TCC). Run 'ukit install' from within the owning tool to update it.`,
|
|
112
116
|
);
|
|
113
117
|
skippedUpdates += 1;
|
|
118
|
+
skippedByAction[entry.action] += 1;
|
|
114
119
|
continue;
|
|
115
120
|
}
|
|
116
121
|
throw writeError;
|
|
@@ -126,5 +131,5 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
|
|
|
126
131
|
});
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
return { writes, skippedUpdates };
|
|
134
|
+
return { writes, skippedUpdates, skippedByAction };
|
|
130
135
|
}
|
package/src/core/buildPlan.js
CHANGED
|
@@ -101,7 +101,17 @@ export async function buildInstallPlan({
|
|
|
101
101
|
for (const item of manifest.items) {
|
|
102
102
|
if (!selectedIds.has(item.id) && item.type !== 'link') {
|
|
103
103
|
const sourcePath = safeResolve(templatesRoot, item.sourceTemplate);
|
|
104
|
-
|
|
104
|
+
let stat;
|
|
105
|
+
try {
|
|
106
|
+
stat = await fs.stat(sourcePath);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code === 'ENOENT') {
|
|
109
|
+
// An excluded item only contributes an auto-discovery filter, so a
|
|
110
|
+
// missing source for a pack the user does not have is harmless.
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
105
115
|
const sourceDir = (stat.isDirectory() ? sourcePath : path.dirname(sourcePath)) + path.sep;
|
|
106
116
|
excludedSourceDirs.push(sourceDir);
|
|
107
117
|
}
|
|
@@ -145,7 +155,11 @@ export async function buildInstallPlan({
|
|
|
145
155
|
});
|
|
146
156
|
continue;
|
|
147
157
|
}
|
|
148
|
-
throw
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Cannot install manifest item "${item.id}": missing or unreadable template `
|
|
160
|
+
+ `"${item.sourceTemplate}".`,
|
|
161
|
+
{ cause: error },
|
|
162
|
+
);
|
|
149
163
|
}
|
|
150
164
|
if (stat.isDirectory()) {
|
|
151
165
|
const nestedFiles = await walkDirectory(sourcePath);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { writeFileAtomic } from './fileOps.js';
|
|
4
4
|
|
|
5
5
|
const UKIT_ENTRIES = [
|
|
6
6
|
'.cache/',
|
|
@@ -20,6 +20,76 @@ const UKIT_ENTRIES = [
|
|
|
20
20
|
const MARKER_START = '# UKit generated (do not edit)';
|
|
21
21
|
const MARKER_END = '# /UKit';
|
|
22
22
|
|
|
23
|
+
/*
|
|
24
|
+
* Single source of truth for "where is the UKit block in this file".
|
|
25
|
+
*
|
|
26
|
+
* Both `ensureGitignore` and `removeGitignoreBlock` go through `parseGitignore`
|
|
27
|
+
* + `locateBlock`. There is deliberately NO regex/substring notion of a marker
|
|
28
|
+
* anywhere else in this module — historically the fast path used a substring
|
|
29
|
+
* regex, the repair path used `line === MARKER`, and the entry guard used
|
|
30
|
+
* `content.includes(...)`; those three disagreed and silently deleted user
|
|
31
|
+
* lines that merely *contained* marker-like text.
|
|
32
|
+
*
|
|
33
|
+
* Documented behaviour:
|
|
34
|
+
* - A marker is ONLY a line that, after stripping a trailing `\r`, is exactly
|
|
35
|
+
* equal to MARKER_START / MARKER_END. `# UKit generated (do not edit) v2`
|
|
36
|
+
* or a marker with a trailing space is ordinary user content, everywhere.
|
|
37
|
+
* - Line endings: the file's *dominant* ending wins (CRLF only if CRLF pairs
|
|
38
|
+
* outnumber bare LFs; ties go to LF). A file is only ever rewritten when its
|
|
39
|
+
* content actually changes, and a rewrite normalises the whole file to that
|
|
40
|
+
* dominant ending — so a mixed-ending file converges after one normalising
|
|
41
|
+
* write.
|
|
42
|
+
* - Only the FIRST block is managed. Any further marker lines are treated as
|
|
43
|
+
* user content so a second, legitimate block — and every user line between
|
|
44
|
+
* the two — is never touched.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Detect the dominant line ending and split into `\r`-free lines.
|
|
49
|
+
* @param {string} content
|
|
50
|
+
* @returns {{ eol: string, lines: string[] }}
|
|
51
|
+
*/
|
|
52
|
+
function parseGitignore(content) {
|
|
53
|
+
const crlfCount = (content.match(/\r\n/g) ?? []).length;
|
|
54
|
+
const lfCount = (content.match(/\n/g) ?? []).length;
|
|
55
|
+
const bareLfCount = lfCount - crlfCount;
|
|
56
|
+
const eol = crlfCount > bareLfCount ? '\r\n' : '\n';
|
|
57
|
+
const lines = content
|
|
58
|
+
.split('\n')
|
|
59
|
+
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
|
|
60
|
+
return { eol, lines };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Locate the first UKit block at or after `from`, using exact-line equality only.
|
|
65
|
+
*
|
|
66
|
+
* The block spans `[startIdx, endIdx]` inclusive. The search for the closing
|
|
67
|
+
* marker is bounded by the NEXT start marker, so an excision can never run past
|
|
68
|
+
* a second block. If no end marker exists inside that bound the block is
|
|
69
|
+
* "unterminated" and ends just before the next start marker (or at EOF).
|
|
70
|
+
*
|
|
71
|
+
* @param {string[]} lines `\r`-free lines from {@link parseGitignore}
|
|
72
|
+
* @param {number} [from]
|
|
73
|
+
* @returns {{ startIdx: number, endIdx: number, terminated: boolean } | null}
|
|
74
|
+
*/
|
|
75
|
+
function locateBlock(lines, from = 0) {
|
|
76
|
+
const startIdx = lines.indexOf(MARKER_START, from);
|
|
77
|
+
if (startIdx === -1) return null;
|
|
78
|
+
|
|
79
|
+
const next = lines.indexOf(MARKER_START, startIdx + 1);
|
|
80
|
+
const bound = next === -1 ? lines.length : next;
|
|
81
|
+
|
|
82
|
+
// Last end marker *within the bound* — consumes stray/duplicate end markers
|
|
83
|
+
// that belong to this damaged block without reaching into the next one.
|
|
84
|
+
let endIdx = -1;
|
|
85
|
+
for (let i = startIdx + 1; i < bound; i += 1) {
|
|
86
|
+
if (lines[i] === MARKER_END) endIdx = i;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const terminated = endIdx !== -1;
|
|
90
|
+
return { startIdx, endIdx: terminated ? endIdx : bound - 1, terminated };
|
|
91
|
+
}
|
|
92
|
+
|
|
23
93
|
export async function ensureGitignore(projectRoot) {
|
|
24
94
|
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
25
95
|
|
|
@@ -30,48 +100,60 @@ export async function ensureGitignore(projectRoot) {
|
|
|
30
100
|
// no .gitignore yet
|
|
31
101
|
}
|
|
32
102
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
103
|
+
const { eol, lines } = parseGitignore(content);
|
|
104
|
+
const blockLines = [MARKER_START, ...UKIT_ENTRIES, MARKER_END];
|
|
105
|
+
const block = locateBlock(lines);
|
|
106
|
+
|
|
107
|
+
let nextLines;
|
|
108
|
+
if (block) {
|
|
109
|
+
// Splice the canonical block over the located one, in place. Well-formed,
|
|
110
|
+
// unterminated and stray-end-marker blocks all take this one path — a
|
|
111
|
+
// well-formed, current block simply splices to identical content and is
|
|
112
|
+
// caught by the `next === content` no-op check below, which is what makes
|
|
113
|
+
// repeated calls converge.
|
|
114
|
+
nextLines = [
|
|
115
|
+
...lines.slice(0, block.startIdx),
|
|
116
|
+
...blockLines,
|
|
117
|
+
...lines.slice(block.endIdx + 1),
|
|
118
|
+
];
|
|
119
|
+
} else {
|
|
120
|
+
// No marker block — check if entries are already present as active
|
|
121
|
+
// (non-comment) lines. Must filter out comment lines to avoid
|
|
122
|
+
// false-negatives like "# placeholder .claude/ukit/.ukit/" matching the
|
|
123
|
+
// entry check and incorrectly skipping the block insertion.
|
|
124
|
+
const activeLines = lines
|
|
125
|
+
.map((l) => l.trim())
|
|
126
|
+
.filter((trimmed) => trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!'));
|
|
127
|
+
const missing = UKIT_ENTRIES.filter((e) => !activeLines.some((l) => l === e));
|
|
128
|
+
if (missing.length === 0) {
|
|
129
|
+
return false;
|
|
47
130
|
}
|
|
48
|
-
const updated = content.replace(blockPattern, newBlock);
|
|
49
|
-
await writeFileAtomic(gitignorePath, updated);
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
131
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
.filter((trimmed) => trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!'));
|
|
60
|
-
const missing = UKIT_ENTRIES.filter((e) => !activeLines.some((l) => l === e));
|
|
61
|
-
if (missing.length === 0) {
|
|
62
|
-
return false;
|
|
132
|
+
// Append the marker block after the existing content.
|
|
133
|
+
const head = [...lines];
|
|
134
|
+
while (head.length > 0 && head[head.length - 1].trim() === '') head.pop();
|
|
135
|
+
nextLines = head.length > 0
|
|
136
|
+
? [...head, '', ...blockLines, '']
|
|
137
|
+
: [...blockLines, ''];
|
|
63
138
|
}
|
|
64
139
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
140
|
+
const next = nextLines.join(eol);
|
|
141
|
+
if (next === content) {
|
|
142
|
+
return false; // already up to date
|
|
143
|
+
}
|
|
144
|
+
await writeFileAtomic(gitignorePath, next);
|
|
68
145
|
return true;
|
|
69
146
|
}
|
|
70
147
|
|
|
71
148
|
/**
|
|
72
|
-
* Remove the UKit-managed block from .gitignore.
|
|
149
|
+
* Remove the UKit-managed block(s) from .gitignore.
|
|
73
150
|
* Called by uninstall to leave the project in a clean state.
|
|
74
|
-
*
|
|
151
|
+
*
|
|
152
|
+
* Uses the same detection helpers as `ensureGitignore`, so anything
|
|
153
|
+
* `ensureGitignore` can write (including a CRLF-joined block) is removable, and
|
|
154
|
+
* a line that merely contains marker-like text is never matched.
|
|
155
|
+
*
|
|
156
|
+
* @returns {boolean} true if a block was found and removed
|
|
75
157
|
*/
|
|
76
158
|
export async function removeGitignoreBlock(projectRoot) {
|
|
77
159
|
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
@@ -83,35 +165,29 @@ export async function removeGitignoreBlock(projectRoot) {
|
|
|
83
165
|
return false; // no .gitignore — nothing to remove
|
|
84
166
|
}
|
|
85
167
|
|
|
86
|
-
|
|
87
|
-
|
|
168
|
+
const { eol, lines } = parseGitignore(content);
|
|
169
|
+
|
|
170
|
+
let result = lines;
|
|
171
|
+
let cursor = 0;
|
|
172
|
+
let removed = false;
|
|
173
|
+
for (;;) {
|
|
174
|
+
const block = locateBlock(result, cursor);
|
|
175
|
+
if (!block) break;
|
|
176
|
+
removed = true;
|
|
177
|
+
|
|
178
|
+
// Drop the blank separator line that ensureGitignore prepended.
|
|
179
|
+
let from = block.startIdx;
|
|
180
|
+
if (from > 0 && result[from - 1] === '') from -= 1;
|
|
181
|
+
|
|
182
|
+
result = [...result.slice(0, from), ...result.slice(block.endIdx + 1)];
|
|
183
|
+
cursor = from;
|
|
88
184
|
}
|
|
89
185
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const lines = content.split('\n');
|
|
93
|
-
const result = [];
|
|
94
|
-
let inBlock = false;
|
|
95
|
-
|
|
96
|
-
for (const line of lines) {
|
|
97
|
-
if (line === MARKER_START) {
|
|
98
|
-
inBlock = true;
|
|
99
|
-
// Remove the blank separator line that ensureGitignore prepended.
|
|
100
|
-
if (result.length > 0 && result[result.length - 1] === '') {
|
|
101
|
-
result.pop();
|
|
102
|
-
}
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (inBlock) {
|
|
106
|
-
if (line === MARKER_END) {
|
|
107
|
-
inBlock = false;
|
|
108
|
-
}
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
result.push(line);
|
|
186
|
+
if (!removed) {
|
|
187
|
+
return false; // no UKit block present
|
|
112
188
|
}
|
|
113
189
|
|
|
114
|
-
const joined = result.join(
|
|
115
|
-
await writeFileAtomic(gitignorePath, joined ? joined +
|
|
190
|
+
const joined = result.join(eol).trimEnd();
|
|
191
|
+
await writeFileAtomic(gitignorePath, joined ? joined + eol : '');
|
|
116
192
|
return true;
|
|
117
193
|
}
|
|
@@ -53,6 +53,19 @@ function buildTrackedManagedRelativePathSet(plan, projectRoot) {
|
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export function adjustSummaryForSkips(plannedSummary, skippedByAction, skippedUpdates) {
|
|
57
|
+
if (!skippedUpdates) {
|
|
58
|
+
return plannedSummary;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...plannedSummary,
|
|
63
|
+
create: plannedSummary.create - (skippedByAction?.create ?? 0),
|
|
64
|
+
update: plannedSummary.update - (skippedByAction?.update ?? 0),
|
|
65
|
+
skip: plannedSummary.skip + skippedUpdates,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
function shouldAutoPruneObsoletePath(relativePath) {
|
|
57
70
|
return AUTO_PRUNE_OBSOLETE_PATHS.has(relativePath)
|
|
58
71
|
|| AUTO_PRUNE_OBSOLETE_PREFIXES.some((prefix) => relativePath.startsWith(prefix));
|
|
@@ -271,7 +284,7 @@ export async function runInstallPipeline({
|
|
|
271
284
|
const plannedSummary = summarizeDiff(diffResults);
|
|
272
285
|
|
|
273
286
|
if (!dryRun) {
|
|
274
|
-
const { writes, skippedUpdates } = await applyDiffResults(diffResults, {
|
|
287
|
+
const { writes, skippedUpdates, skippedByAction } = await applyDiffResults(diffResults, {
|
|
275
288
|
backupRoot: pathConfig.backupRoot,
|
|
276
289
|
projectRoot: pathConfig.projectRoot,
|
|
277
290
|
});
|
|
@@ -291,16 +304,11 @@ export async function runInstallPipeline({
|
|
|
291
304
|
managedLinkRelativePaths,
|
|
292
305
|
});
|
|
293
306
|
|
|
294
|
-
// Adjust summary to reflect
|
|
295
|
-
// (the planned diff counted them as 'update', but apply skipped them).
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
...plannedSummary,
|
|
300
|
-
update: plannedSummary.update - skippedUpdates,
|
|
301
|
-
skip: plannedSummary.skip + skippedUpdates,
|
|
302
|
-
}
|
|
303
|
-
: plannedSummary;
|
|
307
|
+
// Adjust summary to reflect entries that were skipped for safety
|
|
308
|
+
// (the planned diff counted them as 'create'/'update', but apply skipped them).
|
|
309
|
+
// Subtract from the bucket the skipped entry was actually planned as, so the
|
|
310
|
+
// adjusted counts never go negative.
|
|
311
|
+
const summary = adjustSummaryForSkips(plannedSummary, skippedByAction, skippedUpdates);
|
|
304
312
|
|
|
305
313
|
await writeInstallMetadata({
|
|
306
314
|
installMetaPath: pathConfig.installMetaPath,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { buildRuntimePaths } from './runtimePaths.js';
|
|
4
5
|
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { version: PACKAGE_VERSION } = require('../../package.json');
|
|
8
|
+
|
|
5
9
|
const VALID_AGENTS = new Set(['claude-code', 'codex', 'antigravity', 'opencode']);
|
|
6
10
|
|
|
11
|
+
const BLOCKED_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
12
|
+
|
|
7
13
|
function isPlainObject(value) {
|
|
8
14
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
15
|
}
|
|
@@ -15,6 +21,10 @@ function mergeObjects(base, override) {
|
|
|
15
21
|
|
|
16
22
|
const merged = { ...base };
|
|
17
23
|
for (const [key, value] of Object.entries(override)) {
|
|
24
|
+
if (BLOCKED_MERGE_KEYS.has(key)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
const current = merged[key];
|
|
19
29
|
if (isPlainObject(current) && isPlainObject(value)) {
|
|
20
30
|
merged[key] = mergeObjects(current, value);
|
|
@@ -49,7 +59,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
49
59
|
const safeOverrides = isPlainObject(overrides) ? overrides : {};
|
|
50
60
|
|
|
51
61
|
return mergeObjects({
|
|
52
|
-
version:
|
|
62
|
+
version: PACKAGE_VERSION,
|
|
53
63
|
agent: 'claude-code',
|
|
54
64
|
autonomy: {
|
|
55
65
|
level: 'balanced',
|
|
@@ -164,6 +174,24 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
164
174
|
delegationPolicy: 'allow-review-sidecar',
|
|
165
175
|
},
|
|
166
176
|
},
|
|
177
|
+
modelTiers: {
|
|
178
|
+
lite: { claudeModel: 'claude-haiku-4-5', genericModel: 'unic-lite' },
|
|
179
|
+
code: { claudeModel: 'claude-sonnet-4-6', genericModel: 'unic-code' },
|
|
180
|
+
smart: { claudeModel: 'claude-opus-4-6', genericModel: 'unic-smart' },
|
|
181
|
+
vision: {
|
|
182
|
+
claudeModel: 'unic-vision',
|
|
183
|
+
genericModel: 'unic-vision',
|
|
184
|
+
fallbackModel: 'claude-sonnet-4-6',
|
|
185
|
+
capabilityTier: true,
|
|
186
|
+
note: 'Capability tier, not a cost tier. Orthogonal to lite/code/smart — never insert into escalation.tierOrder. fallbackModel is used when unicMode is off.',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
escalation: {
|
|
190
|
+
enabled: true,
|
|
191
|
+
debugLoopThreshold: 2,
|
|
192
|
+
tierOrder: ['lite', 'code', 'smart'],
|
|
193
|
+
cap: 'smart',
|
|
194
|
+
},
|
|
167
195
|
},
|
|
168
196
|
memory: {
|
|
169
197
|
enabled: true,
|
|
@@ -196,6 +224,14 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
196
224
|
enabled: true,
|
|
197
225
|
smallTaskModel: 'unic-lite',
|
|
198
226
|
smallTaskAgent: 'ukit-small-task-maintainer',
|
|
227
|
+
visionEnabled: true,
|
|
228
|
+
visionModel: 'unic-vision',
|
|
229
|
+
visionAgent: 'ukit-vision-analyst',
|
|
230
|
+
visionCacheDir: '.ukit/storage/cache/vision',
|
|
231
|
+
visionMaxImages: 3,
|
|
232
|
+
visionMaxBytes: 10_485_760,
|
|
233
|
+
visionRetain: 20,
|
|
234
|
+
visionSessionTtlHours: 24,
|
|
199
235
|
smallTaskUseCases: [
|
|
200
236
|
'task-cleanup',
|
|
201
237
|
'compact-decision',
|
package/src/core/uninstall.js
CHANGED
|
@@ -124,7 +124,12 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
124
124
|
regularPaths = [stateDir]; // always delete state dir — it's always UKit-owned
|
|
125
125
|
linkPaths = [];
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
// Two arrays for two unrelated meanings: invalidPaths are suspicious (forged
|
|
128
|
+
// or corrupt metadata pointing outside managed prefixes) and always worth a
|
|
129
|
+
// warning; linkChildPaths are routine (a tracked file under a tracked link
|
|
130
|
+
// that gets removed along with its parent) and must never warn.
|
|
131
|
+
const invalidPaths = [];
|
|
132
|
+
const linkChildPaths = [];
|
|
128
133
|
const trackedEntries = [];
|
|
129
134
|
|
|
130
135
|
for (const entry of installData.files) {
|
|
@@ -137,7 +142,7 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
137
142
|
|
|
138
143
|
const normalizedRelative = normalizeToProjectRelative(projectRoot, absolutePath);
|
|
139
144
|
if (!isAllowedProjectPath(normalizedRelative)) {
|
|
140
|
-
|
|
145
|
+
invalidPaths.push(relPath ?? normalizedRelative ?? absolutePath);
|
|
141
146
|
continue;
|
|
142
147
|
}
|
|
143
148
|
|
|
@@ -159,15 +164,15 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
159
164
|
);
|
|
160
165
|
|
|
161
166
|
if (isUnderTrackedLinkPath) {
|
|
162
|
-
|
|
167
|
+
linkChildPaths.push(entry.normalizedRelative);
|
|
163
168
|
continue;
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
regularPaths.push(entry.absolutePath);
|
|
167
172
|
}
|
|
168
173
|
|
|
169
|
-
if (
|
|
170
|
-
console.warn('[UKit] Skipping invalid uninstall paths from install metadata:',
|
|
174
|
+
if (invalidPaths.length > 0) {
|
|
175
|
+
console.warn('[UKit] Skipping invalid uninstall paths from install metadata:', invalidPaths);
|
|
171
176
|
}
|
|
172
177
|
} else {
|
|
173
178
|
// Old format (no files list): fall back to hardcoded managed paths.
|