@ngockhoale/ukit 2.4.3 → 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.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * project-important.mjs (TASK-038)
3
+ *
4
+ * Installed-runtime mirror of src/core/projectImportant.js — inspector +
5
+ * deterministic envelope renderer for PROJECT_IMPORTANT.md per
6
+ * docs/PROJECT_IMPORTANT_SPEC.md §3, §4, §5.2, §6, §12.1.
7
+ *
8
+ * Deliberate mirror: the installed runtime cannot import package source, so
9
+ * this file keeps the same constants, state names, scanner semantics, and
10
+ * 6,000-code-point output as the source module; drift is locked out by
11
+ * tests/consistency/projectImportantRuntimeParity.test.js (spec §12.2).
12
+ *
13
+ * Guarantees:
14
+ * - Open no-follow → fstat → isFile → fd-read (no symlink resolution, no
15
+ * whole-file readFile on arbitrary sizes; stops after code point 6,001).
16
+ * - Strict UTF-8 decode (no U+FFFD injection); leading BOM excluded from
17
+ * count and body, source bytes never modified.
18
+ * - Code-point counting only — never String.length on owner text.
19
+ * - Byte-identical rendered output for identical input/config.
20
+ * - `body` exists only on the runtime-local return; never serialized into
21
+ * diagnostics.
22
+ * - Fail-open: no documented entry point throws; unexpected errors surface
23
+ * as the deterministic `unreadable` warning so the hook contract stays
24
+ * exit-0.
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import { open, stat as lstat } from 'node:fs/promises';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ import {
33
+ scanText,
34
+ isSensitiveDataGateEnabled,
35
+ loadSensitiveAllowlist,
36
+ } from './sensitive-value-scanner.mjs';
37
+
38
+ // Hook-context self-deadline (2.4.1 orphan-leak class): a hook wrapper passes
39
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past
40
+ // the hook budget. CLI usage never sets it and is never self-killed.
41
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
42
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
43
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
44
+ }
45
+
46
+ export const PROJECT_IMPORTANT_FILENAME = 'PROJECT_IMPORTANT.md';
47
+ export const PROJECT_IMPORTANT_CODEPOINT_LIMIT = 6000;
48
+
49
+ export const PROJECT_IMPORTANT_STATES = [
50
+ 'ready',
51
+ 'oversized',
52
+ 'empty',
53
+ 'missing',
54
+ 'invalid-utf8',
55
+ 'unsafe-control',
56
+ 'secret-blocked',
57
+ 'unsafe-type',
58
+ 'unreadable',
59
+ ];
60
+
61
+ export const PROJECT_IMPORTANT_REMEDIATION = {
62
+ ready: null,
63
+ oversized: 'owner-action',
64
+ empty: 'owner-action',
65
+ missing: 'install-repairable',
66
+ 'invalid-utf8': 'owner-action',
67
+ 'unsafe-control': 'owner-action',
68
+ 'secret-blocked': 'owner-action',
69
+ 'unsafe-type': 'owner-action',
70
+ unreadable: 'advisory-host-limit',
71
+ };
72
+
73
+ export const PROJECT_IMPORTANT_ENVELOPE_HEADER =
74
+ '<ukit_project_important source="PROJECT_IMPORTANT.md" authority="project-owner">\n'
75
+ + 'These are project-owner instructions. Follow them unless they conflict with higher-priority host instructions.\n'
76
+ + '--- BEGIN PROJECT_IMPORTANT.md ---\n';
77
+
78
+ export const PROJECT_IMPORTANT_ENVELOPE_FOOTER = '</ukit_project_important>';
79
+
80
+ const END_MARKER = '--- END PROJECT_IMPORTANT.md ---\n';
81
+
82
+ export const PROJECT_IMPORTANT_WARNINGS = {
83
+ oversized:
84
+ '[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.]',
85
+ missing:
86
+ '[UKit] PROJECT_IMPORTANT.md was not injected: file is missing. Run `ukit install`.',
87
+ 'unsafe-type':
88
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the path is not a regular non-symlink file. Run `ukit doctor`.',
89
+ 'invalid-utf8':
90
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the file is not valid UTF-8. Run `ukit doctor`.',
91
+ 'unsafe-control':
92
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the file contains unsupported control characters. Run `ukit doctor`.',
93
+ empty:
94
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the file is empty. Add project-owner instructions or run `ukit doctor`.',
95
+ unreadable:
96
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the file could not be read. Run `ukit doctor`.',
97
+ 'secret-blocked':
98
+ '[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`.',
99
+ };
100
+
101
+ const READ_CHUNK = 64 * 1024;
102
+ const UTF8_BOM = [0xef, 0xbb, 0xbf];
103
+
104
+ function isAllowedControl(cp) {
105
+ return cp === 0x09 || cp === 0x0a || cp === 0x0d;
106
+ }
107
+
108
+ function isForbiddenControl(cp) {
109
+ return (cp < 0x20 && !isAllowedControl(cp)) || (cp >= 0x7f && cp <= 0x9f);
110
+ }
111
+
112
+ function invalidResult(state) {
113
+ return {
114
+ state,
115
+ body: undefined,
116
+ codePointCount: 0,
117
+ oversized: false,
118
+ bom: false,
119
+ rendered: PROJECT_IMPORTANT_WARNINGS[state],
120
+ remediationClass: PROJECT_IMPORTANT_REMEDIATION[state],
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Bounded, strict inspector.
126
+ *
127
+ * @param {{ projectRoot: string, config?: object }} options
128
+ * @returns {Promise<{ state, body?, codePointCount, oversized, bom, rendered, remediationClass }>}
129
+ */
130
+ export async function inspectProjectImportant(options = {}) {
131
+ const { projectRoot, config } = options;
132
+ const target = path.join(projectRoot, PROJECT_IMPORTANT_FILENAME);
133
+
134
+ let handle;
135
+ try {
136
+ // O_NOFOLLOW where supported: symlinks fail open instead of resolving.
137
+ const flags = fs.constants.O_RDONLY
138
+ | (fs.constants.O_NOFOLLOW || 0)
139
+ | (fs.constants.O_NONBLOCK || 0);
140
+ handle = await open(target, flags);
141
+ } catch (err) {
142
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
143
+ return invalidResult('missing');
144
+ }
145
+ if (err && (err.code === 'ELOOP' || err.code === 'EMLINK')) {
146
+ return invalidResult('unsafe-type');
147
+ }
148
+ // Broken symlink surfaces as ENOENT on some platforms only via lstat —
149
+ // but O_NOFOLLOW gives ELOOP. ENOENT from a symlinked *path* means the
150
+ // link target is missing; check whether the entry itself is a symlink.
151
+ if (err && (err.code === 'EACCES' || err.code === 'EPERM')) {
152
+ return invalidResult('unreadable');
153
+ }
154
+ // Platforms without O_NOFOLLOW support, or odd errors: distinguish
155
+ // symlink/type via lstat before declaring unreadable.
156
+ try {
157
+ const lst = await lstat(target);
158
+ if (!lst.isFile()) return invalidResult('unsafe-type');
159
+ } catch {
160
+ return invalidResult('missing');
161
+ }
162
+ return invalidResult('unreadable');
163
+ }
164
+
165
+ try {
166
+ const fst = await handle.stat();
167
+ if (!fst.isFile()) return invalidResult('unsafe-type');
168
+
169
+ // Streamed, bounded strict decode: stop at code point 6,001.
170
+ const decoder = createStrictDecoder();
171
+ const buf = Buffer.allocUnsafe(READ_CHUNK);
172
+ const cps = [];
173
+ let bom = false;
174
+ let firstChunk = true;
175
+ let done = false;
176
+
177
+ while (!done) {
178
+ const { bytesRead } = await handle.read(buf, 0, READ_CHUNK, null);
179
+ let chunk = buf.subarray(0, bytesRead);
180
+ if (bytesRead === 0) {
181
+ decoder.flush(); // throws on truncated trailing sequence
182
+ done = true;
183
+ break;
184
+ }
185
+ if (firstChunk) {
186
+ firstChunk = false;
187
+ if (
188
+ bytesRead >= 3
189
+ && chunk[0] === UTF8_BOM[0]
190
+ && chunk[1] === UTF8_BOM[1]
191
+ && chunk[2] === UTF8_BOM[2]
192
+ ) {
193
+ bom = true;
194
+ chunk = chunk.subarray(3);
195
+ }
196
+ }
197
+ for (const cp of decoder.decode(chunk)) {
198
+ cps.push(cp);
199
+ if (cps.length >= PROJECT_IMPORTANT_CODEPOINT_LIMIT + 1) {
200
+ done = true;
201
+ break;
202
+ }
203
+ }
204
+ }
205
+
206
+ if (cps.length === 0) {
207
+ return { ...invalidResult('empty'), bom };
208
+ }
209
+
210
+ const oversized = cps.length > PROJECT_IMPORTANT_CODEPOINT_LIMIT;
211
+ const injected = oversized ? cps.slice(0, PROJECT_IMPORTANT_CODEPOINT_LIMIT) : cps;
212
+ for (const cp of injected) {
213
+ if (isForbiddenControl(cp)) {
214
+ return { ...invalidResult('unsafe-control'), bom, codePointCount: cps.length };
215
+ }
216
+ }
217
+
218
+ const body = String.fromCodePoint(...injected);
219
+ const codePointCount = cps.length;
220
+
221
+ // Secret gate on the body that would be injected.
222
+ const gateEnabled = isSensitiveDataGateEnabled(config);
223
+ const allowlistHashes = loadSensitiveAllowlist(config);
224
+ const scan = scanText(body, { allowlistHashes, gateEnabled });
225
+ if (scan.hasSecret) {
226
+ return {
227
+ ...invalidResult('secret-blocked'),
228
+ bom,
229
+ codePointCount,
230
+ oversized,
231
+ };
232
+ }
233
+
234
+ const inspection = {
235
+ state: oversized ? 'oversized' : 'ready',
236
+ body,
237
+ codePointCount,
238
+ oversized,
239
+ bom,
240
+ rendered: undefined,
241
+ remediationClass: PROJECT_IMPORTANT_REMEDIATION[oversized ? 'oversized' : 'ready'],
242
+ };
243
+ inspection.rendered = renderProjectImportant(inspection);
244
+ return inspection;
245
+ } catch (err) {
246
+ if (err && err.message === 'invalid-utf8') {
247
+ return invalidResult('invalid-utf8');
248
+ }
249
+ if (err && (err.code === 'EACCES' || err.code === 'EPERM')) {
250
+ return invalidResult('unreadable');
251
+ }
252
+ return invalidResult('unreadable');
253
+ } finally {
254
+ await handle.close().catch(() => {});
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Strict incremental UTF-8 decoder that rejects overlong, surrogate,
260
+ * >U+10FFFF and truncated sequences; throws Error('invalid-utf8').
261
+ */
262
+ function createStrictDecoder() {
263
+ let needed = 0;
264
+ let value = 0;
265
+ let min = 0;
266
+
267
+ function push(out, cp, seqMin) {
268
+ if (cp < seqMin || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) {
269
+ throw new Error('invalid-utf8');
270
+ }
271
+ out.push(cp);
272
+ }
273
+
274
+ return {
275
+ /** @param {Buffer|Uint8Array} chunk @returns {number[]} */
276
+ decode(chunk) {
277
+ const out = [];
278
+ for (let i = 0; i < chunk.length; i += 1) {
279
+ const b = chunk[i];
280
+ if (needed > 0) {
281
+ if (b < 0x80 || b > 0xbf) throw new Error('invalid-utf8');
282
+ value = (value << 6) | (b & 0x3f);
283
+ needed -= 1;
284
+ if (needed === 0) {
285
+ push(out, value, min);
286
+ }
287
+ continue;
288
+ }
289
+ if (b < 0x80) {
290
+ out.push(b);
291
+ } else if (b >= 0xc2 && b <= 0xdf) {
292
+ needed = 1; min = 0x80; value = b & 0x1f;
293
+ } else if (b >= 0xe0 && b <= 0xef) {
294
+ needed = 2; min = 0x800; value = b & 0x0f;
295
+ } else if (b >= 0xf0 && b <= 0xf4) {
296
+ needed = 3; min = 0x10000; value = b & 0x07;
297
+ } else {
298
+ throw new Error('invalid-utf8');
299
+ }
300
+ }
301
+ return out;
302
+ },
303
+ flush() {
304
+ if (needed > 0) throw new Error('invalid-utf8');
305
+ },
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Deterministic envelope renderer. Warning states render as the frozen
311
+ * warning literal only; ready/oversized render the full envelope.
312
+ * Fail-open: any internal render error degrades to the deterministic
313
+ * `unreadable` warning — no throw escapes this entry point.
314
+ *
315
+ * @param {{ state: string, body?: string }} inspection
316
+ * @returns {string}
317
+ */
318
+ export function renderProjectImportant(inspection) {
319
+ try {
320
+ const { state, body } = inspection || {};
321
+ if (state !== 'ready' && state !== 'oversized') {
322
+ return PROJECT_IMPORTANT_WARNINGS[state] || PROJECT_IMPORTANT_WARNINGS.unreadable;
323
+ }
324
+ const warning = state === 'oversized'
325
+ ? PROJECT_IMPORTANT_WARNINGS.oversized + '\n'
326
+ : '';
327
+ // Framing newline outside the body: body bytes are preserved verbatim,
328
+ // the END marker always starts on its own line.
329
+ const framing = body.endsWith('\n') ? '' : '\n';
330
+ return (
331
+ PROJECT_IMPORTANT_ENVELOPE_HEADER
332
+ + body
333
+ + framing
334
+ + END_MARKER
335
+ + warning
336
+ + PROJECT_IMPORTANT_ENVELOPE_FOOTER
337
+ );
338
+ } catch {
339
+ return PROJECT_IMPORTANT_WARNINGS.unreadable;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Convenience: inspect + ensure `rendered` is populated.
345
+ * Fail-open: an unexpected inspector/render error degrades to the
346
+ * deterministic `unreadable` result instead of throwing.
347
+ *
348
+ * @param {string} projectRoot
349
+ * @param {{ config?: object }} [options]
350
+ */
351
+ export async function renderProjectImportantForProject(projectRoot, options = {}) {
352
+ try {
353
+ const inspection = await inspectProjectImportant({ projectRoot, ...options });
354
+ if (inspection.rendered === undefined) {
355
+ inspection.rendered = renderProjectImportant(inspection);
356
+ }
357
+ return inspection;
358
+ } catch {
359
+ return invalidResult('unreadable');
360
+ }
361
+ }
362
+
363
+ // CLI entry (TASK-041, spec §9): `node project-important.mjs` prints the
364
+ // deterministic rendered envelope — or the frozen invalid-state warning — for
365
+ // the resolved project root. Advisory fail-open: any unexpected error degrades
366
+ // to the deterministic `unreadable` warning and the process still exits 0, so a
367
+ // SessionStart hook can never block the session on a renderer fault.
368
+ if (
369
+ process.argv[1]
370
+ && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
371
+ ) {
372
+ try {
373
+ const projectRoot = process.env.UKIT_PROJECT_ROOT
374
+ || process.env.CLAUDE_PROJECT_DIR
375
+ || process.cwd();
376
+ const inspection = await renderProjectImportantForProject(projectRoot);
377
+ process.stdout.write(`${inspection.rendered ?? PROJECT_IMPORTANT_WARNINGS.unreadable}\n`);
378
+ } catch {
379
+ process.stdout.write(`${PROJECT_IMPORTANT_WARNINGS.unreadable}\n`);
380
+ }
381
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * sensitive-value-scanner.mjs (TASK-038)
3
+ *
4
+ * Installed-runtime mirror of src/core/sensitiveValueScanner.js — the
5
+ * high-confidence secret *value* scanner shared with
6
+ * templates/.claude/hooks/sensitive-data-guard.sh. Deliberate mirror: the
7
+ * installed runtime cannot import package source, so this file is kept
8
+ * byte-semantic with the source module and locked by
9
+ * tests/consistency/projectImportantRuntimeParity.test.js.
10
+ *
11
+ * Scope is the value scanner only: vendor/API-key patterns, JWT, private-key
12
+ * block, SHA-256 exact-value allowlist matching, and the
13
+ * security.sensitiveDataGate config read. File/path classification and Bash
14
+ * command-shape logic stay in the hook.
15
+ *
16
+ * Leak-safety contract: return values carry labels only — never a secret
17
+ * value, prefix, suffix, excerpt, or hash of a matched secret.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto';
21
+
22
+ // Hook-context self-deadline (2.4.1 orphan-leak class): a hook wrapper passes
23
+ // UKIT_HOOK_DEADLINE_MS so a wedged scan can never orphan this process past
24
+ // the hook budget. CLI usage never sets it and is never self-killed.
25
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
26
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
27
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
28
+ }
29
+
30
+ function sha256(value) {
31
+ return createHash('sha256').update(value).digest('hex');
32
+ }
33
+
34
+ // --- high-confidence secret value patterns ---
35
+ // Semantically identical to TOKEN_PATTERNS in sensitive-data-guard.sh.
36
+ // AKIA/ASIA/AIza prefixes are pure base64-compatible text, so an encoded blob
37
+ // can contain a coincidental key-shaped substring — those rules must stand
38
+ // alone with no base64/base64url character on either side.
39
+ const TOKEN_PATTERNS = [
40
+ { label: 'OpenAI/Anthropic-style API key', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
41
+ { label: 'AWS access key id', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
42
+ { label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
43
+ { label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
44
+ { label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
45
+ { label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
46
+ { label: 'Google API key', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
47
+ { label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
48
+ { label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
49
+ { label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
50
+ ];
51
+
52
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/i;
53
+
54
+ /**
55
+ * Scan text for high-confidence secret values.
56
+ *
57
+ * @param {string} text
58
+ * @param {{ allowlistHashes?: string[], gateEnabled?: boolean }} [options]
59
+ * allowlistHashes: SHA-256 hex digests of explicitly approved exact values.
60
+ * gateEnabled: pass isSensitiveDataGateEnabled(config); when false the scan
61
+ * reports the gate's exit-0 semantics (no detection) like today's hook.
62
+ * @returns {{ hasSecret: boolean, allowed: boolean, labels: string[] }}
63
+ * labels only — never values, excerpts, or hashes.
64
+ */
65
+ export function scanText(text, options = {}) {
66
+ const { allowlistHashes = [], gateEnabled = true } = options || {};
67
+ const clean = { hasSecret: false, allowed: false, labels: [] };
68
+ if (gateEnabled === false) return clean;
69
+ if (typeof text !== 'string' || text.length === 0) return clean;
70
+
71
+ const allowed = new Set(
72
+ Array.isArray(allowlistHashes) ? allowlistHashes.filter((h) => typeof h === 'string') : [],
73
+ );
74
+
75
+ const labels = new Set();
76
+ let sawAllowlisted = false;
77
+ for (const { label, re } of TOKEN_PATTERNS) {
78
+ re.lastIndex = 0;
79
+ let match;
80
+ while ((match = re.exec(text)) !== null) {
81
+ if (allowed.has(sha256(match[0]))) {
82
+ sawAllowlisted = true;
83
+ continue;
84
+ }
85
+ labels.add(label);
86
+ }
87
+ }
88
+
89
+ if (labels.size === 0) {
90
+ return { hasSecret: false, allowed: sawAllowlisted, labels: [] };
91
+ }
92
+ return { hasSecret: true, allowed: false, labels: [...labels] };
93
+ }
94
+
95
+ /**
96
+ * Gate toggle: enabled unless security.sensitiveDataGate === false
97
+ * (mirrors the hook's explicit-false check on .ukit/storage/config.json).
98
+ *
99
+ * @param {object|null|undefined} config injected config object
100
+ * @returns {boolean}
101
+ */
102
+ export function isSensitiveDataGateEnabled(config) {
103
+ if (config && config.security && config.security.sensitiveDataGate === false) {
104
+ return false;
105
+ }
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * Load the SHA-256 exact-value allowlist from an injected config object.
111
+ * Accepts security.allowlist.values (mirror of allowlist.json) or
112
+ * security.sensitiveDataAllowlist; keeps only well-formed 64-hex digests.
113
+ *
114
+ * @param {object|null|undefined} config
115
+ * @returns {string[]} SHA-256 hex digests
116
+ */
117
+ export function loadSensitiveAllowlist(config) {
118
+ if (!config || typeof config !== 'object') return [];
119
+ const security = config.security && typeof config.security === 'object' ? config.security : {};
120
+ const candidates = [];
121
+ if (security.allowlist && Array.isArray(security.allowlist.values)) {
122
+ candidates.push(...security.allowlist.values);
123
+ }
124
+ if (Array.isArray(security.sensitiveDataAllowlist)) {
125
+ candidates.push(...security.sensitiveDataAllowlist);
126
+ }
127
+ return candidates.filter((v) => typeof v === 'string' && SHA256_HEX_RE.test(v));
128
+ }
@@ -56,7 +56,7 @@ export const HOOK_EVENT_MAP = {
56
56
  },
57
57
  before_agent_start: ['sensitive-data-guard.sh', 'skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
58
58
  'session.compacting': ['reinject-context.sh'],
59
- session_start: ['auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
59
+ session_start: ['project-important.sh', 'auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
60
60
  };
61
61
 
62
62
  const TOOL_NAME_MAP = {
@@ -111,6 +111,7 @@ export const ADVISORY_SCRIPTS = new Set([
111
111
  'task-watchdog.sh',
112
112
  'compress-output.sh',
113
113
  'reinject-context.sh',
114
+ 'project-important.sh',
114
115
  'auto-prune-bash.sh',
115
116
  'reset-compact-pressure.sh',
116
117
  'handoff-resume.sh',
@@ -703,10 +704,12 @@ export async function runSessionCompact(pi, event, { projectRoot, context: exten
703
704
  // session_compact fires only after agent.replaceMessages()/rebaseAfterCompaction() already
704
705
  // ran (session-maintenance.ts), i.e. the session is idle here, and agent-session.ts's
705
706
  // sendCustomMessage() non-streaming branch appends the message to the live context
706
- // regardless of deliverAs — so 'steer' is accepted right after a compaction. Not a live
707
- // repro, so the 'nextTurn' copy below stays as defense in depth.
707
+ // regardless of deliverAs — so 'steer' is accepted right after a compaction.
708
+ // TASK-042: delivery is exact-once. The former defense-in-depth 'nextTurn' copy produced
709
+ // a second model-visible duplicate of the same context; per spec §10/§17 Phase 5 keep
710
+ // 'steer' only (switch to 'nextTurn' ONLY if a live omp smoke proves 'steer' is not
711
+ // retained after compact — never both).
708
712
  sendContext(pi, result.context, 'steer');
709
- sendContext(pi, result.context, 'nextTurn');
710
713
  return undefined;
711
714
  }
712
715
 
@@ -238,6 +238,14 @@ At the start of every OpenCode session, before working on the first task:
238
238
  4. If the route result points to a skill, read that SKILL.md before acting — do not skip this step.
239
239
  5. If `.ukit/storage/config.json` has `router.enabled: true`, prefer the router output over ad-hoc guessing.
240
240
 
241
+ ## Project Owner Instructions — Codex and OpenCode
242
+
243
+ When running in Codex or OpenCode, read and follow the root
244
+ `PROJECT_IMPORTANT.md` before doing project work. It is the canonical
245
+ project-owner instruction source. Do not copy its contents into this file.
246
+ If it is missing or unreadable, state that limitation and continue with the
247
+ remaining project instructions.
248
+
241
249
  ## Skills
242
250
 
243
251
  - Canonical skills live in `.claude/skills/`.
@@ -0,0 +1,9 @@
1
+ # Project Important Instructions
2
+
3
+ <!--
4
+ Add project-specific, non-negotiable AI instructions here.
5
+
6
+ UKit creates this file only when it is missing. After creation, UKit never rewrites,
7
+ merges, formats, chmods, or deletes it. Keep it at or below 6,000 Unicode code
8
+ points for complete runtime injection. Do not put credentials or secrets here.
9
+ -->