@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -0
  3. package/manifests/platform.full.yaml +51 -112
  4. package/package.json +2 -1
  5. package/scripts/index/refresh-index.mjs +47 -22
  6. package/src/cli/commands/doctor.js +132 -2
  7. package/src/cli/commands/uninstall.js +18 -0
  8. package/src/core/applyPlan.js +17 -2
  9. package/src/core/compact/threshold.js +36 -6
  10. package/src/core/diffPlan.js +35 -0
  11. package/src/core/fileOps.js +26 -0
  12. package/src/core/projectImportant.js +430 -0
  13. package/src/core/sensitiveValueScanner.js +118 -0
  14. package/src/core/status.js +55 -1
  15. package/src/core/uninstall.js +183 -3
  16. package/src/diagnostics/classifyHang.js +246 -0
  17. package/src/index/buildIndex.js +1033 -62
  18. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  19. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  20. package/templates/.claude/hooks/completion-gate.sh +51 -10
  21. package/templates/.claude/hooks/compress-output.sh +38 -6
  22. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  23. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  24. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  25. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  26. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  27. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  28. package/templates/.claude/hooks/project-important.sh +67 -0
  29. package/templates/.claude/hooks/protect-files.sh +31 -5
  30. package/templates/.claude/hooks/record-execution.sh +31 -5
  31. package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
  32. package/templates/.claude/hooks/skill-router.sh +31 -5
  33. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  34. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  35. package/templates/.claude/hooks/verification-guard.sh +107 -112
  36. package/templates/.claude/hooks/vision-router.sh +49 -13
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  39. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  40. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  41. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  42. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  43. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  44. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  45. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  46. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  47. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  48. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  49. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  51. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  52. package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
  53. package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
  54. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  55. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  56. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  57. package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
  58. package/templates/AGENTS.md +8 -0
  59. package/templates/PROJECT_IMPORTANT.md +9 -0
@@ -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
- await writeFileAtomic(entry.targetPath, nextContent);
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 softThreshold = Math.max(
441
- 1,
442
- finiteNumber(config?.compact?.tokenThreshold, loadShippedCompactBudget().tokenThreshold),
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
 
@@ -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',
@@ -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
+ }