@haposoft/cafekit 0.18.1 → 0.18.2
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/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +774 -6
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +192 -59
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -52
- package/bin/phases/copy-payload.js +1 -3
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +43 -59
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +8 -1
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/src/codex/rules/state-sync.md +0 -60
package/bin/lib/codex-install.js
CHANGED
|
@@ -8,6 +8,22 @@ const { preserveAddressingSection } = require('./instruction-blocks');
|
|
|
8
8
|
const CODEX_BLOCK_START = '<!-- CAFEKIT CODEX START -->';
|
|
9
9
|
const CODEX_BLOCK_END = '<!-- CAFEKIT CODEX END -->';
|
|
10
10
|
const INSTRUCTION_EXTENSIONS = new Set(['.md', '.mdx', '.txt']);
|
|
11
|
+
const CODEX_ASK_DESCRIPTION_BOUNDARY = 'A request to fix or change something is still answered without editing any file; point the user to $cf-fix.';
|
|
12
|
+
const CODEX_ASK_NOTE = 'Rule 1 (Codex): never edit, create, or delete files in this skill, '
|
|
13
|
+
+ 'even when the user says fix it or sửa giúp luôn. Do not run commands that write files. '
|
|
14
|
+
+ 'Answer the cause with evidence and tell the user to run $cf-fix.';
|
|
15
|
+
const CODEX_RESEARCH_DEPTH_MARKER = 'Codex research depth: treat a settled repository decision that remains in force as a known option.';
|
|
16
|
+
const CODEX_RESEARCH_DEPTH_NOTE = CODEX_RESEARCH_DEPTH_MARKER
|
|
17
|
+
+ ' Use Quick for that decision. An outdated supporting rationale alone does not raise depth '
|
|
18
|
+
+ 'unless correcting it changes the recommendation. Conflicting evidence that changes the '
|
|
19
|
+
+ 'recommendation still requires escalation under the depth table.';
|
|
20
|
+
const CODEX_SPECS_SCOPE_MARKER = 'Codex scope gate: put the GATE-SCOPE question in the final answer of the turn.';
|
|
21
|
+
const CODEX_SPECS_SCOPE_NOTE = CODEX_SPECS_SCOPE_MARKER
|
|
22
|
+
+ ' Ask it there, not in commentary or through `request_user_input`. Do not send a summary after the question. '
|
|
23
|
+
+ 'For each item needing a decision, use `[NEEDS CLARIFICATION: <question>]`, replacing <question> '
|
|
24
|
+
+ 'with the real question, not a placeholder or a quotation of this note. Offer EXPAND, KEEP, and CUT together. '
|
|
25
|
+
+ 'Preserve the exact words NEEDS CLARIFICATION, EXPAND, KEEP, and CUT; do not translate them. '
|
|
26
|
+
+ 'Wait for the user to decide; do not choose scope or implement code on their behalf.';
|
|
11
27
|
|
|
12
28
|
const AGENT_NAMES = [
|
|
13
29
|
'brainstormer',
|
|
@@ -200,11 +216,15 @@ function normalizeAskUserQuestion(content) {
|
|
|
200
216
|
const oneSeparator = '(?:[^\\S\\r\\n]+|[^\\S\\r\\n]*\\r?\\n[^\\S\\r\\n]*)';
|
|
201
217
|
const pattern = new RegExp(
|
|
202
218
|
`(?:(\\b(?:${determinerPattern}|[0-9]+)\\b)(${oneSeparator}))?`
|
|
203
|
-
+ `(\\bAskUserQuestion\\b|${masked.askMarkerPattern})`,
|
|
219
|
+
+ `(\\bAskUserQuestion\\b|${masked.askMarkerPattern}|\\bnative(?:\\s+structured user-input|\\s+input)\\s+tool\\b)`,
|
|
204
220
|
'g'
|
|
205
221
|
);
|
|
206
|
-
const
|
|
207
|
-
|
|
222
|
+
const withoutRuntimePossessive = masked.content.replace(
|
|
223
|
+
/\bthe runtime['’]s\s+native(?:\s+structured user-input|\s+input)\s+tool\b/g,
|
|
224
|
+
"a question in the turn's final answer"
|
|
225
|
+
);
|
|
226
|
+
const normalized = withoutRuntimePossessive.replace(pattern, (match, determiner, spacing, _token, offset, source) => {
|
|
227
|
+
const literalIndex = match.search(/AskUserQuestion|\bnative\b/);
|
|
208
228
|
const tokenOffset = offset + (literalIndex >= 0
|
|
209
229
|
? literalIndex
|
|
210
230
|
: match.indexOf(masked.askMarkerPrefix));
|
|
@@ -219,18 +239,33 @@ function normalizeAskUserQuestion(content) {
|
|
|
219
239
|
const normalizedDeterminer = /^an$/i.test(determiner)
|
|
220
240
|
? (determiner[0] === 'A' ? 'A' : 'a')
|
|
221
241
|
: determiner;
|
|
222
|
-
return `${normalizedDeterminer}${spacing}
|
|
242
|
+
return `${normalizedDeterminer}${spacing}question in the turn's final answer`;
|
|
223
243
|
}
|
|
224
244
|
|
|
225
245
|
const trailing = source.slice(offset + match.length);
|
|
226
246
|
const pluralContext = new RegExp(`^${oneSeparator}(?:calls|batches)\\b`, 'i').test(trailing);
|
|
227
|
-
return `${pluralContext ? '' : 'a '}
|
|
247
|
+
return `${pluralContext ? '' : 'a '}question in the turn's final answer`;
|
|
228
248
|
});
|
|
229
249
|
return masked.restore(normalized);
|
|
230
250
|
}
|
|
231
251
|
|
|
232
|
-
function normalizeRuntimePaths(content) {
|
|
233
|
-
|
|
252
|
+
function normalizeRuntimePaths(content, sourcePath = '') {
|
|
253
|
+
const stateRootsPattern = /const RUNTIME_STATE_ROOTS = Object\.freeze\(\[[\s\S]*?\]\);/;
|
|
254
|
+
const sharedStateRoots = path.basename(sourcePath) === 'provenance.cjs'
|
|
255
|
+
? content.match(stateRootsPattern)?.[0] : null;
|
|
256
|
+
const preservedRoots = [];
|
|
257
|
+
let markerPrefix = '\uE000CAFEKIT_HOST_';
|
|
258
|
+
while (content.includes(markerPrefix)) markerPrefix += '_';
|
|
259
|
+
const rootToken = /(?<![\w/\\])\.claude(?:[/\\])?(?![\w/\\.-])/g;
|
|
260
|
+
const instruction = isInstructionAsset(sourcePath) ? content.replace(/^.*$/gm, line => {
|
|
261
|
+
if (!/(?<![\w/\\])\.codex(?:[/\\])?(?![\w/\\.-])/.test(line)) return line;
|
|
262
|
+
return line.replace(rootToken, original => {
|
|
263
|
+
const marker = `${markerPrefix}${preservedRoots.length}\uE001`;
|
|
264
|
+
preservedRoots.push([marker, original]);
|
|
265
|
+
return marker;
|
|
266
|
+
});
|
|
267
|
+
}) : content;
|
|
268
|
+
let next = normalizeSourcePaths(instruction, {
|
|
234
269
|
runtimeRoot: '.codex',
|
|
235
270
|
skillsRoot: '.agents/skills'
|
|
236
271
|
})
|
|
@@ -239,6 +274,15 @@ function normalizeRuntimePaths(content) {
|
|
|
239
274
|
.replace(/(?<!~\/)\.claude(?=[/\\])/g, '.codex')
|
|
240
275
|
.replace(/(['"])\.claude\1/g, '$1.codex$1')
|
|
241
276
|
.replace(/`\.claude`/g, '`.codex`');
|
|
277
|
+
// This table describes every runtime's generated state, not the install root.
|
|
278
|
+
if (sharedStateRoots) next = next.replace(stateRootsPattern, () => sharedStateRoots);
|
|
279
|
+
if (/\/ai-multimodal\/scripts\/(?:media_optimizer|document_converter)\.py$/.test(sourcePath.replace(/\\/g, '/'))) {
|
|
280
|
+
// Shared skills live under .agents; native support/config still lives in .codex.
|
|
281
|
+
next = next.replace(/\bclaude_dir = skills_dir\.parent\b/g,
|
|
282
|
+
"claude_dir = skills_dir.parent.parent / '.codex'");
|
|
283
|
+
}
|
|
284
|
+
for (const [marker, original] of preservedRoots) next = next.split(marker).join(original);
|
|
285
|
+
return next;
|
|
242
286
|
}
|
|
243
287
|
|
|
244
288
|
function normalizeSkillNames(content) {
|
|
@@ -262,6 +306,7 @@ function normalizeAgentNames(content) {
|
|
|
262
306
|
|
|
263
307
|
function normalizeAgentInvocations(content) {
|
|
264
308
|
return content
|
|
309
|
+
.replace(/\bTask\s*\(\s*Explore\s*\)/g, 'spawn_agent(agent_type="explorer", fork_turns="none", task_name="explore", message="Describe the scan")')
|
|
265
310
|
.replace(/\bAgent\s*\(/g, 'spawn_agent(')
|
|
266
311
|
.replace(/\bsubagent_type\s*=\s*"([^"]+)"/g, (_match, name) => (
|
|
267
312
|
`agent_type="${normalizeAgentRole(name)}", fork_turns="none"`
|
|
@@ -286,15 +331,60 @@ function isInstructionAsset(sourcePath) {
|
|
|
286
331
|
}
|
|
287
332
|
|
|
288
333
|
function normalizeCodexBody(content, sourcePath = '') {
|
|
289
|
-
let next = normalizeRuntimePaths(String(content));
|
|
334
|
+
let next = normalizeRuntimePaths(String(content), sourcePath);
|
|
290
335
|
next = next
|
|
291
|
-
.replace(/\bCLAUDE\.md\b/g,
|
|
336
|
+
.replace(/^.*$/gm, line => line.replace(/\bCLAUDE\.md\b/g, (name, offset) => (
|
|
337
|
+
isInstructionAsset(sourcePath) && /(?<![\w/\\])AGENTS\.md\b/.test(line)
|
|
338
|
+
&& !/[\w/\\]/.test(line[offset - 1] || '') ? name : 'AGENTS.md'
|
|
339
|
+
)))
|
|
292
340
|
.replace(/\bClaude Code\b/g, 'Codex CLI');
|
|
293
341
|
|
|
294
342
|
if (!isInstructionAsset(sourcePath)) return next;
|
|
295
343
|
|
|
344
|
+
if (/(?:^|\/)rules\/orchestrator\.md$/.test(String(sourcePath).replace(/\\/g, '/'))) {
|
|
345
|
+
next = next
|
|
346
|
+
.replace('When the current session or a subagent runs on a model below `fable` (e.g. `opus`, `sonnet`, `haiku`) and hits a hard problem',
|
|
347
|
+
'When the current session or a subagent hits a hard problem')
|
|
348
|
+
.replace('It runs autonomously on the strongest available model',
|
|
349
|
+
'It runs autonomously with high reasoning effort on the session model unless configuration selects another');
|
|
350
|
+
}
|
|
351
|
+
|
|
296
352
|
next = normalizeAgentInvocations(normalizeAgentNames(normalizeSkillNames(next)));
|
|
297
|
-
|
|
353
|
+
next = next.replace(/`?CLAUDE_CODE_SUBAGENT_MODEL`?/g,
|
|
354
|
+
'the configured agent model (`model` in `.codex/agents/<agent>.toml`, or `[agents].default_subagent_model` in native Codex configuration)');
|
|
355
|
+
next = applyReplacements(normalizeAskUserQuestion(next), INSTRUCTION_REPLACEMENTS);
|
|
356
|
+
const { frontmatter, body } = splitFrontmatter(next);
|
|
357
|
+
if (frontmatter.when_to_use && frontmatter.description) {
|
|
358
|
+
const description = `${frontmatter.description} Use when: ${frontmatter.when_to_use}`;
|
|
359
|
+
if (description.length <= 1024) {
|
|
360
|
+
const header = next.slice(0, next.length - body.length)
|
|
361
|
+
.replace(/^description:[^\r\n]*(?:\r?\n[ \t]+[^\r\n]*)*/m,
|
|
362
|
+
() => `description: ${JSON.stringify(description)}`);
|
|
363
|
+
next = header + body;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Only the installed ask skill receives this Codex-specific boundary, never eval prompts.
|
|
367
|
+
if (/(?:^|\/)skills\/ask\/SKILL\.md$/.test(String(sourcePath).replace(/\\/g, '/'))) {
|
|
368
|
+
const { frontmatter, body } = splitFrontmatter(next);
|
|
369
|
+
const description = `${frontmatter.description} ${CODEX_ASK_DESCRIPTION_BOUNDARY}`;
|
|
370
|
+
if (description.length > 1024) throw new Error('Codex ask description exceeds 1024 characters');
|
|
371
|
+
const header = next.slice(0, next.length - body.length)
|
|
372
|
+
.replace(/^description:[^\r\n]*$/m, () => `description: ${JSON.stringify(description)}`);
|
|
373
|
+
next = `${header}\n${CODEX_ASK_NOTE}\n\n${body}`;
|
|
374
|
+
}
|
|
375
|
+
// Only the installed research skill receives this note, never prompts or templates.
|
|
376
|
+
if (/(?:^|\/)skills\/research\/SKILL\.md$/.test(String(sourcePath).replace(/\\/g, '/'))) {
|
|
377
|
+
const { body } = splitFrontmatter(next);
|
|
378
|
+
const header = next.slice(0, next.length - body.length);
|
|
379
|
+
next = `${header}\n${CODEX_RESEARCH_DEPTH_NOTE}\n\n${body}`;
|
|
380
|
+
}
|
|
381
|
+
// Only the installed specs skill receives this note, never prompts or templates.
|
|
382
|
+
if (/(?:^|\/)skills\/specs\/SKILL\.md$/.test(String(sourcePath).replace(/\\/g, '/')) && !next.includes(CODEX_SPECS_SCOPE_NOTE)) {
|
|
383
|
+
const { body } = splitFrontmatter(next);
|
|
384
|
+
const header = next.slice(0, next.length - body.length);
|
|
385
|
+
next = `${header}\n${CODEX_SPECS_SCOPE_NOTE}\n\n${body}`;
|
|
386
|
+
}
|
|
387
|
+
return next;
|
|
298
388
|
}
|
|
299
389
|
|
|
300
390
|
function getCodexCopyOptions(baseOptions = {}) {
|
|
@@ -307,14 +397,42 @@ function getCodexCopyOptions(baseOptions = {}) {
|
|
|
307
397
|
function convertCodexAgentContent(content, fileName = '') {
|
|
308
398
|
const { frontmatter, body } = splitFrontmatter(content);
|
|
309
399
|
const name = codexAgentName(frontmatter.name || fileName);
|
|
310
|
-
|
|
400
|
+
let description = normalizeCodexBody(
|
|
311
401
|
frontmatter.description || `CafeKit Codex agent: ${name}`
|
|
312
402
|
);
|
|
313
|
-
|
|
403
|
+
let agentBody = body;
|
|
404
|
+
if (['git_ops', 'project_manager'].includes(name)) {
|
|
405
|
+
let inTeamSection = false;
|
|
406
|
+
agentBody = body.split('\n').flatMap(line => {
|
|
407
|
+
if (/^## /.test(line)) {
|
|
408
|
+
inTeamSection = /^## (?:Team Mode|Collaborative Interlocking \(Swarm Protocol\))\s*$/.test(line);
|
|
409
|
+
}
|
|
410
|
+
if (!inTeamSection) return [line];
|
|
411
|
+
if (/\b(?:TaskList|TaskGet|TaskUpdate|SendMessage|shutdown_request)\b|When instantiated as a team member|When triggered as an active teammate/.test(line)) return [];
|
|
412
|
+
return [line.replace(/^(\s*)\d+\. /, '$1- ')];
|
|
413
|
+
}).join('\n');
|
|
414
|
+
}
|
|
415
|
+
let instructions = normalizeCodexBody(agentBody).trim();
|
|
416
|
+
const readOnly = ['brainstormer', 'code_auditor', 'inspector', 'researcher'].includes(name);
|
|
417
|
+
if (readOnly) {
|
|
418
|
+
instructions += '\n\n## Read-only role\n\nRead-only role: never create, never edit, and never delete files, and never run commands that write to disk; return findings in your reply.';
|
|
419
|
+
}
|
|
420
|
+
const reasoning = [];
|
|
421
|
+
if (name === 'strategist') {
|
|
422
|
+
description = 'Autonomous advisory counsel with high reasoning effort; inherits the session model unless native spawn or agent defaults select another. No session model switch or user interview. Mention @strategist or spawn it for hard design, debugging, or trade-off calls. Advisory-only; returns advice, not code.';
|
|
423
|
+
instructions = instructions
|
|
424
|
+
.replace('consulted for counsel and running on the strongest available model',
|
|
425
|
+
'consulted for counsel with high reasoning effort on the configured native model')
|
|
426
|
+
.replace(/## Runtime note\s+[\s\S]*$/,
|
|
427
|
+
'## Runtime note\n\nThis agent sets `model_reasoning_effort = "high"` and omits `model`. It inherits the session model unless an explicit native spawn or `[agents].default_subagent_model` selects another. No strongest-model selection is guaranteed. For deployment control, set `model` in `.codex/agents/strategist.toml` or configure native agent defaults in native Codex configuration; choose a model that supports high reasoning effort. Advice remains advisory-only and never counts as execution proof.');
|
|
428
|
+
reasoning.push('model_reasoning_effort = "high"');
|
|
429
|
+
}
|
|
314
430
|
|
|
315
431
|
return [
|
|
316
432
|
`name = ${JSON.stringify(name)}`,
|
|
317
433
|
`description = ${JSON.stringify(description)}`,
|
|
434
|
+
...(readOnly ? ['sandbox_mode = "read-only"'] : []),
|
|
435
|
+
...reasoning,
|
|
318
436
|
`developer_instructions = ${JSON.stringify(instructions)}`,
|
|
319
437
|
''
|
|
320
438
|
].join('\n');
|
|
@@ -365,6 +483,10 @@ function transformManagedCodexContent(content, transform) {
|
|
|
365
483
|
}
|
|
366
484
|
|
|
367
485
|
module.exports = {
|
|
486
|
+
CODEX_RESEARCH_DEPTH_MARKER,
|
|
487
|
+
CODEX_RESEARCH_DEPTH_NOTE,
|
|
488
|
+
CODEX_SPECS_SCOPE_MARKER,
|
|
489
|
+
CODEX_SPECS_SCOPE_NOTE,
|
|
368
490
|
normalizeCodexBody,
|
|
369
491
|
getCodexCopyOptions,
|
|
370
492
|
codexAgentName,
|
package/bin/lib/context.js
CHANGED
|
@@ -128,7 +128,7 @@ const PLATFORMS = {
|
|
|
128
128
|
detectFiles: ['.omp'],
|
|
129
129
|
commandsDir: null,
|
|
130
130
|
// omp discovers .claude/skills and .agents/skills itself, so CafeKit copies
|
|
131
|
-
// no skill payload for it. See specs/omp-runtime-support/plan.md.
|
|
131
|
+
// no skill payload for it. See specs/archive/omp-runtime-support/plan.md.
|
|
132
132
|
skillsDir: '.agents/skills',
|
|
133
133
|
agentsDir: null,
|
|
134
134
|
skillsRef: '.agents/skills',
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
getCopyOptions
|
|
17
17
|
} = require('../lib/context');
|
|
18
18
|
const { sha256 } = require('../lib/manifest');
|
|
19
|
+
const { assertNoSymlinkPath } = require('../lib/path-safety');
|
|
19
20
|
const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
|
|
20
21
|
const { report, treeAction } = require('./report');
|
|
21
22
|
const {
|
|
@@ -164,30 +165,84 @@ function copyClaudeRuntimeFiles(ctx, platformKey) {
|
|
|
164
165
|
});
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
/**
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
168
|
+
/** Remove only obsolete runtime bytes proven unchanged by the ownership manifest. */
|
|
169
|
+
function removeObsoleteRuntimeFiles(ctx, platformKey) {
|
|
170
|
+
const completed = (ctx.obsoleteRuntimeCleanedPlatforms ||= new Set());
|
|
171
|
+
if (completed.has(platformKey)) return;
|
|
172
|
+
completed.add(platformKey);
|
|
173
|
+
const preserved = new Set();
|
|
174
|
+
for (const key of [platformKey]) {
|
|
175
|
+
const platform = PLATFORMS[key];
|
|
176
|
+
const tracker = ctx.trackers?.[key];
|
|
177
|
+
const ownership = ctx.ownership?.[platform.folder] || { files: {} };
|
|
178
|
+
const obsoleteFiles = (ctx.manifest?.obsolete?.runtimeFiles || [])
|
|
179
|
+
.filter(relative => key === 'claude' || /^(?:hooks|scripts|skills)\//.test(relative));
|
|
180
|
+
for (const relative of obsoleteFiles) {
|
|
181
|
+
const targetPath = key === 'codex' && relative.startsWith('skills/')
|
|
182
|
+
? path.join(platform.skillsDir, relative.slice('skills/'.length))
|
|
183
|
+
: path.join(platform.folder, relative);
|
|
184
|
+
let stat;
|
|
185
|
+
try {
|
|
186
|
+
assertNoSymlinkPath(targetPath);
|
|
187
|
+
stat = fs.statSync(targetPath);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error.code === 'ENOENT') {
|
|
190
|
+
if (key === 'codex' && tracker && !ctx.dryRun) tracker.prune(tracker.keyFor(targetPath));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
preserved.add(targetPath);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
if (!tracker) throw new Error('Missing ownership tracker');
|
|
198
|
+
const trackedKeys = [];
|
|
199
|
+
const inspect = file => {
|
|
200
|
+
assertNoSymlinkPath(file);
|
|
201
|
+
const current = fs.statSync(file);
|
|
202
|
+
if (current.isDirectory()) {
|
|
203
|
+
for (const name of fs.readdirSync(file)) inspect(path.join(file, name));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!current.isFile()) throw new Error('Unsupported obsolete file type');
|
|
207
|
+
const trackedKey = tracker.keyFor(file);
|
|
208
|
+
const recordedHash = ownership.files?.[trackedKey]?.sha256;
|
|
209
|
+
if (!recordedHash || recordedHash !== sha256(fs.readFileSync(file))) {
|
|
210
|
+
throw new Error('Obsolete file is not proven unchanged');
|
|
211
|
+
}
|
|
212
|
+
trackedKeys.push(trackedKey);
|
|
213
|
+
};
|
|
214
|
+
inspect(targetPath);
|
|
215
|
+
assertNoSymlinkPath(targetPath);
|
|
216
|
+
const targetKey = tracker.keyFor(targetPath);
|
|
217
|
+
if (!ctx.dryRun) {
|
|
218
|
+
fs.rmSync(targetPath, { force: true, recursive: stat.isDirectory() });
|
|
219
|
+
for (const trackedKey of trackedKeys) tracker.prune(trackedKey);
|
|
220
|
+
if (stat.isDirectory()) {
|
|
221
|
+
tracker.prune(targetKey);
|
|
222
|
+
tracker.prunePrefix(`${targetKey}/`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete ${key} runtime: ${relative}`);
|
|
226
|
+
ctx.results.updated++;
|
|
227
|
+
} catch {
|
|
228
|
+
preserved.add(targetPath);
|
|
186
229
|
}
|
|
187
230
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
231
|
+
}
|
|
232
|
+
if (preserved.size) {
|
|
233
|
+
ctx.results.preserved = (ctx.results.preserved || 0) + preserved.size;
|
|
234
|
+
(ctx.results.preservedFiles ||= []).push(...preserved);
|
|
235
|
+
}
|
|
236
|
+
const allPreserved = (ctx.obsoleteRuntimePreserved ||= new Set());
|
|
237
|
+
for (const target of preserved) allPreserved.add(target);
|
|
238
|
+
const selected = (ctx.platforms || [platformKey]).filter(key => key === 'claude' || key === 'codex');
|
|
239
|
+
if (selected.every(key => completed.has(key)) && allPreserved.size) {
|
|
240
|
+
ctx.ui.warn(`Preserved ${allPreserved.size} obsolete runtime item(s); review and remove manually if no longer needed: ${[...allPreserved].join(', ')}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
|
|
245
|
+
if (platformKey === 'claude') removeObsoleteRuntimeFiles(ctx, platformKey);
|
|
191
246
|
}
|
|
192
247
|
|
|
193
248
|
/** Remove renamed agents only when ownership manifest proves pristine bytes. */
|
|
@@ -258,7 +313,7 @@ function copyClaudeMdFile(ctx, platformKey) {
|
|
|
258
313
|
}
|
|
259
314
|
|
|
260
315
|
/** Copy the rules/ tree (ownership-aware; no longer force-overwrites). */
|
|
261
|
-
function copyRulesDirectory(ctx, platformKey) {
|
|
316
|
+
function copyRulesDirectory(ctx, platformKey, excludedNames = new Set()) {
|
|
262
317
|
if (!hasPlatformCapability(platformKey, 'rules')) return;
|
|
263
318
|
|
|
264
319
|
const src = path.join(SRC, 'claude/rules');
|
|
@@ -270,9 +325,19 @@ function copyRulesDirectory(ctx, platformKey) {
|
|
|
270
325
|
}
|
|
271
326
|
|
|
272
327
|
const transform = getCopyOptions(platformKey, {}).transform;
|
|
273
|
-
const
|
|
328
|
+
const options = {
|
|
274
329
|
src, dest, platformFolder: PLATFORMS[platformKey].folder, ctx, tracker: ctx.trackers[platformKey], transform
|
|
275
|
-
}
|
|
330
|
+
};
|
|
331
|
+
let agg;
|
|
332
|
+
if (excludedNames.size === 0) agg = copyManagedTree(options);
|
|
333
|
+
else {
|
|
334
|
+
agg = { created: 0, updated: 0, unchanged: 0, preserved: 0, missing: 0 };
|
|
335
|
+
for (const name of fs.readdirSync(src)) {
|
|
336
|
+
if (excludedNames.has(name)) continue;
|
|
337
|
+
const child = copyManagedTree({ ...options, sourceRoot: src, src: path.join(src, name), dest: path.join(dest, name) });
|
|
338
|
+
for (const key of Object.keys(agg)) agg[key] += child[key];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
276
341
|
report(ctx, treeAction(agg), 'rules/ directory');
|
|
277
342
|
}
|
|
278
343
|
|
|
@@ -303,6 +368,7 @@ module.exports = {
|
|
|
303
368
|
copyOutputStylesDirectory,
|
|
304
369
|
copyClaudeRuntimeFiles,
|
|
305
370
|
removeObsoleteClaudeRuntimeFiles,
|
|
371
|
+
removeObsoleteRuntimeFiles,
|
|
306
372
|
copyClaudeMdFile,
|
|
307
373
|
copyClaudeAgentsMdFile,
|
|
308
374
|
ensureSharedAgentsMdCore,
|
|
@@ -12,6 +12,26 @@ const { PLATFORMS } = require('../lib/context');
|
|
|
12
12
|
|
|
13
13
|
const SRC_CLAUDE = path.join(__dirname, '../../src/claude');
|
|
14
14
|
|
|
15
|
+
const FORMER_MATCHERS = new Set([
|
|
16
|
+
'Read|Write|Edit|MultiEdit|Bash|Glob',
|
|
17
|
+
'Read|Write|Edit|MultiEdit|Bash|Glob|Grep',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function refreshManagedMatchers(settings, template, ctx) {
|
|
21
|
+
if (!Array.isArray(settings.hooks?.PreToolUse)) return settings;
|
|
22
|
+
const targets = template.hooks?.PreToolUse || [];
|
|
23
|
+
const entries = settings.hooks.PreToolUse.map(entry => {
|
|
24
|
+
if (!FORMER_MATCHERS.has(entry?.matcher) || !Array.isArray(entry.hooks) || !entry.hooks.length) return entry;
|
|
25
|
+
const candidates = targets.filter(target =>
|
|
26
|
+
entry.hooks.every(hook => target.hooks.some(managed => managed.command === hook?.command)));
|
|
27
|
+
if (candidates.length !== 1) return entry;
|
|
28
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: hook PreToolUse matcher updated`);
|
|
29
|
+
ctx.results.updated++;
|
|
30
|
+
return { ...entry, matcher: candidates[0].matcher };
|
|
31
|
+
});
|
|
32
|
+
return { ...settings, hooks: { ...settings.hooks, PreToolUse: entries } };
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
/**
|
|
16
36
|
* Remove obsolete hook entries from a settings object whose command matches any
|
|
17
37
|
* manifest-listed substring (e.g. a retired router hook). Returns a possibly
|
|
@@ -97,7 +117,8 @@ function mergeClaudeSettings(ctx, platformKey) {
|
|
|
97
117
|
}
|
|
98
118
|
}
|
|
99
119
|
|
|
100
|
-
const mergedSettings =
|
|
120
|
+
const mergedSettings = refreshManagedMatchers(
|
|
121
|
+
pruneObsoleteSettingsHooks({ ...existingSettings }, ctx), managedSettings, ctx);
|
|
101
122
|
|
|
102
123
|
// statusLine: adopt managed value if upgrading, missing, or CafeKit-owned.
|
|
103
124
|
if (managedSettings.statusLine) {
|
|
@@ -17,21 +17,81 @@ const { PLATFORMS } = require('../lib/context');
|
|
|
17
17
|
|
|
18
18
|
const CODEX_SRC = path.join(__dirname, '../../src/codex');
|
|
19
19
|
const HOOK_COMMAND_TEMPLATE = /^node "\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
20
|
+
const LEGACY_GIT_COMMAND = /^node "\$\(git rev-parse --show-toplevel\)\/\.codex\/hooks\/([a-z0-9-]+\.cjs)"$/;
|
|
21
|
+
const SHELL_QUOTED = "'(?:[^']|'\\\\'')*'";
|
|
22
|
+
const POSIX_COMMAND = new RegExp(`^(?:PATH="\\$PATH"(?::${SHELL_QUOTED})+ )?node (${SHELL_QUOTED})$`);
|
|
23
|
+
const WINDOWS_LAUNCHER = 'node -e "process.argv[1]=Buffer.from(process.argv[1],\'base64url\').toString(\'utf8\');require(\'module\').runMain()" ';
|
|
24
|
+
|
|
25
|
+
// Former template values from the hooks.json history. Custom expressions never
|
|
26
|
+
// establish ownership, even when every handler belongs to CafeKit.
|
|
27
|
+
const FORMER_MATCHERS = {
|
|
28
|
+
PreToolUse: new Set([
|
|
29
|
+
'Bash|apply_patch|Edit|Write|Read|Glob|Grep|mcp__.*',
|
|
30
|
+
'Bash|exec_command|apply_patch|Edit|Write|Read|Glob|Grep|mcp__.*|shell',
|
|
31
|
+
]),
|
|
32
|
+
PostToolUse: new Set([
|
|
33
|
+
'Agent|spawn_agent|update_plan|task|Task|TodoWrite',
|
|
34
|
+
'apply_patch|Edit|Write|MultiEdit',
|
|
35
|
+
]),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function refreshManagedMatchers(config, template, ctx) {
|
|
39
|
+
let updated = 0;
|
|
40
|
+
const hooks = { ...(config.hooks || {}) };
|
|
41
|
+
for (const [event, former] of Object.entries(FORMER_MATCHERS)) {
|
|
42
|
+
if (!Array.isArray(hooks[event])) continue;
|
|
43
|
+
hooks[event] = hooks[event].map(group => {
|
|
44
|
+
const handlers = Array.isArray(group?.hooks) ? group.hooks : [];
|
|
45
|
+
const scripts = handlers.map(managedHandlerScript);
|
|
46
|
+
if (!scripts.some(Boolean)) return group;
|
|
47
|
+
const candidates = handlers.length && scripts.every(Boolean)
|
|
48
|
+
? (template.hooks[event] || []).filter(target => {
|
|
49
|
+
const wanted = target.hooks.map(managedHandlerScript);
|
|
50
|
+
return scripts.every(script => wanted.includes(script));
|
|
51
|
+
}) : [];
|
|
52
|
+
const target = candidates.length === 1 ? candidates[0] : null;
|
|
53
|
+
if (target && (group.matcher || '') === (target.matcher || '')) return group;
|
|
54
|
+
if (target && former.has(group.matcher)) {
|
|
55
|
+
updated += 1;
|
|
56
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: ${event} matcher updated`);
|
|
57
|
+
return { ...group, matcher: target.matcher };
|
|
58
|
+
}
|
|
59
|
+
ctx.ui.warn(`Codex hooks: ${event} matcher preserved; custom, mixed, or ambiguous group. Review it manually.`);
|
|
60
|
+
return group;
|
|
61
|
+
});
|
|
33
62
|
}
|
|
34
|
-
return
|
|
63
|
+
return { config: { ...config, hooks }, updated };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A basename alone cannot prove ownership: users may also run tools/hooks/session.cjs.
|
|
67
|
+
// Only known source scripts at the native runtime path and an exact emitted launcher
|
|
68
|
+
// shape may be rebound or count as already registered.
|
|
69
|
+
const MANAGED_SCRIPTS = new Set(fs.readdirSync(path.join(CODEX_SRC, 'hooks')).filter((name) => /^[a-z0-9-]+\.cjs$/.test(name)));
|
|
70
|
+
|
|
71
|
+
function nativeScript(target) {
|
|
72
|
+
if (!path.posix.isAbsolute(target) && !path.win32.isAbsolute(target)) return null;
|
|
73
|
+
const match = target.match(/[/\\]\.codex[/\\]hooks[/\\]([a-z0-9-]+\.cjs)$/);
|
|
74
|
+
return match && MANAGED_SCRIPTS.has(match[1]) ? match[1] : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function managedCommandScript(command) {
|
|
78
|
+
if (typeof command !== 'string') return null;
|
|
79
|
+
const template = command.match(HOOK_COMMAND_TEMPLATE) || command.match(LEGACY_GIT_COMMAND);
|
|
80
|
+
if (template) return MANAGED_SCRIPTS.has(template[1]) ? template[1] : null;
|
|
81
|
+
const posix = command.match(POSIX_COMMAND);
|
|
82
|
+
if (posix) return nativeScript(posix[1].slice(1, -1).replace(/'\\''/g, "'"));
|
|
83
|
+
if (!command.startsWith(WINDOWS_LAUNCHER)) return null;
|
|
84
|
+
const encoded = command.slice(WINDOWS_LAUNCHER.length);
|
|
85
|
+
if (!/^[A-Za-z0-9_-]+$/.test(encoded)) return null;
|
|
86
|
+
const target = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
87
|
+
return Buffer.from(target, 'utf8').toString('base64url') === encoded ? nativeScript(target) : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function managedHandlerScript(handler) {
|
|
91
|
+
if (handler?.type !== 'command') return null;
|
|
92
|
+
const fields = ['command', 'commandWindows'].filter((field) => handler[field] !== undefined);
|
|
93
|
+
const scripts = fields.map((field) => managedCommandScript(handler[field]));
|
|
94
|
+
return scripts.length > 0 && scripts.every((script) => script && script === scripts[0]) ? scripts[0] : null;
|
|
35
95
|
}
|
|
36
96
|
|
|
37
97
|
/** The hook script a command runs, which is stable across installs and platforms. */
|
|
@@ -57,8 +117,8 @@ function hookPathFor(script, projectRoot) {
|
|
|
57
117
|
* nothing outside a repository and the *outer* root for a project nested inside one. Both
|
|
58
118
|
* cases produced a path that does not exist, so Codex started every hook, every hook died,
|
|
59
119
|
* and the gates were silently absent. The path is known at install time, so it is written
|
|
60
|
-
* directly. The literal
|
|
61
|
-
*
|
|
120
|
+
* directly. The literal `.codex/hooks/<script>.cjs` path and emitted launcher shape
|
|
121
|
+
* let managedHandlerScript() recognize CafeKit's own entries on a reinstall.
|
|
62
122
|
*/
|
|
63
123
|
/**
|
|
64
124
|
* Directories to fall back on when PATH carries no `node`.
|
|
@@ -88,7 +148,7 @@ function posixHookCommand(script, projectRoot) {
|
|
|
88
148
|
*/
|
|
89
149
|
function windowsHookCommand(script, projectRoot) {
|
|
90
150
|
const encoded = Buffer.from(hookPathFor(script, projectRoot), 'utf8').toString('base64url');
|
|
91
|
-
return
|
|
151
|
+
return WINDOWS_LAUNCHER + encoded;
|
|
92
152
|
}
|
|
93
153
|
|
|
94
154
|
/** Rewrite a managed template handler into absolute launchers for both platforms. */
|
|
@@ -104,10 +164,9 @@ function materializeHookCommands(handler, projectRoot) {
|
|
|
104
164
|
}
|
|
105
165
|
|
|
106
166
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* CafeKit itself emitted is rewritten; anything else stays untouched.
|
|
167
|
+
* Rebind recognized CafeKit launchers after an upgrade or a project move. Registration
|
|
168
|
+
* preserves the user's event/matcher placement; only emitted command shapes change,
|
|
169
|
+
* and a custom field for another platform remains untouched.
|
|
111
170
|
*/
|
|
112
171
|
function repairLegacyCodexLaunchers(config, projectRoot) {
|
|
113
172
|
if (!config.hooks) return { config, repaired: 0 };
|
|
@@ -120,13 +179,13 @@ function repairLegacyCodexLaunchers(config, projectRoot) {
|
|
|
120
179
|
return {
|
|
121
180
|
...group,
|
|
122
181
|
hooks: group.hooks.map((handler) => {
|
|
123
|
-
|
|
124
|
-
const template = typeof handler?.commandWindows === 'string'
|
|
125
|
-
&& handler.commandWindows.match(HOOK_COMMAND_TEMPLATE);
|
|
126
|
-
if (!legacy && !template) return handler;
|
|
182
|
+
if (handler?.type !== 'command') return handler;
|
|
127
183
|
const next = { ...handler };
|
|
128
|
-
|
|
129
|
-
|
|
184
|
+
for (const [field, build] of [['command', posixHookCommand], ['commandWindows', windowsHookCommand]]) {
|
|
185
|
+
const script = managedCommandScript(handler[field]);
|
|
186
|
+
if (script) next[field] = build(script, projectRoot);
|
|
187
|
+
}
|
|
188
|
+
if (next.command === handler.command && next.commandWindows === handler.commandWindows) return handler;
|
|
130
189
|
repaired += 1;
|
|
131
190
|
return next;
|
|
132
191
|
}),
|
|
@@ -139,7 +198,7 @@ function repairLegacyCodexLaunchers(config, projectRoot) {
|
|
|
139
198
|
/** Drop CafeKit hooks the manifest has retired, leaving foreign entries untouched. */
|
|
140
199
|
function pruneObsoleteCodexHooks(config, ctx) {
|
|
141
200
|
const obsolete = ctx.manifest?.obsolete?.settingsHookCommandSubstrings || [];
|
|
142
|
-
if (!config.hooks
|
|
201
|
+
if (!config.hooks) return { config, removed: 0 };
|
|
143
202
|
|
|
144
203
|
let removed = 0;
|
|
145
204
|
const pruned = {};
|
|
@@ -150,7 +209,8 @@ function pruneObsoleteCodexHooks(config, ctx) {
|
|
|
150
209
|
if (!Array.isArray(group?.hooks)) { keptGroups.push(group); continue; }
|
|
151
210
|
const keptHooks = group.hooks.filter((handler) => {
|
|
152
211
|
const command = handler?.command || '';
|
|
153
|
-
const isObsolete = obsolete.some((substring) => command.includes(substring))
|
|
212
|
+
const isObsolete = obsolete.some((substring) => command.includes(substring))
|
|
213
|
+
|| (eventName === 'SessionStart' && managedHandlerScript(handler) === 'docs-sync.cjs');
|
|
154
214
|
if (isObsolete) removed += 1;
|
|
155
215
|
return !isObsolete;
|
|
156
216
|
});
|
|
@@ -196,12 +256,15 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
|
|
|
196
256
|
ctx.results.updated++;
|
|
197
257
|
}
|
|
198
258
|
|
|
199
|
-
const { config:
|
|
259
|
+
const { config: rebound, repaired } = repairLegacyCodexLaunchers(pruned, projectRoot);
|
|
200
260
|
if (repaired > 0) {
|
|
201
|
-
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: rebound ${repaired} launcher(s)
|
|
261
|
+
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: rebound ${repaired} launcher(s) to the current project`);
|
|
202
262
|
ctx.results.updated++;
|
|
203
263
|
}
|
|
204
264
|
|
|
265
|
+
const { config: base, updated: matcherUpdates } = refreshManagedMatchers(rebound, managed, ctx);
|
|
266
|
+
ctx.results.updated += matcherUpdates;
|
|
267
|
+
|
|
205
268
|
const merged = { ...base };
|
|
206
269
|
if (typeof managed.description === 'string' && typeof merged.description !== 'string') {
|
|
207
270
|
merged.description = managed.description;
|
|
@@ -215,7 +278,7 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
|
|
|
215
278
|
// it. Matching on the script rather than the whole command survives the path
|
|
216
279
|
// differences between installs and platforms.
|
|
217
280
|
const registered = new Set(
|
|
218
|
-
groups.flatMap((group) => (group?.hooks
|
|
281
|
+
groups.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : []).map(managedHandlerScript)).filter(Boolean),
|
|
219
282
|
);
|
|
220
283
|
|
|
221
284
|
for (const managedGroup of managedGroups) {
|
|
@@ -242,7 +305,7 @@ function mergeCodexHooks(ctx, platformKey, projectRoot = process.cwd()) {
|
|
|
242
305
|
merged.hooks[eventName] = groups;
|
|
243
306
|
}
|
|
244
307
|
|
|
245
|
-
if (added === 0 && removed === 0) {
|
|
308
|
+
if (added === 0 && removed === 0 && matcherUpdates === 0) {
|
|
246
309
|
ctx.ui.detail(` → ${ctx.dryRun ? '[dry-run] ' : ''}Codex hooks: already registered`);
|
|
247
310
|
ctx.results.unchanged++;
|
|
248
311
|
}
|