@ngockhoale/ukit 1.2.1 → 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.
- package/CHANGELOG.md +27 -1
- package/README.md +7 -5
- package/manifests/platform.full.yaml +127 -13
- package/package.json +1 -1
- package/src/core/runInstallPipeline.js +6 -1
- package/src/core/runtimeConfig.js +29 -112
- package/src/index/impactCatalog.js +12 -0
- package/src/index/taskRouting.js +3 -0
- package/templates/.claude/agents/ukit-small-task-maintainer.md +5 -5
- package/templates/.claude/hooks/post-edit-verify.sh +13 -0
- package/templates/.claude/hooks/pre-edit-backup.sh +13 -0
- package/templates/.claude/hooks/stale-spec-guard.sh +13 -0
- package/templates/.claude/settings.json +20 -0
- package/templates/.claude/ukit/index/anchor-search.mjs +99 -0
- package/templates/.claude/ukit/index/lib/index-core.mjs +3 -0
- package/templates/.claude/ukit/index/post-edit-verify.mjs +206 -0
- package/templates/.claude/ukit/index/pre-edit-backup.mjs +84 -0
- package/templates/.claude/ukit/index/route-task.mjs +6 -0
- package/templates/.claude/ukit/index/safe-patch.mjs +97 -0
- package/templates/.claude/ukit/index/stale-spec-check.mjs +192 -0
- package/templates/.claude/ukit/runtime/safe-patch-core.mjs +140 -0
- package/templates/.claude/ukit/runtime/text-profile.mjs +139 -0
- package/templates/.codex/README.md +4 -3
- package/templates/.codex/settings.json +10 -11
- package/templates/.gitignore +0 -1
- package/templates/AGENTS.md +11 -4
- package/templates/CLAUDE.md +12 -4
- package/templates/ukit/README.md +1 -1
- package/templates/ukit/storage/config.json +153 -4
- package/templates/.claude/ukit/.env.example +0 -17
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
classifySafePatchRisk,
|
|
6
|
+
countOccurrences,
|
|
7
|
+
lineNumberForIndex,
|
|
8
|
+
parseJsonInput,
|
|
9
|
+
pathExists,
|
|
10
|
+
readRuntimeSafePatchConfig,
|
|
11
|
+
resolveProjectFile,
|
|
12
|
+
summarizeSnippet,
|
|
13
|
+
} from '../runtime/safe-patch-core.mjs';
|
|
14
|
+
|
|
15
|
+
function getToolName(payload = {}) {
|
|
16
|
+
return String(payload.tool_name || payload.tool || payload.name || '').trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getFilePath(payload = {}) {
|
|
20
|
+
return payload.tool_input?.file_path || payload.file_path || '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getOldString(payload = {}) {
|
|
24
|
+
return payload.tool_input?.old_string ?? payload.old_string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatRisk(risk) {
|
|
28
|
+
return risk.labels.length > 0 ? risk.labels.join(',') : 'low';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function checkStaleSpec({ projectRoot = process.cwd(), payload = {} } = {}) {
|
|
32
|
+
const config = await readRuntimeSafePatchConfig(projectRoot);
|
|
33
|
+
if (config.enabled === false) {
|
|
34
|
+
return { status: 'disabled' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const toolName = getToolName(payload);
|
|
38
|
+
const filePath = getFilePath(payload);
|
|
39
|
+
if (!filePath) return { status: 'skipped', reason: 'no-file' };
|
|
40
|
+
const resolved = resolveProjectFile(projectRoot, filePath);
|
|
41
|
+
const exists = await pathExists(resolved.absolute);
|
|
42
|
+
|
|
43
|
+
let profile = null;
|
|
44
|
+
let risk = classifySafePatchRisk(resolved.relative, null, config);
|
|
45
|
+
if (exists) {
|
|
46
|
+
profile = await analyzeTextFile(resolved.absolute);
|
|
47
|
+
risk = classifySafePatchRisk(resolved.relative, profile, config);
|
|
48
|
+
}
|
|
49
|
+
const strict = Boolean(config.strictSharedRisk) && risk.strict;
|
|
50
|
+
|
|
51
|
+
if (profile?.shebangHasBom && strict) {
|
|
52
|
+
return {
|
|
53
|
+
status: 'blocked',
|
|
54
|
+
code: 'shebang-bom',
|
|
55
|
+
message: `BLOCKED: shebang file '${resolved.relative}' has a UTF-8 BOM before #!. Use safe-patch.mjs after removing/handling BOM intentionally.`,
|
|
56
|
+
file: resolved.relative,
|
|
57
|
+
risk,
|
|
58
|
+
profile: publicTextProfile(profile),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (profile && (profile.binaryLike || !profile.utf8Valid) && strict) {
|
|
63
|
+
return {
|
|
64
|
+
status: 'blocked',
|
|
65
|
+
code: 'non-text',
|
|
66
|
+
message: `BLOCKED: '${resolved.relative}' is not strict UTF-8 text. Refusing risky agent edit to avoid corruption.`,
|
|
67
|
+
file: resolved.relative,
|
|
68
|
+
risk,
|
|
69
|
+
profile: publicTextProfile(profile),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (/write/i.test(toolName) && exists && strict) {
|
|
74
|
+
return {
|
|
75
|
+
status: 'blocked',
|
|
76
|
+
code: 'shared-risk-write',
|
|
77
|
+
message: `BLOCKED: whole-file Write to existing shared-risk file '${resolved.relative}'. Use anchored Edit or node .claude/ukit/index/safe-patch.mjs with a unique anchor.`,
|
|
78
|
+
file: resolved.relative,
|
|
79
|
+
risk,
|
|
80
|
+
profile: profile ? publicTextProfile(profile) : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!/edit/i.test(toolName)) {
|
|
85
|
+
return { status: 'skipped', reason: 'not-edit', file: resolved.relative, risk };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const oldString = getOldString(payload);
|
|
89
|
+
if (!exists) {
|
|
90
|
+
return { status: 'skipped', reason: 'new-file', file: resolved.relative, risk };
|
|
91
|
+
}
|
|
92
|
+
if (typeof oldString !== 'string' || oldString.length === 0) {
|
|
93
|
+
if (strict) {
|
|
94
|
+
return {
|
|
95
|
+
status: 'blocked',
|
|
96
|
+
code: 'missing-old-string',
|
|
97
|
+
message: `BLOCKED: missing old_string for shared-risk edit '${resolved.relative}'. Use a unique current-file anchor and exact old_string.`,
|
|
98
|
+
file: resolved.relative,
|
|
99
|
+
risk,
|
|
100
|
+
profile: publicTextProfile(profile),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { status: 'warn', code: 'missing-old-string', file: resolved.relative, risk };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const occurrence = countOccurrences(profile.text, oldString);
|
|
107
|
+
if (occurrence.count === 1) {
|
|
108
|
+
return {
|
|
109
|
+
status: 'ok',
|
|
110
|
+
specStatus: 'exact',
|
|
111
|
+
file: resolved.relative,
|
|
112
|
+
risk,
|
|
113
|
+
profile: publicTextProfile(profile),
|
|
114
|
+
line: lineNumberForIndex(profile.text, occurrence.indexes[0]),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const code = occurrence.count === 0 ? 'stale-spec' : 'ambiguous-spec';
|
|
119
|
+
const message = occurrence.count === 0
|
|
120
|
+
? `BLOCKED: stale spec for '${resolved.relative}' — old_string was not found in current file. Re-read current source, then choose Apply as-is / Adapt to current code / Skip.`
|
|
121
|
+
: `BLOCKED: ambiguous spec for '${resolved.relative}' — old_string matched ${occurrence.count} times. Use a unique anchor or narrower old_string.`;
|
|
122
|
+
|
|
123
|
+
if (strict) {
|
|
124
|
+
return {
|
|
125
|
+
status: 'blocked',
|
|
126
|
+
code,
|
|
127
|
+
message,
|
|
128
|
+
file: resolved.relative,
|
|
129
|
+
risk,
|
|
130
|
+
profile: publicTextProfile(profile),
|
|
131
|
+
matches: occurrence.indexes.slice(0, 5).map((index) => ({
|
|
132
|
+
line: lineNumberForIndex(profile.text, index),
|
|
133
|
+
snippet: summarizeSnippet(profile.text.slice(index, index + Math.min(oldString.length, 180))),
|
|
134
|
+
})),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
status: 'warn',
|
|
140
|
+
code,
|
|
141
|
+
message: message.replace(/^BLOCKED:/, 'WARNING:'),
|
|
142
|
+
file: resolved.relative,
|
|
143
|
+
risk,
|
|
144
|
+
profile: publicTextProfile(profile),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readStdin() {
|
|
149
|
+
return await new Promise((resolve) => {
|
|
150
|
+
let raw = '';
|
|
151
|
+
process.stdin.setEncoding('utf8');
|
|
152
|
+
process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
153
|
+
process.stdin.on('end', () => resolve(raw));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
158
|
+
return {
|
|
159
|
+
json: argv.includes('--json'),
|
|
160
|
+
help: argv.includes('--help') || argv.includes('-h'),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function main() {
|
|
165
|
+
const args = parseArgs();
|
|
166
|
+
if (args.help) {
|
|
167
|
+
const help = 'Usage: stale-spec-check.mjs < Claude hook JSON on stdin';
|
|
168
|
+
process.stdout.write(args.json ? `${JSON.stringify({ help })}\n` : `${help}\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const payload = parseJsonInput(await readStdin(), {});
|
|
172
|
+
const result = await checkStaleSpec({ projectRoot: process.env.CLAUDE_PROJECT_DIR || process.cwd(), payload });
|
|
173
|
+
if (args.json) {
|
|
174
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
175
|
+
} else if (result.status === 'ok') {
|
|
176
|
+
process.stdout.write(`[ukit-safe-patch] spec=exact file=${result.file} line=${result.line} risk=${formatRisk(result.risk)}\n`);
|
|
177
|
+
} else if (result.status === 'warn') {
|
|
178
|
+
process.stderr.write(`[ukit-safe-patch] ${result.message}\n`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (result.status === 'blocked') {
|
|
182
|
+
process.stderr.write(`[ukit-safe-patch] ${result.message}\n`);
|
|
183
|
+
process.exit(2);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
|
|
188
|
+
main().catch((error) => {
|
|
189
|
+
process.stderr.write(`[ukit-safe-patch] ERROR: ${error?.message || error}\n`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -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.
|
|
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
|
|
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/`.
|
|
@@ -23,7 +23,8 @@ Auto-generated by UKit for OpenAI Codex.
|
|
|
23
23
|
- Maintainers can inspect runtime state with `ukit status` and `ukit memory export`.
|
|
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
|
-
- UKit keeps Claude PreCompact/reinject and OpenCode native auto/prune compaction intact. For Codex Desktop long sessions, UKit can use `
|
|
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
|
|
|
@@ -148,7 +148,6 @@
|
|
|
148
148
|
}
|
|
149
149
|
},
|
|
150
150
|
"smallTaskModel": {
|
|
151
|
-
"env": "UKIT_SMALL_TASK_MODEL",
|
|
152
151
|
"default": "unic-lite",
|
|
153
152
|
"agent": "ukit-small-task-maintainer",
|
|
154
153
|
"useFor": [
|
|
@@ -209,12 +208,9 @@
|
|
|
209
208
|
},
|
|
210
209
|
"codexContext": {
|
|
211
210
|
"autoCompact": true,
|
|
212
|
-
"budgetTokens":
|
|
211
|
+
"budgetTokens": 100000,
|
|
213
212
|
"compactTarget": 150,
|
|
214
213
|
"compactTargetUnit": "lines",
|
|
215
|
-
"targetEnv": "UKIT_CODEX_COMPACT_TARGET",
|
|
216
|
-
"budgetEnv": "UKIT_CODEX_CONTEXT_BUDGET",
|
|
217
|
-
"autoCompactEnv": "UKIT_CODEX_AUTO_COMPACT",
|
|
218
214
|
"mode": "soft-handoff",
|
|
219
215
|
"preserve": [
|
|
220
216
|
"current-goal",
|
|
@@ -229,19 +225,20 @@
|
|
|
229
225
|
120,
|
|
230
226
|
170
|
|
231
227
|
],
|
|
232
|
-
"compactTargetMax": 170
|
|
228
|
+
"compactTargetMax": 170,
|
|
229
|
+
"configPath": ".ukit/storage/config.json",
|
|
230
|
+
"targetField": "compact.codexContext.compactTarget",
|
|
231
|
+
"budgetField": "compact.codexContext.budgetTokens",
|
|
232
|
+
"autoCompactField": "compact.codexContext.autoCompact"
|
|
233
233
|
},
|
|
234
234
|
"agentContext": {
|
|
235
235
|
"preserveClaudePreCompact": true,
|
|
236
236
|
"preserveOpenCodeCompaction": true,
|
|
237
237
|
"codexSoftHandoff": {
|
|
238
238
|
"autoCompact": true,
|
|
239
|
-
"budgetTokens":
|
|
239
|
+
"budgetTokens": 100000,
|
|
240
240
|
"compactTarget": 150,
|
|
241
241
|
"compactTargetUnit": "lines",
|
|
242
|
-
"targetEnv": "UKIT_CODEX_COMPACT_TARGET",
|
|
243
|
-
"budgetEnv": "UKIT_CODEX_CONTEXT_BUDGET",
|
|
244
|
-
"autoCompactEnv": "UKIT_CODEX_AUTO_COMPACT",
|
|
245
242
|
"mode": "soft-handoff",
|
|
246
243
|
"preserve": [
|
|
247
244
|
"current-goal",
|
|
@@ -258,7 +255,9 @@
|
|
|
258
255
|
],
|
|
259
256
|
"compactTargetMax": 170
|
|
260
257
|
}
|
|
261
|
-
}
|
|
258
|
+
},
|
|
259
|
+
"configPath": ".ukit/storage/config.json",
|
|
260
|
+
"configField": "subagents.smallTaskModel"
|
|
262
261
|
}
|
|
263
262
|
},
|
|
264
263
|
"execution": {
|
package/templates/.gitignore
CHANGED
package/templates/AGENTS.md
CHANGED
|
@@ -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.
|
|
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
|
|
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`.
|
|
@@ -78,10 +78,10 @@
|
|
|
78
78
|
|
|
79
79
|
## Small-Task Maintainer (internal)
|
|
80
80
|
|
|
81
|
-
- UKit may route low-risk internal decisions to the `ukit-small-task-maintainer` subagent using `
|
|
81
|
+
- UKit may route low-risk internal decisions to the `ukit-small-task-maintainer` subagent using `subagents.smallTaskModel` (default `unic-lite`).
|
|
82
82
|
- Use it for safe/reversible UKit chores: dọn `docs/TASKS.md`, queued-task classification, fast-vs-slow/safe-vs-risky lane decisions, skill-routing/step-budget hints, agent context-budget decisions, compact/summary decisions, docs/status summarization, auto-triage, queue maintenance, and small workspace cleanup.
|
|
83
83
|
- Run it as a sidecar/parallel lane only; do not block, replace, or slow the user task. Do not block the main AI flow: if the small-task lane sees security, risky/shared code, release/publish, data-loss, architecture, deep-reasoning risk, weak context, or quality risk, it hands back to the main model instead of asking the end user to decide.
|
|
84
|
-
- This is optional internal orchestration config from `.
|
|
84
|
+
- This is optional internal orchestration config from `.ukit/storage/config.json`; never turn it into an end-user workflow. End users still only need `ukit install` and natural-language product work. Always preserve the CoDev priority: quality > safety > speed > token discipline. Keep Claude PreCompact/reinject and OpenCode native auto/prune compaction enabled. For Codex Desktop long sessions, `compact.codexContext.compactTarget` defaults to 150 compact handoff lines (120-150 preferred, hard max 170), decided internally by the small-task maintainer.
|
|
85
85
|
|
|
86
86
|
## Subagent Policy (internal only)
|
|
87
87
|
|
|
@@ -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.
|
package/templates/CLAUDE.md
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
@@ -75,10 +83,10 @@
|
|
|
75
83
|
|
|
76
84
|
## Small-Task Maintainer (internal)
|
|
77
85
|
|
|
78
|
-
- UKit may route low-risk internal decisions to the `ukit-small-task-maintainer` subagent using `
|
|
86
|
+
- UKit may route low-risk internal decisions to the `ukit-small-task-maintainer` subagent using `subagents.smallTaskModel` (default `unic-lite`).
|
|
79
87
|
- Use it for safe/reversible UKit chores: dọn `docs/TASKS.md`, queued-task classification, fast-vs-slow/safe-vs-risky lane decisions, skill-routing/step-budget hints, agent context-budget decisions, compact/summary decisions, docs/status summarization, auto-triage, queue maintenance, and small workspace cleanup.
|
|
80
88
|
- Run it as a sidecar/parallel lane only; do not block, replace, or slow the user task. Do not block the main AI flow: if the small-task lane sees security, risky/shared code, release/publish, data-loss, architecture, deep-reasoning risk, weak context, or quality risk, it hands back to the main model instead of asking the end user to decide.
|
|
81
|
-
- This is optional internal orchestration config from `.
|
|
89
|
+
- This is optional internal orchestration config from `.ukit/storage/config.json`; never turn it into an end-user workflow. End users still only need `ukit install` and natural-language product work. Always preserve the CoDev priority: quality > safety > speed > token discipline. Keep Claude PreCompact/reinject and OpenCode native auto/prune compaction enabled. For Codex Desktop long sessions, `compact.codexContext.compactTarget` defaults to 150 compact handoff lines (120-150 preferred, hard max 170), decided internally by the small-task maintainer.
|
|
82
90
|
|
|
83
91
|
## Selective Subagent Policy (internal only)
|
|
84
92
|
|
package/templates/ukit/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# UKit Shared Runtime
|
|
2
2
|
|
|
3
|
-
This folder stores shared UKit runtime state for v1.
|
|
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/`
|