@ngockhoale/ukit 1.2.2 → 1.3.0

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.
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_SAFE_PATCH_CONFIG = {
5
+ enabled: true,
6
+ strictSharedRisk: true,
7
+ largeFileLineThreshold: 800,
8
+ largeFileByteThreshold: 200_000,
9
+ backupEnabled: true,
10
+ backupRetentionDays: 30,
11
+ deltaMaxChangedLines: 120,
12
+ deltaMaxHunks: 3,
13
+ deltaMaxDiffCells: 2_000_000,
14
+ };
15
+
16
+ const SHARED_RISK_PATTERNS = [
17
+ /^\.claude\/hooks\//,
18
+ /^\.claude\/ukit\//,
19
+ /^\.codex\//,
20
+ /^templates\/\.claude\/hooks\//,
21
+ /^templates\/\.claude\/ukit\//,
22
+ /^src\/index\//,
23
+ /^src\/core\/(runInstallPipeline|applyPlan|buildPlan|diffPlan|metadata|migrateLegacy|uninstall|runtimeConfig|runtimePaths)\.js$/,
24
+ /^src\/core\/(output|token|compact)\//,
25
+ /^manifests\/platform\.full\.yaml$/,
26
+ // Intentional for UKit development: template edits become installed runtime behavior after `ukit install`,
27
+ // so templates stay in the strict shared-risk lane rather than being treated as ordinary docs/assets.
28
+ /^templates\//,
29
+ /^scripts\//,
30
+ /(^|\/)(AGENTS|CLAUDE)\.md$/,
31
+ ];
32
+
33
+ export function parseJsonInput(raw, fallback = {}) {
34
+ try {
35
+ return JSON.parse(String(raw || '').trim() || '{}');
36
+ } catch {
37
+ return fallback;
38
+ }
39
+ }
40
+
41
+ export async function readRuntimeSafePatchConfig(projectRoot) {
42
+ const configPath = path.join(projectRoot, '.ukit', 'storage', 'config.json');
43
+ try {
44
+ const raw = await fs.readFile(configPath, 'utf8');
45
+ const config = JSON.parse(raw);
46
+ return { ...DEFAULT_SAFE_PATCH_CONFIG, ...(config.safePatch && typeof config.safePatch === 'object' ? config.safePatch : {}) };
47
+ } catch {
48
+ return { ...DEFAULT_SAFE_PATCH_CONFIG };
49
+ }
50
+ }
51
+
52
+ export function normalizeRelativePath(value) {
53
+ return String(value || '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
54
+ }
55
+
56
+ export function resolveProjectFile(projectRoot, filePath) {
57
+ const root = path.resolve(projectRoot || process.cwd());
58
+ const raw = String(filePath || '').trim();
59
+ if (!raw) return null;
60
+ const absolute = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw);
61
+ const relative = path.relative(root, absolute);
62
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
63
+ throw new Error(`Path escapes project root: ${filePath}`);
64
+ }
65
+ return {
66
+ absolute,
67
+ relative: normalizeRelativePath(relative),
68
+ };
69
+ }
70
+
71
+ export async function pathExists(filePath) {
72
+ try {
73
+ await fs.access(filePath);
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ export function classifySafePatchRisk(relativePath, profile = null, config = DEFAULT_SAFE_PATCH_CONFIG) {
81
+ const normalized = normalizeRelativePath(relativePath);
82
+ const labels = [];
83
+ if (SHARED_RISK_PATTERNS.some((pattern) => pattern.test(normalized))) {
84
+ labels.push('shared-risk');
85
+ }
86
+ if (profile?.size > Number(config.largeFileByteThreshold || DEFAULT_SAFE_PATCH_CONFIG.largeFileByteThreshold)) {
87
+ labels.push('large-file');
88
+ }
89
+ if (profile?.text) {
90
+ const lines = profile.text.split(/\r\n|\n|\r/).length;
91
+ if (lines > Number(config.largeFileLineThreshold || DEFAULT_SAFE_PATCH_CONFIG.largeFileLineThreshold)) {
92
+ labels.push('large-file');
93
+ }
94
+ }
95
+ if (profile?.hasBom) labels.push('bom-sensitive');
96
+ if (profile?.newline === 'CRLF' || profile?.newline === 'CR' || profile?.newline === 'mixed') labels.push('newline-sensitive');
97
+ if (profile?.text && /[^\x00-\x7f]/.test(profile.text)) labels.push('multilingual-text');
98
+ if (profile?.shebangHasBom) labels.push('shebang-bom');
99
+ if (profile?.binaryLike || profile?.utf8Valid === false) labels.push('non-text');
100
+
101
+ return {
102
+ labels: [...new Set(labels)],
103
+ strict: labels.includes('shared-risk') || labels.includes('large-file') || labels.includes('shebang-bom'),
104
+ };
105
+ }
106
+
107
+ export function countOccurrences(text, needle) {
108
+ const haystack = String(text ?? '');
109
+ const query = String(needle ?? '');
110
+ if (!query) return { count: 0, indexes: [] };
111
+ const indexes = [];
112
+ let start = 0;
113
+ while (start <= haystack.length) {
114
+ const index = haystack.indexOf(query, start);
115
+ if (index === -1) break;
116
+ indexes.push(index);
117
+ start = index + Math.max(query.length, 1);
118
+ }
119
+ return { count: indexes.length, indexes };
120
+ }
121
+
122
+ export function lineNumberForIndex(text, index) {
123
+ return String(text ?? '').slice(0, Math.max(0, index)).split(/\n/).length;
124
+ }
125
+
126
+ export function buildLineWindow(text, line, contextLines = 3) {
127
+ const lines = String(text ?? '').split(/\r\n|\n|\r/);
128
+ const startLine = Math.max(1, Number(line || 1) - contextLines);
129
+ const endLine = Math.min(lines.length, Number(line || 1) + contextLines);
130
+ return {
131
+ startLine,
132
+ endLine,
133
+ text: lines.slice(startLine - 1, endLine).join('\n'),
134
+ };
135
+ }
136
+
137
+ export function summarizeSnippet(value, max = 240) {
138
+ const compact = String(value || '').replace(/\s+/g, ' ').trim();
139
+ return compact.length > max ? `${compact.slice(0, max - 3)}...` : compact;
140
+ }
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs/promises';
2
+ import crypto from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+
6
+ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
7
+
8
+ export function analyzeTextBuffer(buffer) {
9
+ const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer ?? '');
10
+ const hasBom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
11
+ const body = hasBom ? bytes.subarray(3) : bytes;
12
+ const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
13
+ const binaryLike = looksBinaryLike(body);
14
+
15
+ let text = '';
16
+ let utf8Valid = false;
17
+ let decodeError = null;
18
+ if (!binaryLike) {
19
+ try {
20
+ text = new TextDecoder('utf-8', { fatal: true }).decode(body);
21
+ utf8Valid = true;
22
+ } catch (error) {
23
+ decodeError = error?.message || String(error);
24
+ }
25
+ }
26
+
27
+ const newline = utf8Valid ? detectNewlineStyle(text) : 'unknown';
28
+ const hasFinalNewline = utf8Valid ? /(?:\r\n|\n|\r)$/.test(text) : false;
29
+ const shebangHasBom = hasBom && body.length >= 2 && body[0] === 0x23 && body[1] === 0x21;
30
+
31
+ return {
32
+ hasBom,
33
+ newline,
34
+ hasFinalNewline,
35
+ binaryLike,
36
+ utf8Valid,
37
+ sha256,
38
+ size: bytes.length,
39
+ text,
40
+ bodySize: body.length,
41
+ shebangHasBom,
42
+ ...(decodeError ? { decodeError } : {}),
43
+ };
44
+ }
45
+
46
+ export async function analyzeTextFile(filePath) {
47
+ const buffer = await fs.readFile(filePath);
48
+ return analyzeTextBuffer(buffer);
49
+ }
50
+
51
+ export function publicTextProfile(profile) {
52
+ return {
53
+ hasBom: Boolean(profile?.hasBom),
54
+ newline: profile?.newline ?? 'unknown',
55
+ hasFinalNewline: Boolean(profile?.hasFinalNewline),
56
+ binaryLike: Boolean(profile?.binaryLike),
57
+ utf8Valid: Boolean(profile?.utf8Valid),
58
+ sha256: profile?.sha256 ?? '',
59
+ size: Number(profile?.size ?? 0),
60
+ shebangHasBom: Boolean(profile?.shebangHasBom),
61
+ };
62
+ }
63
+
64
+ export function encodeTextWithProfile(text, profile = {}) {
65
+ const encoded = Buffer.from(String(text ?? ''), 'utf8');
66
+ return profile.hasBom ? Buffer.concat([UTF8_BOM, encoded]) : encoded;
67
+ }
68
+
69
+ export function normalizeNewlinesForProfile(text, profile = {}) {
70
+ const value = String(text ?? '');
71
+ if (profile.newline !== 'CRLF' && profile.newline !== 'CR') {
72
+ return value;
73
+ }
74
+
75
+ const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
76
+ if (profile.newline === 'CR') {
77
+ return normalized.replace(/\n/g, '\r');
78
+ }
79
+ return normalized.replace(/\n/g, '\r\n');
80
+ }
81
+
82
+ export async function writeTextWithProfile(filePath, text, profile = {}) {
83
+ await fs.writeFile(filePath, encodeTextWithProfile(text, profile));
84
+ }
85
+
86
+ function detectNewlineStyle(text) {
87
+ const crlf = (text.match(/\r\n/g) || []).length;
88
+ const bareLf = (text.match(/(?<!\r)\n/g) || []).length;
89
+ const bareCr = (text.match(/\r(?!\n)/g) || []).length;
90
+ const styles = [crlf > 0 ? 'CRLF' : null, bareLf > 0 ? 'LF' : null, bareCr > 0 ? 'CR' : null].filter(Boolean);
91
+ if (styles.length === 0) return 'none';
92
+ if (styles.length === 1) return styles[0];
93
+ return 'mixed';
94
+ }
95
+
96
+ function looksBinaryLike(buffer) {
97
+ if (!buffer || buffer.length === 0) return false;
98
+ const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
99
+ let suspicious = 0;
100
+ for (const byte of sample) {
101
+ if (byte === 0) return true;
102
+ if (byte < 0x09) suspicious += 1;
103
+ if (byte > 0x0d && byte < 0x20) suspicious += 1;
104
+ }
105
+ return suspicious / sample.length > 0.15;
106
+ }
107
+
108
+ function parseArgs(argv = process.argv.slice(2)) {
109
+ const args = { json: false };
110
+ for (let i = 0; i < argv.length; i += 1) {
111
+ const arg = argv[i];
112
+ if (arg === '--json') args.json = true;
113
+ else if (arg === '--file') args.file = argv[++i];
114
+ else if (arg === '--help' || arg === '-h') args.help = true;
115
+ }
116
+ return args;
117
+ }
118
+
119
+ async function main() {
120
+ const args = parseArgs();
121
+ if (args.help || !args.file) {
122
+ const help = 'Usage: node text-profile.mjs --file <path> [--json]';
123
+ process.stdout.write(args.json ? `${JSON.stringify({ help })}\n` : `${help}\n`);
124
+ return;
125
+ }
126
+ const profile = publicTextProfile(await analyzeTextFile(args.file));
127
+ if (args.json) {
128
+ process.stdout.write(`${JSON.stringify({ profile })}\n`);
129
+ } else {
130
+ process.stdout.write(`[ukit:text-profile] ${args.file} bom=${profile.hasBom ? 'yes' : 'no'} newline=${profile.newline} utf8=${profile.utf8Valid ? 'valid' : 'invalid'} bytes=${profile.size}\n`);
131
+ }
132
+ }
133
+
134
+ if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
135
+ main().catch((error) => {
136
+ process.stderr.write(`[ukit:text-profile] ERROR: ${error?.message || error}\n`);
137
+ process.exit(1);
138
+ });
139
+ }
@@ -12,10 +12,10 @@ Auto-generated by UKit for OpenAI Codex.
12
12
  - Do not make end users memorize skill names, helper scripts, or routing internals unless they are debugging UKit itself.
13
13
  - **Treat helper commands as internal orchestration. Do not ask end users to run them.**
14
14
 
15
- ## UKit v1.2.2 Shared Runtime
15
+ ## UKit v1.3.0 Shared Runtime
16
16
 
17
17
  - Shared runtime state lives in `.ukit/storage/`.
18
- - Treat `.ukit/storage/config.json` as the source of compact, token-pipeline, router, memory, and validation toggles.
18
+ - Treat `.ukit/storage/config.json` as the source of compact, token-pipeline, router, memory, validation, and Safe Patch guardrail toggles.
19
19
  - Reuse `.ukit/storage/memory/` before asking users to restate prior decisions.
20
20
  - For non-trivial work, prefer `ukit memory recall "<current task>"` before widening docs.
21
21
  - Reusable cache state lives in `.ukit/storage/cache/prompt-cache.json`, `.ukit/storage/cache/compact-history.json`, `.ukit/storage/cache/compact-pressure.json`, `.ukit/storage/cache/output-history.json`, and preserved raw tool outputs under `.ukit/storage/cache/tee/`.
@@ -24,6 +24,7 @@ Auto-generated by UKit for OpenAI Codex.
24
24
  - Reuse compact `previous-context` / `recent-output` route snapshots before replaying raw history.
25
25
  - Threshold-based compact pressure is internal orchestration; Codex users should not need to manage it manually.
26
26
  - UKit keeps Claude PreCompact/reinject and OpenCode native auto/prune compaction intact. For Codex Desktop long sessions, UKit can use `subagents.smallTaskModel` through `ukit-small-task-maintainer` to decide soft auto-compact handoffs. Default `compact.codexContext.compactTarget=150` means about 150 compact handoff lines (120-150 preferred, hard max 170), not 150 app-context tokens.
27
+ - Safe Patch Protocol is internal: for risky file edits, prefer current-file anchors and exact specs, avoid whole-file rewrites, and preserve multilingual/BOM/newline profiles. Do not ask normal users to run helper commands.
27
28
 
28
29
  ## Speed / Reading Contract
29
30
 
@@ -43,10 +43,10 @@
43
43
  - If a concrete verification lane is needed, prefer `node .claude/ukit/index/verify-context.mjs ...`.
44
44
  - These helper/index commands are internal orchestration. Run them yourself when needed; never turn them into required end-user workflow.
45
45
 
46
- ## UKit v1.2.2 Shared Runtime
46
+ ## UKit v1.3.0 Shared Runtime
47
47
 
48
48
  - Shared runtime state lives in `.ukit/storage/`.
49
- - Treat `.ukit/storage/config.json` as the source of runtime toggles for compact, token pipeline, router, memory, and validation behavior.
49
+ - Treat `.ukit/storage/config.json` as the source of runtime toggles for compact, token pipeline, router, memory, validation, and Safe Patch behavior.
50
50
  - Reuse `.ukit/storage/memory/` before asking users to restate prior decisions.
51
51
  - For non-trivial work, prefer `ukit memory recall "<current task>"` before widening doc reads.
52
52
  - Reusable runtime cache lives in `.ukit/storage/cache/prompt-cache.json`, `.ukit/storage/cache/compact-history.json`, and `.ukit/storage/cache/output-history.json`.
@@ -95,6 +95,13 @@
95
95
  - If route memory already includes `delegate=<lane>`, treat it as an internal hint after any required indexed-context step.
96
96
  - Do not ask end users to name agents or remember agent commands.
97
97
 
98
+ ## Safe Patch Protocol
99
+
100
+ - Safe Patch is internal orchestration: normal users still only need `ukit install` and natural language.
101
+ - For risky/shared/large edits, prefer unique current-file anchors over line numbers or stale pasted blocks.
102
+ - Do not silently merge stale specs: if `old_string` is missing or ambiguous, re-read current source and ask whether to apply as-is, adapt, or skip.
103
+ - Preserve UTF-8 BOM/no-BOM and LF/CRLF for existing multilingual/user-authored files; use UKit safe patch helpers internally when normal Edit/Write may normalize bytes.
104
+
98
105
  ## Team Workflow Safety
99
106
 
100
107
  - Do not teach normal contributors `ukit doctor`, `ukit diff`, `ukit uninstall`, or `ukit index ...` unless they are explicitly debugging UKit itself.
@@ -42,10 +42,10 @@
42
42
  - **Do not ask normal contributors to run internal helper commands**; run them yourself or tell them to rerun `ukit install`.
43
43
  - Do not ask normal contributors to memorize `ukit doctor`, `ukit diff`, `ukit uninstall`, or `ukit index ...` unless they explicitly need maintainer/debug help.
44
44
 
45
- ## UKit v1.2.2 Shared Runtime
45
+ ## UKit v1.3.0 Shared Runtime
46
46
 
47
47
  - Shared runtime state lives in `.ukit/storage/`.
48
- - Treat `.ukit/storage/config.json` as the source of runtime toggles for compact, token pipeline, router, memory, and validation behavior.
48
+ - Treat `.ukit/storage/config.json` as the source of runtime toggles for compact, token pipeline, router, memory, validation, and Safe Patch behavior.
49
49
  - Reuse `.ukit/storage/memory/` before asking users to restate decisions.
50
50
  - For non-trivial work, prefer `ukit memory recall "<current task>"` before widening doc reads.
51
51
  - Reusable cache/compact/output state lives in `.ukit/storage/cache/prompt-cache.json`, `.ukit/storage/cache/compact-history.json`, and `.ukit/storage/cache/output-history.json`.
@@ -53,6 +53,14 @@
53
53
  - Maintainers can inspect runtime state with `ukit status` and `ukit memory export`.
54
54
  - If runtime files are missing or corrupt, tell maintainers to rerun `ukit install`.
55
55
 
56
+
57
+ ## Safe Patch Protocol
58
+
59
+ - Safe Patch is internal orchestration: normal users still only need `ukit install` and natural language.
60
+ - For risky/shared/large edits, prefer unique current-file anchors over line numbers or stale pasted blocks.
61
+ - Do not silently merge stale specs: if `old_string` is missing or ambiguous, re-read current source and ask whether to apply as-is, adapt, or skip.
62
+ - Preserve UTF-8 BOM/no-BOM and LF/CRLF for existing multilingual/user-authored files; use UKit safe patch helpers internally when normal Edit/Write may normalize bytes.
63
+
56
64
  ## Context + Verification Budget
57
65
 
58
66
  - **Trivial**: no docs.
@@ -1,6 +1,6 @@
1
1
  # UKit Shared Runtime
2
2
 
3
- This folder stores shared UKit runtime state for v1.2.2 features.
3
+ This folder stores shared UKit runtime state for v1.3.0 features.
4
4
 
5
5
  - `storage/config.json` — runtime feature flags and defaults
6
6
  - `storage/cache/` — prompt-cache, compact history, compact pressure state, output summaries, and preserved raw tool outputs under `storage/cache/tee/`
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.2.2",
2
+ "version": "1.3.0",
3
3
  "agent": "claude-code",
4
4
  "compact": {
5
5
  "enabled": true,
@@ -86,6 +86,17 @@
86
86
  "maxRetries": 1,
87
87
  "confidenceThreshold": 50
88
88
  },
89
+ "safePatch": {
90
+ "enabled": true,
91
+ "strictSharedRisk": true,
92
+ "largeFileLineThreshold": 800,
93
+ "largeFileByteThreshold": 200000,
94
+ "backupEnabled": true,
95
+ "backupRetentionDays": 30,
96
+ "deltaMaxChangedLines": 120,
97
+ "deltaMaxHunks": 3,
98
+ "deltaMaxDiffCells": 2000000
99
+ },
89
100
  "subagents": {
90
101
  "enabled": true,
91
102
  "smallTaskModel": "unic-lite",
@@ -273,6 +284,17 @@
273
284
  "decisions": "Nhóm quyết định sidecar được phép hỗ trợ.",
274
285
  "stepBudgets": "Gợi ý số bước planning tối đa theo độ lớn task trước khi reassess."
275
286
  }
287
+ },
288
+ "safePatch": {
289
+ "enabled": "Bật Safe Patch Protocol: UKit âm thầm chặn patch stale/ambiguous trên file rủi ro và giữ an toàn encoding khi helper nội bộ sửa file.",
290
+ "strictSharedRisk": "Nếu true, file runtime/shared-risk như .claude/hooks hoặc .claude/ukit sẽ bị guard chặt hơn; người dùng vẫn không cần nhớ command mới.",
291
+ "largeFileLineThreshold": "Số dòng từ đó file được xem là lớn và cần anchor/spec guard kỹ hơn.",
292
+ "largeFileByteThreshold": "Kích thước byte từ đó file được xem là lớn/rủi ro khi agent edit.",
293
+ "backupEnabled": "Nếu true, UKit tạo rollback bytes dưới .ukit/storage/backups cho edit rủi ro.",
294
+ "backupRetentionDays": "Số ngày giữ backup rollback cũ trong .ukit/storage/backups; UKit chỉ tự xoá folder backup có tên ngày hợp lệ YYYY-MM-DD.",
295
+ "deltaMaxChangedLines": "Ngân sách dòng đổi mặc định cho kiểm tra delta sau này.",
296
+ "deltaMaxHunks": "Ngân sách hunk đổi mặc định cho kiểm tra delta sau này.",
297
+ "deltaMaxDiffCells": "Giới hạn cell LCS khi tính delta; quá ngưỡng này UKit dùng fallback tuyến tính để tránh chậm trên file rất lớn."
276
298
  }
277
299
  }
278
300
  }