@ngockhoale/ukit 2.4.2 → 2.5.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 +57 -0
- package/README.md +20 -0
- package/manifests/platform.full.yaml +51 -112
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/cli/commands/doctor.js +132 -2
- package/src/cli/commands/uninstall.js +18 -0
- package/src/core/applyPlan.js +17 -2
- package/src/core/compact/threshold.js +36 -6
- package/src/core/diffPlan.js +35 -0
- package/src/core/fileOps.js +26 -0
- package/src/core/projectImportant.js +430 -0
- package/src/core/sensitiveValueScanner.js +118 -0
- package/src/core/status.js +55 -1
- package/src/core/uninstall.js +183 -3
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/project-important.sh +67 -0
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
- package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
- package/templates/AGENTS.md +8 -0
- package/templates/PROJECT_IMPORTANT.md +9 -0
package/src/core/applyPlan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { writeFileAtomic, copyFileSafe, createDirectoryLink, removeLinkOnly } from './fileOps.js';
|
|
3
|
+
import { writeFileAtomic, writeFileExclusive, copyFileSafe, createDirectoryLink, removeLinkOnly } from './fileOps.js';
|
|
4
4
|
|
|
5
5
|
// legacy: '.antigravity/' stays TCC-protected — Antigravity adapter removed in v2.2.0, but a
|
|
6
6
|
// stale install may still have the dir and must not lose TCC protection.
|
|
@@ -107,9 +107,24 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
|
|
|
107
107
|
)
|
|
108
108
|
: entry.renderedContent;
|
|
109
109
|
|
|
110
|
+
// Race-safe seed: a `create` entry with `mergeStrategy: skip` must not overwrite a
|
|
111
|
+
// target that appeared after the diff ran. Exclusive 'wx' create fails with EEXIST
|
|
112
|
+
// instead — that becomes a skipped create (no rewrite, no chmod, no tracked write),
|
|
113
|
+
// and install continues. Temp+rename is NOT used here because rename overwrites.
|
|
114
|
+
const exclusiveSkipCreate = entry.action === 'create' && entry.mergeStrategy === 'skip';
|
|
115
|
+
|
|
110
116
|
try {
|
|
111
|
-
|
|
117
|
+
if (exclusiveSkipCreate) {
|
|
118
|
+
await writeFileExclusive(entry.targetPath, nextContent);
|
|
119
|
+
} else {
|
|
120
|
+
await writeFileAtomic(entry.targetPath, nextContent);
|
|
121
|
+
}
|
|
112
122
|
} catch (writeError) {
|
|
123
|
+
if (writeError.code === 'EEXIST' && exclusiveSkipCreate) {
|
|
124
|
+
skippedUpdates += 1;
|
|
125
|
+
skippedByAction.create += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
113
128
|
if (
|
|
114
129
|
(writeError.code === 'EPERM' || writeError.code === 'EACCES') &&
|
|
115
130
|
isTccProtectedPath(entry.targetPath)
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
|
|
2
|
+
// The installed runtime is the single implementation of capacity negotiation; this source
|
|
3
|
+
// twin consumes it rather than re-deriving the precedence rules. Both files ship together.
|
|
4
|
+
import {
|
|
5
|
+
readContextCapacityRecord,
|
|
6
|
+
SOFT_TO_CAP_RATIO,
|
|
7
|
+
} from '../../../templates/.claude/ukit/runtime/compact-threshold.mjs';
|
|
8
|
+
import { resolveContextCapTokens } from '../../../templates/.claude/ukit/runtime/context-capacity.mjs';
|
|
2
9
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
3
10
|
import { buildCompactMachineKey, compressLine, estimateTokenCount } from '../token/index.js';
|
|
4
11
|
import { compactContextBlock } from './index.js';
|
|
@@ -436,22 +443,45 @@ function computeEstimatedTotalTokens({
|
|
|
436
443
|
return baselineTokens + estimatedContextTokens + windowTokens + sessionExcess;
|
|
437
444
|
}
|
|
438
445
|
|
|
446
|
+
// The record the advisory guard published for this project, or null when nothing has been
|
|
447
|
+
// negotiated yet (fresh install, non-project caller, tests).
|
|
448
|
+
function readNegotiatedCapacity(config = {}) {
|
|
449
|
+
const record = readContextCapacityRecord(config?.projectRoot);
|
|
450
|
+
if (!record) return null;
|
|
451
|
+
return resolveContextCapTokens({
|
|
452
|
+
env: process.env,
|
|
453
|
+
config,
|
|
454
|
+
modelMetadata: { model: record.model },
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
439
458
|
export function buildCompactThresholds(config = {}) {
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
459
|
+
const shippedHardCap = positiveInteger(
|
|
460
|
+
config?.compact?.hardCapTokens,
|
|
461
|
+
loadShippedCompactBudget().hardCapTokens,
|
|
443
462
|
);
|
|
463
|
+
const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
|
|
464
|
+
// Negotiated capacity (H22): the guard publishes what it derived from the live route and
|
|
465
|
+
// this shared path consumes it; without a record the shipped tuning applies unchanged.
|
|
466
|
+
const negotiated = readNegotiatedCapacity(config);
|
|
467
|
+
const hardCapTokens = negotiated
|
|
468
|
+
? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
|
|
469
|
+
: shippedHardCap;
|
|
470
|
+
// An explicit operator tokenThreshold is honored as-is; otherwise the advisory phase is
|
|
471
|
+
// derived from the negotiating cap so it can never sit above the cap it precedes.
|
|
472
|
+
const softThreshold = explicitSoftThreshold > 0
|
|
473
|
+
? Math.max(1, explicitSoftThreshold)
|
|
474
|
+
: Math.max(1, Math.round(hardCapTokens * SOFT_TO_CAP_RATIO));
|
|
444
475
|
const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
|
|
445
476
|
const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
|
|
446
|
-
// Keep the source mirror's config contract aligned with the installed runtime: malformed
|
|
447
|
-
// caps fall back to a safe ceiling rather than turning every mutation into an over-cap call.
|
|
448
|
-
const hardCapTokens = positiveInteger(config?.compact?.hardCapTokens, loadShippedCompactBudget().hardCapTokens);
|
|
449
477
|
|
|
450
478
|
return {
|
|
451
479
|
softThreshold,
|
|
452
480
|
hardThreshold,
|
|
453
481
|
baselineTokens,
|
|
454
482
|
hardCapTokens,
|
|
483
|
+
capacitySource: negotiated?.capacity?.source ?? 'shipped',
|
|
484
|
+
capacityTokens: negotiated?.capacity?.tokens ?? null,
|
|
455
485
|
};
|
|
456
486
|
}
|
|
457
487
|
|
package/src/core/diffPlan.js
CHANGED
|
@@ -132,6 +132,37 @@ function resolveFileAction(entry, existingContent) {
|
|
|
132
132
|
return { ...entry, exists, action, existingContent };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// A `mergeStrategy: skip` target must never be classified as "missing" just because it
|
|
136
|
+
// cannot be READ — a directory, broken symlink, FIFO/device, or unreadable (EACCES) file
|
|
137
|
+
// at the seed path all mean "exists, do not touch". readFileOrNull would turn every one
|
|
138
|
+
// of those into null → 'create' → an overwrite attempt. Existence for skip entries is
|
|
139
|
+
// decided by lstat (no symlink follow, no device open — reading a FIFO would hang).
|
|
140
|
+
async function resolveSkipEntryAction(entry) {
|
|
141
|
+
let stat;
|
|
142
|
+
try {
|
|
143
|
+
stat = await fs.lstat(entry.targetPath);
|
|
144
|
+
} catch {
|
|
145
|
+
// ENOENT (or a stat failure we cannot distinguish from it) is the only state that
|
|
146
|
+
// means "seed me"; the apply step still uses an exclusive 'wx' create for the race.
|
|
147
|
+
return { ...entry, exists: false, action: 'create', existingContent: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Only a regular, readable file can prove byte-equality for an 'unchanged' verdict.
|
|
151
|
+
// Anything else — directory, symlink (valid or broken), FIFO, socket, device, or a
|
|
152
|
+
// file we cannot read — is simply 'skip': present, owner-owned, hands off.
|
|
153
|
+
let existingContent = null;
|
|
154
|
+
if (stat.isFile() && !stat.isSymbolicLink()) {
|
|
155
|
+
existingContent = await readFileOrNull(
|
|
156
|
+
entry.targetPath,
|
|
157
|
+
Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (existingContent === null) {
|
|
161
|
+
return { ...entry, exists: true, action: 'skip', existingContent: null };
|
|
162
|
+
}
|
|
163
|
+
return resolveFileAction(entry, existingContent);
|
|
164
|
+
}
|
|
165
|
+
|
|
135
166
|
export async function diffInstallPlan(plan) {
|
|
136
167
|
// Check all entries in parallel
|
|
137
168
|
const results = await Promise.all(
|
|
@@ -141,6 +172,10 @@ export async function diffInstallPlan(plan) {
|
|
|
141
172
|
return { ...entry, exists: action !== 'create', action, existingContent: null };
|
|
142
173
|
}
|
|
143
174
|
|
|
175
|
+
if (entry.mergeStrategy === 'skip') {
|
|
176
|
+
return resolveSkipEntryAction(entry);
|
|
177
|
+
}
|
|
178
|
+
|
|
144
179
|
const existingContent = await readFileOrNull(
|
|
145
180
|
entry.targetPath,
|
|
146
181
|
Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
|
package/src/core/fileOps.js
CHANGED
|
@@ -128,6 +128,32 @@ export async function copyFileSafe(fromPath, toPath) {
|
|
|
128
128
|
await fs.copyFile(fromPath, toPath);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Exclusive-create write ('wx'): succeeds only when `filePath` does not already exist.
|
|
133
|
+
* Used for seed-once (`mergeStrategy: skip`) creates so a file that appears between
|
|
134
|
+
* diff and apply is never overwritten — the caller maps EEXIST to a skipped create.
|
|
135
|
+
* Never used as a temp+rename pair: rename could overwrite a target that just appeared.
|
|
136
|
+
* @param {string} filePath
|
|
137
|
+
* @param {string|Buffer} content - raw bytes; no encoding/normalization is applied
|
|
138
|
+
*/
|
|
139
|
+
export async function writeFileExclusive(filePath, content) {
|
|
140
|
+
await ensureDir(path.dirname(filePath));
|
|
141
|
+
await fs.writeFile(filePath, content, { flag: 'wx' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Raw-byte copy into a path that must not already exist (exclusive destination create,
|
|
146
|
+
* `fs.constants.COPYFILE_EXCL`). No decode/re-encode — the destination is byte-identical
|
|
147
|
+
* to the source. Source is opened by path; callers needing no-follow semantics must
|
|
148
|
+
* verify the source type before calling. EEXIST propagates for the caller to handle.
|
|
149
|
+
* @param {string} fromPath
|
|
150
|
+
* @param {string} toPath
|
|
151
|
+
*/
|
|
152
|
+
export async function copyFileRawExclusive(fromPath, toPath) {
|
|
153
|
+
await ensureDir(path.dirname(toPath));
|
|
154
|
+
await fs.copyFile(fromPath, toPath, fs.constants.COPYFILE_EXCL);
|
|
155
|
+
}
|
|
156
|
+
|
|
131
157
|
export async function writeJson(filePath, data) {
|
|
132
158
|
await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
133
159
|
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectImportant.js (TASK-037)
|
|
3
|
+
*
|
|
4
|
+
* Source-side inspector + deterministic envelope renderer for
|
|
5
|
+
* PROJECT_IMPORTANT.md per docs/PROJECT_IMPORTANT_SPEC.md §3, §4, §5.2, §6,
|
|
6
|
+
* §12.1.
|
|
7
|
+
*
|
|
8
|
+
* Guarantees:
|
|
9
|
+
* - Open no-follow → fstat → isFile → fd-read (no symlink resolution, no
|
|
10
|
+
* whole-file readFile on arbitrary sizes; stops after code point 6,001).
|
|
11
|
+
* - Strict UTF-8 decode (no U+FFFD injection); leading BOM excluded from
|
|
12
|
+
* count and body, source bytes never modified.
|
|
13
|
+
* - Code-point counting only — never String.length on owner text.
|
|
14
|
+
* - Byte-identical rendered output for identical input/config.
|
|
15
|
+
* - `body` exists only on the runtime-local return; never serialized into
|
|
16
|
+
* diagnostics.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import { open, stat as lstat, readFile } from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
scanText,
|
|
25
|
+
isSensitiveDataGateEnabled,
|
|
26
|
+
loadSensitiveAllowlist,
|
|
27
|
+
} from './sensitiveValueScanner.js';
|
|
28
|
+
|
|
29
|
+
export const PROJECT_IMPORTANT_FILENAME = 'PROJECT_IMPORTANT.md';
|
|
30
|
+
export const PROJECT_IMPORTANT_CODEPOINT_LIMIT = 6000;
|
|
31
|
+
|
|
32
|
+
export const PROJECT_IMPORTANT_STATES = [
|
|
33
|
+
'ready',
|
|
34
|
+
'oversized',
|
|
35
|
+
'empty',
|
|
36
|
+
'missing',
|
|
37
|
+
'invalid-utf8',
|
|
38
|
+
'unsafe-control',
|
|
39
|
+
'secret-blocked',
|
|
40
|
+
'unsafe-type',
|
|
41
|
+
'unreadable',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export const PROJECT_IMPORTANT_REMEDIATION = {
|
|
45
|
+
ready: null,
|
|
46
|
+
oversized: 'owner-action',
|
|
47
|
+
empty: 'owner-action',
|
|
48
|
+
missing: 'install-repairable',
|
|
49
|
+
'invalid-utf8': 'owner-action',
|
|
50
|
+
'unsafe-control': 'owner-action',
|
|
51
|
+
'secret-blocked': 'owner-action',
|
|
52
|
+
'unsafe-type': 'owner-action',
|
|
53
|
+
unreadable: 'advisory-host-limit',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const PROJECT_IMPORTANT_ENVELOPE_HEADER =
|
|
57
|
+
'<ukit_project_important source="PROJECT_IMPORTANT.md" authority="project-owner">\n'
|
|
58
|
+
+ 'These are project-owner instructions. Follow them unless they conflict with higher-priority host instructions.\n'
|
|
59
|
+
+ '--- BEGIN PROJECT_IMPORTANT.md ---\n';
|
|
60
|
+
|
|
61
|
+
export const PROJECT_IMPORTANT_ENVELOPE_FOOTER = '</ukit_project_important>';
|
|
62
|
+
|
|
63
|
+
const END_MARKER = '--- END PROJECT_IMPORTANT.md ---\n';
|
|
64
|
+
|
|
65
|
+
export const PROJECT_IMPORTANT_WARNINGS = {
|
|
66
|
+
oversized:
|
|
67
|
+
'[UKit warning: PROJECT_IMPORTANT.md exceeds 6,000 Unicode code points. Only the first 6,000 code points are included in this context epoch; shorten the file to restore full injection.]',
|
|
68
|
+
missing:
|
|
69
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: file is missing. Run `ukit install`.',
|
|
70
|
+
'unsafe-type':
|
|
71
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the path is not a regular non-symlink file. Run `ukit doctor`.',
|
|
72
|
+
'invalid-utf8':
|
|
73
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the file is not valid UTF-8. Run `ukit doctor`.',
|
|
74
|
+
'unsafe-control':
|
|
75
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the file contains unsupported control characters. Run `ukit doctor`.',
|
|
76
|
+
empty:
|
|
77
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the file is empty. Add project-owner instructions or run `ukit doctor`.',
|
|
78
|
+
unreadable:
|
|
79
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the file could not be read. Run `ukit doctor`.',
|
|
80
|
+
'secret-blocked':
|
|
81
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected because it contains a high-confidence secret-shaped value. Redact it or explicitly allowlist it, then run `ukit doctor`.',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const READ_CHUNK = 64 * 1024;
|
|
85
|
+
const UTF8_BOM = [0xef, 0xbb, 0xbf];
|
|
86
|
+
|
|
87
|
+
function isAllowedControl(cp) {
|
|
88
|
+
return cp === 0x09 || cp === 0x0a || cp === 0x0d;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isForbiddenControl(cp) {
|
|
92
|
+
return (cp < 0x20 && !isAllowedControl(cp)) || (cp >= 0x7f && cp <= 0x9f);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function invalidResult(state) {
|
|
96
|
+
return {
|
|
97
|
+
state,
|
|
98
|
+
body: undefined,
|
|
99
|
+
codePointCount: 0,
|
|
100
|
+
oversized: false,
|
|
101
|
+
bom: false,
|
|
102
|
+
rendered: PROJECT_IMPORTANT_WARNINGS[state],
|
|
103
|
+
remediationClass: PROJECT_IMPORTANT_REMEDIATION[state],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Bounded, strict inspector.
|
|
109
|
+
*
|
|
110
|
+
* @param {{ projectRoot: string, config?: object }} options
|
|
111
|
+
* @returns {Promise<{ state, body?, codePointCount, oversized, bom, rendered, remediationClass }>}
|
|
112
|
+
*/
|
|
113
|
+
export async function inspectProjectImportant(options = {}) {
|
|
114
|
+
const { projectRoot, config } = options;
|
|
115
|
+
const target = path.join(projectRoot, PROJECT_IMPORTANT_FILENAME);
|
|
116
|
+
|
|
117
|
+
let handle;
|
|
118
|
+
try {
|
|
119
|
+
// O_NOFOLLOW where supported: symlinks fail open instead of resolving.
|
|
120
|
+
const flags = fs.constants.O_RDONLY
|
|
121
|
+
| (fs.constants.O_NOFOLLOW || 0)
|
|
122
|
+
| (fs.constants.O_NONBLOCK || 0);
|
|
123
|
+
handle = await open(target, flags);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
|
|
126
|
+
return invalidResult('missing');
|
|
127
|
+
}
|
|
128
|
+
if (err && (err.code === 'ELOOP' || err.code === 'EMLINK')) {
|
|
129
|
+
return invalidResult('unsafe-type');
|
|
130
|
+
}
|
|
131
|
+
// Broken symlink surfaces as ENOENT on some platforms only via lstat —
|
|
132
|
+
// but O_NOFOLLOW gives ELOOP. ENOENT from a symlinked *path* means the
|
|
133
|
+
// link target is missing; check whether the entry itself is a symlink.
|
|
134
|
+
if (err && (err.code === 'EACCES' || err.code === 'EPERM')) {
|
|
135
|
+
return invalidResult('unreadable');
|
|
136
|
+
}
|
|
137
|
+
// Platforms without O_NOFOLLOW support, or odd errors: distinguish
|
|
138
|
+
// symlink/type via lstat before declaring unreadable.
|
|
139
|
+
try {
|
|
140
|
+
const lst = await lstat(target);
|
|
141
|
+
if (!lst.isFile()) return invalidResult('unsafe-type');
|
|
142
|
+
} catch {
|
|
143
|
+
return invalidResult('missing');
|
|
144
|
+
}
|
|
145
|
+
return invalidResult('unreadable');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const fst = await handle.stat();
|
|
150
|
+
if (!fst.isFile()) return invalidResult('unsafe-type');
|
|
151
|
+
|
|
152
|
+
// Streamed, bounded strict decode: stop at code point 6,001.
|
|
153
|
+
const decoder = createStrictDecoder();
|
|
154
|
+
const buf = Buffer.allocUnsafe(READ_CHUNK);
|
|
155
|
+
const cps = [];
|
|
156
|
+
let bom = false;
|
|
157
|
+
let firstChunk = true;
|
|
158
|
+
let done = false;
|
|
159
|
+
|
|
160
|
+
while (!done) {
|
|
161
|
+
const { bytesRead } = await handle.read(buf, 0, READ_CHUNK, null);
|
|
162
|
+
let chunk = buf.subarray(0, bytesRead);
|
|
163
|
+
if (bytesRead === 0) {
|
|
164
|
+
decoder.flush(); // throws on truncated trailing sequence
|
|
165
|
+
done = true;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (firstChunk) {
|
|
169
|
+
firstChunk = false;
|
|
170
|
+
if (
|
|
171
|
+
bytesRead >= 3
|
|
172
|
+
&& chunk[0] === UTF8_BOM[0]
|
|
173
|
+
&& chunk[1] === UTF8_BOM[1]
|
|
174
|
+
&& chunk[2] === UTF8_BOM[2]
|
|
175
|
+
) {
|
|
176
|
+
bom = true;
|
|
177
|
+
chunk = chunk.subarray(3);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const cp of decoder.decode(chunk)) {
|
|
181
|
+
cps.push(cp);
|
|
182
|
+
if (cps.length >= PROJECT_IMPORTANT_CODEPOINT_LIMIT + 1) {
|
|
183
|
+
done = true;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (cps.length === 0) {
|
|
190
|
+
return { ...invalidResult('empty'), bom };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const oversized = cps.length > PROJECT_IMPORTANT_CODEPOINT_LIMIT;
|
|
194
|
+
const injected = oversized ? cps.slice(0, PROJECT_IMPORTANT_CODEPOINT_LIMIT) : cps;
|
|
195
|
+
for (const cp of injected) {
|
|
196
|
+
if (isForbiddenControl(cp)) {
|
|
197
|
+
return { ...invalidResult('unsafe-control'), bom, codePointCount: cps.length };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const body = String.fromCodePoint(...injected);
|
|
202
|
+
const codePointCount = cps.length;
|
|
203
|
+
|
|
204
|
+
// Secret gate on the body that would be injected.
|
|
205
|
+
const gateEnabled = isSensitiveDataGateEnabled(config);
|
|
206
|
+
const allowlistHashes = loadSensitiveAllowlist(config);
|
|
207
|
+
const scan = scanText(body, { allowlistHashes, gateEnabled });
|
|
208
|
+
if (scan.hasSecret) {
|
|
209
|
+
return {
|
|
210
|
+
...invalidResult('secret-blocked'),
|
|
211
|
+
bom,
|
|
212
|
+
codePointCount,
|
|
213
|
+
oversized,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const inspection = {
|
|
218
|
+
state: oversized ? 'oversized' : 'ready',
|
|
219
|
+
body,
|
|
220
|
+
codePointCount,
|
|
221
|
+
oversized,
|
|
222
|
+
bom,
|
|
223
|
+
rendered: undefined,
|
|
224
|
+
remediationClass: PROJECT_IMPORTANT_REMEDIATION[oversized ? 'oversized' : 'ready'],
|
|
225
|
+
};
|
|
226
|
+
inspection.rendered = renderProjectImportant(inspection);
|
|
227
|
+
return inspection;
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (err && err.message === 'invalid-utf8') {
|
|
230
|
+
return invalidResult('invalid-utf8');
|
|
231
|
+
}
|
|
232
|
+
if (err && (err.code === 'EACCES' || err.code === 'EPERM')) {
|
|
233
|
+
return invalidResult('unreadable');
|
|
234
|
+
}
|
|
235
|
+
return invalidResult('unreadable');
|
|
236
|
+
} finally {
|
|
237
|
+
await handle.close().catch(() => {});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Strict incremental UTF-8 decoder that rejects overlong, surrogate,
|
|
243
|
+
* >U+10FFFF and truncated sequences; throws Error('invalid-utf8').
|
|
244
|
+
*/
|
|
245
|
+
function createStrictDecoder() {
|
|
246
|
+
let needed = 0;
|
|
247
|
+
let value = 0;
|
|
248
|
+
let min = 0;
|
|
249
|
+
|
|
250
|
+
function push(out, cp, seqMin) {
|
|
251
|
+
if (cp < seqMin || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) {
|
|
252
|
+
throw new Error('invalid-utf8');
|
|
253
|
+
}
|
|
254
|
+
out.push(cp);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
/** @param {Buffer|Uint8Array} chunk @returns {number[]} */
|
|
259
|
+
decode(chunk) {
|
|
260
|
+
const out = [];
|
|
261
|
+
for (let i = 0; i < chunk.length; i += 1) {
|
|
262
|
+
const b = chunk[i];
|
|
263
|
+
if (needed > 0) {
|
|
264
|
+
if (b < 0x80 || b > 0xbf) throw new Error('invalid-utf8');
|
|
265
|
+
value = (value << 6) | (b & 0x3f);
|
|
266
|
+
needed -= 1;
|
|
267
|
+
if (needed === 0) {
|
|
268
|
+
push(out, value, min);
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (b < 0x80) {
|
|
273
|
+
out.push(b);
|
|
274
|
+
} else if (b >= 0xc2 && b <= 0xdf) {
|
|
275
|
+
needed = 1; min = 0x80; value = b & 0x1f;
|
|
276
|
+
} else if (b >= 0xe0 && b <= 0xef) {
|
|
277
|
+
needed = 2; min = 0x800; value = b & 0x0f;
|
|
278
|
+
} else if (b >= 0xf0 && b <= 0xf4) {
|
|
279
|
+
needed = 3; min = 0x10000; value = b & 0x07;
|
|
280
|
+
} else {
|
|
281
|
+
throw new Error('invalid-utf8');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
},
|
|
286
|
+
flush() {
|
|
287
|
+
if (needed > 0) throw new Error('invalid-utf8');
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Deterministic envelope renderer. Warning states render as the frozen
|
|
294
|
+
* warning literal only; ready/oversized render the full envelope.
|
|
295
|
+
*
|
|
296
|
+
* @param {{ state: string, body?: string }} inspection
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
export function renderProjectImportant(inspection) {
|
|
300
|
+
const { state, body } = inspection || {};
|
|
301
|
+
if (state !== 'ready' && state !== 'oversized') {
|
|
302
|
+
return PROJECT_IMPORTANT_WARNINGS[state] || PROJECT_IMPORTANT_WARNINGS.unreadable;
|
|
303
|
+
}
|
|
304
|
+
const warning = state === 'oversized'
|
|
305
|
+
? PROJECT_IMPORTANT_WARNINGS.oversized + '\n'
|
|
306
|
+
: '';
|
|
307
|
+
// Framing newline outside the body: body bytes are preserved verbatim,
|
|
308
|
+
// the END marker always starts on its own line.
|
|
309
|
+
const framing = body.endsWith('\n') ? '' : '\n';
|
|
310
|
+
return (
|
|
311
|
+
PROJECT_IMPORTANT_ENVELOPE_HEADER
|
|
312
|
+
+ body
|
|
313
|
+
+ framing
|
|
314
|
+
+ END_MARKER
|
|
315
|
+
+ warning
|
|
316
|
+
+ PROJECT_IMPORTANT_ENVELOPE_FOOTER
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Convenience: inspect + ensure `rendered` is populated.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} projectRoot
|
|
324
|
+
* @param {{ config?: object }} [options]
|
|
325
|
+
*/
|
|
326
|
+
export async function renderProjectImportantForProject(projectRoot, options = {}) {
|
|
327
|
+
const inspection = await inspectProjectImportant({ projectRoot, ...options });
|
|
328
|
+
if (inspection.rendered === undefined) {
|
|
329
|
+
inspection.rendered = renderProjectImportant(inspection);
|
|
330
|
+
}
|
|
331
|
+
return inspection;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* TASK-044 — adapter wiring facts for status/doctor (spec §13/§14).
|
|
336
|
+
*
|
|
337
|
+
* Returns install + wiring facts only — never file bodies. Claude wiring is
|
|
338
|
+
* always expected; omp/Codex/OpenCode are checked only when installed, and an
|
|
339
|
+
* absent adapter is `installed:false`, never a failure.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} projectRoot
|
|
342
|
+
* @param {{ trackedPaths?: string[] }} [options] install.json file list, if known
|
|
343
|
+
* @returns {Promise<{claude: object, omp: object, codex: object, opencode: object, agentsFallback: boolean}>}
|
|
344
|
+
*/
|
|
345
|
+
export async function inspectProjectImportantWiring(projectRoot, options = {}) {
|
|
346
|
+
const trackedPaths = Array.isArray(options.trackedPaths) ? options.trackedPaths : [];
|
|
347
|
+
const tracked = (prefix) => trackedPaths.some((entry) => entry.startsWith(prefix));
|
|
348
|
+
|
|
349
|
+
const runtimeModulePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'project-important.mjs');
|
|
350
|
+
const hookPath = path.join(projectRoot, '.claude', 'hooks', 'project-important.sh');
|
|
351
|
+
const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
|
|
352
|
+
const ompBridgePath = path.join(projectRoot, '.omp', 'hooks', 'pre', 'ukit-bridge.js');
|
|
353
|
+
const agentsPath = path.join(projectRoot, 'AGENTS.md');
|
|
354
|
+
|
|
355
|
+
const exists = async (p) => {
|
|
356
|
+
try {
|
|
357
|
+
await lstat(p);
|
|
358
|
+
return true;
|
|
359
|
+
} catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// --- Claude wiring (always expected) ---
|
|
365
|
+
const runtimeModule = await exists(runtimeModulePath);
|
|
366
|
+
let hookInstalled = false;
|
|
367
|
+
let hookExecutable = false;
|
|
368
|
+
try {
|
|
369
|
+
const st = await lstat(hookPath);
|
|
370
|
+
hookInstalled = st.isFile();
|
|
371
|
+
hookExecutable = hookInstalled && (st.mode & 0o111) !== 0;
|
|
372
|
+
} catch {
|
|
373
|
+
// absent
|
|
374
|
+
}
|
|
375
|
+
let settingsWired = false;
|
|
376
|
+
try {
|
|
377
|
+
const raw = await readFile(settingsPath, 'utf8');
|
|
378
|
+
const settings = JSON.parse(raw);
|
|
379
|
+
const sessionStart = Array.isArray(settings?.hooks?.SessionStart) ? settings.hooks.SessionStart : [];
|
|
380
|
+
const commands = sessionStart.flatMap((entry) => (
|
|
381
|
+
Array.isArray(entry?.hooks) ? entry.hooks.map((h) => String(h?.command ?? '')) : []
|
|
382
|
+
));
|
|
383
|
+
const idx = commands.findIndex((cmd) => cmd.includes('project-important.sh'));
|
|
384
|
+
settingsWired = idx === 0;
|
|
385
|
+
} catch {
|
|
386
|
+
settingsWired = false;
|
|
387
|
+
}
|
|
388
|
+
const claude = {
|
|
389
|
+
installed: true,
|
|
390
|
+
runtimeModule,
|
|
391
|
+
hookInstalled,
|
|
392
|
+
hookExecutable,
|
|
393
|
+
settingsWired,
|
|
394
|
+
wired: runtimeModule && hookInstalled && hookExecutable && settingsWired,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// --- AGENTS.md owner-instructions pointer (shared Codex/OpenCode fallback) ---
|
|
398
|
+
let agentsFallback = false;
|
|
399
|
+
try {
|
|
400
|
+
const agents = await readFile(agentsPath, 'utf8');
|
|
401
|
+
agentsFallback = agents.includes('Project Owner Instructions')
|
|
402
|
+
&& agents.includes(PROJECT_IMPORTANT_FILENAME);
|
|
403
|
+
} catch {
|
|
404
|
+
agentsFallback = false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// --- omp bridge (only when omp installed/tracked) ---
|
|
408
|
+
const ompBridgeExists = await exists(ompBridgePath);
|
|
409
|
+
const ompInstalled = tracked('.omp/') || ompBridgeExists || await exists(path.join(projectRoot, '.omp', 'config.yml'));
|
|
410
|
+
let ompWired = false;
|
|
411
|
+
if (ompInstalled && ompBridgeExists) {
|
|
412
|
+
try {
|
|
413
|
+
const bridge = await readFile(ompBridgePath, 'utf8');
|
|
414
|
+
ompWired = /session_start\s*:\s*\[\s*['"]project-important\.sh/.test(bridge);
|
|
415
|
+
} catch {
|
|
416
|
+
ompWired = false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const omp = { installed: ompInstalled, wired: ompInstalled && ompWired };
|
|
420
|
+
|
|
421
|
+
// --- Codex fallback (only when adapter installed) ---
|
|
422
|
+
const codexInstalled = tracked('.codex/') || await exists(path.join(projectRoot, '.codex', 'settings.json'));
|
|
423
|
+
const codex = { installed: codexInstalled, wired: codexInstalled && agentsFallback };
|
|
424
|
+
|
|
425
|
+
// --- OpenCode fallback (only when opencode.json tracked/installed) ---
|
|
426
|
+
const opencodeInstalled = tracked('opencode.json') || await exists(path.join(projectRoot, 'opencode.json'));
|
|
427
|
+
const opencode = { installed: opencodeInstalled, wired: opencodeInstalled && agentsFallback };
|
|
428
|
+
|
|
429
|
+
return { claude, omp, codex, opencode, agentsFallback };
|
|
430
|
+
}
|