@haposoft/cafekit 0.18.2 → 0.18.3

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 (118) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/codex-hooks.test.js +38 -6
  7. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  8. package/bin/__tests__/codex-native.test.js +15 -8
  9. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  10. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  11. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  12. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  13. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  14. package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
  15. package/bin/__tests__/migrate-apply.test.js +373 -0
  16. package/bin/__tests__/migrate-cli.test.js +152 -0
  17. package/bin/__tests__/migrate-merged.test.js +269 -0
  18. package/bin/__tests__/migrate-plan.test.js +271 -0
  19. package/bin/__tests__/omp-bridge.test.js +81 -5
  20. package/bin/__tests__/omp-hooks.test.js +24 -0
  21. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  22. package/bin/__tests__/privacy-paths.test.js +184 -0
  23. package/bin/__tests__/usage-hook-retired.test.js +24 -6
  24. package/bin/__tests__/vibe-run.test.js +215 -0
  25. package/bin/__tests__/vibe-skill.test.js +549 -0
  26. package/bin/install.js +99 -1
  27. package/bin/lib/backup.js +43 -24
  28. package/bin/lib/codex-install.js +7 -0
  29. package/bin/lib/context.js +5 -0
  30. package/bin/lib/lock.js +98 -18
  31. package/bin/lib/managed-writer.js +16 -10
  32. package/bin/lib/migrate-apply.js +202 -0
  33. package/bin/lib/migrate-merged.js +342 -0
  34. package/bin/lib/migrate-plan.js +419 -0
  35. package/bin/lib/plugin-inventory.js +33 -0
  36. package/bin/phases/claude-runtime.js +5 -3
  37. package/bin/phases/codex-hooks.js +38 -0
  38. package/bin/phases/codex-runtime.js +106 -74
  39. package/bin/phases/copy-payload.js +49 -1
  40. package/bin/phases/omp-runtime.js +4 -26
  41. package/bin/phases/root-config.js +5 -2
  42. package/package.json +1 -1
  43. package/src/claude/CLAUDE.md +1 -1
  44. package/src/claude/agents/advisor.md +55 -0
  45. package/src/claude/agents/debugger.md +2 -3
  46. package/src/claude/agents/deployer.md +2 -1
  47. package/src/claude/agents/docs-keeper.md +12 -0
  48. package/src/claude/agents/inspector.md +1 -1
  49. package/src/claude/agents/project-manager.md +2 -2
  50. package/src/claude/agents/researcher.md +0 -1
  51. package/src/claude/agents/spec-maker.md +4 -1
  52. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  53. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  54. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  55. package/src/claude/hooks/__tests__/precompact.test.js +7 -1
  56. package/src/claude/hooks/__tests__/privacy-block.test.js +33 -9
  57. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  58. package/src/claude/hooks/__tests__/spec-gate.test.js +86 -1
  59. package/src/claude/hooks/agent.cjs +5 -2
  60. package/src/claude/hooks/inspect-block.cjs +3 -1
  61. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  62. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  63. package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
  64. package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
  65. package/src/claude/hooks/lib/spec-touch.cjs +14 -1
  66. package/src/claude/hooks/plugin-context.cjs +80 -0
  67. package/src/claude/hooks/privacy-block.cjs +2 -1
  68. package/src/claude/hooks/rules.cjs +5 -2
  69. package/src/claude/hooks/session.cjs +4 -1
  70. package/src/claude/hooks/spec-state.cjs +5 -4
  71. package/src/claude/hooks/state.cjs +5 -1
  72. package/src/claude/migration-manifest.json +6 -1
  73. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  74. package/src/claude/plugin/core.md +71 -0
  75. package/src/claude/rules/manage-docs.md +1 -2
  76. package/src/claude/rules/review-audit-self-decision.md +1 -1
  77. package/src/claude/rules/skill-workflow-routing.md +1 -1
  78. package/src/claude/rules/state-sync.md +14 -0
  79. package/src/claude/rules/workflow.md +1 -1
  80. package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
  81. package/src/claude/scripts/provenance.cjs +40 -16
  82. package/src/claude/scripts/setup-statusline.cjs +88 -0
  83. package/src/claude/skills/adv/SKILL.md +139 -0
  84. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  85. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  86. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  87. package/src/claude/skills/code-review/SKILL.md +4 -1
  88. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  89. package/src/claude/skills/develop/SKILL.md +5 -3
  90. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  91. package/src/claude/skills/fix/SKILL.md +2 -2
  92. package/src/claude/skills/git/SKILL.md +4 -2
  93. package/src/claude/skills/git/references/merge-pr.md +125 -0
  94. package/src/claude/skills/specs/SKILL.md +2 -2
  95. package/src/claude/skills/specs/references/review.md +1 -1
  96. package/src/claude/skills/specs/references/templates.md +2 -0
  97. package/src/claude/skills/vibe/SKILL.md +69 -0
  98. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  99. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  100. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  101. package/src/codex/AGENTS.md +1 -1
  102. package/src/codex/hooks/inspect-block.cjs +7 -4
  103. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  104. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  105. package/src/codex/hooks/lib/privacy-state.cjs +18 -1
  106. package/src/codex/hooks/lib/state-store.cjs +5 -4
  107. package/src/codex/hooks/precompact.cjs +9 -8
  108. package/src/codex/hooks/privacy-approval.cjs +2 -2
  109. package/src/codex/hooks/privacy-block.cjs +5 -4
  110. package/src/codex/hooks/rules.cjs +12 -6
  111. package/src/codex/hooks/session.cjs +22 -10
  112. package/src/codex/plugin/core.md +76 -0
  113. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  114. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  115. package/src/codex/scripts/setup-agents.cjs +124 -0
  116. package/src/common/AGENTS.md +1 -1
  117. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
  118. package/src/omp/hooks/privacy-block.cjs +2 -1
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const os = require('os');
4
5
  const path = require('path');
5
6
  const { commandAccess } = require('./privacy-command-analysis.cjs');
6
7
 
@@ -116,7 +117,7 @@ function extractCommandPaths(command) {
116
117
  // (<<BODY) does expand, so it is left to the analyzer.
117
118
  const scrubbed = scrubCommand(command);
118
119
 
119
- const paths = new Set(commandAccess(scrubbed).paths);
120
+ const paths = new Set(cachedAccess(scrubbed).paths);
120
121
 
121
122
  // Two shapes the analyzer cannot return. It drops any word carrying an
122
123
  // expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched by
@@ -137,14 +138,45 @@ function extractCommandPaths(command) {
137
138
  return [...paths];
138
139
  }
139
140
 
141
+ // omp's edit modes name files in their own grammars: hashline headers `[path#TAG]` (tag
142
+ // optional, path may be quoted) and `¶path`, a hashline `MV <dest>` move, and the sloppy
143
+ // `*** Edit File:` header. The move destination is a write like any other.
144
+ const PATCH_FILE_LINES = [
145
+ /^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm,
146
+ /^[ \t]*\*{3}[ \t]+Edit[ \t]+File:[ \t]*(.+)$/gim,
147
+ /^\[(.+?)(?:#[0-9A-Fa-f]{4})?\]$/gm,
148
+ /^\u00b6(.+)$/gm,
149
+ /^[ \t]*MV[ \t]+(.+)$/gm,
150
+ ];
151
+
140
152
  function extractPatchPaths(patch) {
141
- return [...String(patch).matchAll(
142
- /^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm
143
- )]
144
- .map((match) => match[1].trim())
153
+ const text = String(patch).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
154
+ return PATCH_FILE_LINES.flatMap((pattern) => [...text.matchAll(pattern)])
155
+ .map((match) => match[1].trim().replace(/^(["'])(.*)\1$/, '$2').replace(/#[0-9A-Fa-f]{4}$/, ''))
145
156
  .filter(Boolean);
146
157
  }
147
158
 
159
+ // omp's read takes `path:<selector>` (`raw`, `conflicts`, `img`, a line or range list);
160
+ // the file is the part before it, so `.env:raw` is `.env`.
161
+ const READ_SELECTOR = /:((?:raw|conflicts|img|-?\d+(?:[-+,.]\d+)*))$/i;
162
+
163
+ function withoutSelectors(filePath) {
164
+ const variants = [];
165
+ let current = filePath;
166
+ for (let count = 0; count < 2 && READ_SELECTOR.test(current); count += 1) {
167
+ current = current.replace(READ_SELECTOR, '');
168
+ if (current) variants.push(current);
169
+ }
170
+ return variants;
171
+ }
172
+
173
+ // omp's eval runs code with no path argument; a quoted literal naming a secret file is the
174
+ // only file reference the gate can see.
175
+ function evalLiterals(input) {
176
+ const code = typeof input?.code === 'string' ? input.code : '';
177
+ return [...code.matchAll(/(["'`])((?:(?!\1)[^\\\n]|\\.)*)\1/g)].map((match) => match[2].trim()).filter(Boolean);
178
+ }
179
+
148
180
 
149
181
  function shellInput(input) {
150
182
  const value = input?.command ?? input?.cmd;
@@ -163,7 +195,7 @@ function patchInput(input) {
163
195
  }
164
196
 
165
197
  function isPathKey(key, mcp) {
166
- return /^(?:file_?paths?|paths?|search_?paths?|director(?:y|ies)|cwd|root|locations?|notebook_path|globs?)$/i.test(key)
198
+ return /^(?:file_?paths?|paths?|search_?paths?|director(?:y|ies)|cwd|root|locations?|notebook_path|globs?|rename)$/i.test(key)
167
199
  || (mcp && /path|file|dir|uri|source|dest|target/i.test(key));
168
200
  }
169
201
 
@@ -187,9 +219,14 @@ function extractPaths(toolName, input) {
187
219
  if (['bash', 'exec_command', 'shell'].includes(tool)) {
188
220
  paths.push(...extractCommandPaths(shellInput(input)));
189
221
  } else if (['apply_patch', 'edit', 'write'].includes(tool)) {
190
- paths.push(...extractPatchPaths(patchInput(input)));
222
+ const patch = patchInput(input);
223
+ paths.push(...extractPatchPaths(patch));
224
+ // An edit whose text names no file cannot be judged, so it is not allowed through.
225
+ if (tool === 'edit' && patch.trim() && !paths.length) throw new Error('edit input names no file');
226
+ } else if (tool === 'eval') {
227
+ paths.push(...evalLiterals(input));
191
228
  }
192
- return [...new Set(paths)];
229
+ return [...new Set(paths.flatMap((filePath) => [filePath, ...withoutSelectors(filePath)]))];
193
230
  }
194
231
 
195
232
  function sensitiveRequest(filePath, cwd) {
@@ -245,7 +282,7 @@ function protectedWrite(filePath, cwd) {
245
282
  }
246
283
 
247
284
  function extractWritePaths(tool, input) {
248
- if (['bash', 'exec_command', 'shell'].includes(tool)) return commandAccess(scrubCommand(shellInput(input)));
285
+ if (['bash', 'exec_command', 'shell'].includes(tool)) return cachedAccess(scrubCommand(shellInput(input)));
249
286
  const paths = [];
250
287
  if (['write', 'edit', 'multiedit', 'notebookedit'].includes(tool) || tool.startsWith('mcp__')) {
251
288
  collectPathValues(input, '', paths, tool.startsWith('mcp__'));
@@ -260,9 +297,332 @@ function protectedUnknownWrite(filePath) {
260
297
  || ['.claude', '.codex', '.omp'].includes(parts.at(-1));
261
298
  }
262
299
 
300
+ // Dynamic shell. One budget covers the whole command -- directory entries and stats, assignments resolved, and wall
301
+ // time -- so a crafted command cannot outrun the hook's own timeout; running out of it asks rather than allows. Every
302
+ // step below is linear in its input, and every loop that touches the disk checks the budget.
303
+ const DYNAMIC_BUDGET = { entries: 4000, assignments: 100000, milliseconds: 250, globstarMilliseconds: 60, textLength: 16384, braceVariants: 64, braceGroups: 64 };
304
+ const OVER_BUDGET = 'dynamic shell expansion over the check budget';
305
+ const DECODED_READ = 'decoded input fed to a shell or reader';
306
+ const UNRESOLVED = '\u0000';
307
+ // Names a glob is tested against when its directory is unknown (after `cd "$D"`) or only its name is visible.
308
+ const SENSITIVE_NAMES = ['.env', '.env.local', '.env.production', '.env.development', 'credentials', 'credentials.json',
309
+ 'secrets.yml', 'secrets.yaml', 'secrets.json', 'server.pem', 'server.key', 'cert.p12', 'cert.pfx', 'id_rsa', 'id_ed25519',
310
+ 'id_ecdsa', 'id_dsa', '.netrc', '.pgpass', 'kubeconfig', 'app.keystore', 'app.jks', 'auth.json', 'token.json', 'tokens.json'];
311
+
312
+ const hasGlob = (text) => /[*?[]/.test(text);
313
+ const overBudget = (budget) => {
314
+ if (budget.entries > DYNAMIC_BUDGET.entries || budget.assignments > DYNAMIC_BUDGET.assignments
315
+ || Date.now() > budget.deadline) budget.over = true;
316
+ return budget.over;
317
+ };
318
+
319
+ // One path segment of a shell glob: * and ? never cross '/', [..] is a class ([!..] negated, a-z ranges), and a name
320
+ // starting with '.' only matches when the pattern starts with '.' too (bash without dotglob). Matching is the two-pointer
321
+ // wildcard walk, so a pattern full of stars costs pattern x name steps, never exponential backtracking.
322
+ const STAR = Symbol('star');
323
+ const ANY = Symbol('any');
324
+ function segmentMatcher(pattern) {
325
+ const tokens = [];
326
+ // Once one '[' finds no ']' after it, no later '[' can: they are all literal, and the rest is not scanned again.
327
+ let unclosed = false;
328
+ for (let index = 0; index < pattern.length; index += 1) {
329
+ const ch = pattern[index];
330
+ if (ch === '*') { if (tokens.at(-1) !== STAR) tokens.push(STAR); continue; }
331
+ if (ch === '?') { tokens.push(ANY); continue; }
332
+ if (ch === '[' && !unclosed) {
333
+ const negated = pattern[index + 1] === '!' || pattern[index + 1] === '^';
334
+ const open = index + (negated ? 2 : 1);
335
+ const close = classEnd(pattern, open);
336
+ if (close > 0) {
337
+ tokens.push(characterClass(pattern.slice(open, close), negated));
338
+ index = close;
339
+ continue;
340
+ }
341
+ unclosed = true;
342
+ }
343
+ tokens.push(ch);
344
+ }
345
+ const dot = pattern.startsWith('.');
346
+ return (name) => (dot || !name.startsWith('.')) && wildcardMatch(tokens, name);
347
+ }
348
+
349
+ // The ']' that closes a class opened before `open`: a ']' first in the class is a member, and a POSIX class
350
+ // (`[:alpha:]`) inside it is skipped whole.
351
+ function classEnd(pattern, open) {
352
+ for (let index = open; index < pattern.length; index += 1) {
353
+ if (pattern[index] === '[' && pattern[index + 1] === ':') {
354
+ const end = pattern.indexOf(':]', index + 2);
355
+ if (end > 0) { index = end + 1; continue; }
356
+ }
357
+ if (pattern[index] === ']' && index > open) return index;
358
+ }
359
+ return -1;
360
+ }
361
+
362
+ function characterClass(body, negated) {
363
+ // A POSIX class (`.e[[:alpha:]]v`) is taken to match any character, negated or not: it may ask more than the shell
364
+ // would match, never less.
365
+ if (body.includes('[:')) return () => true;
366
+ const ranges = [];
367
+ for (let index = 0; index < body.length; index += 1) {
368
+ if (body[index + 1] === '-' && index + 2 < body.length) { ranges.push([body[index], body[index + 2]]); index += 2; }
369
+ else ranges.push([body[index], body[index]]);
370
+ }
371
+ return (ch) => ranges.some(([low, high]) => low <= ch && ch <= high) !== negated;
372
+ }
373
+
374
+ function wildcardMatch(tokens, name) {
375
+ let token = 0;
376
+ let at = 0;
377
+ let star = -1;
378
+ let starAt = 0;
379
+ const one = (expected, ch) => expected === ANY || (typeof expected === 'function' ? expected(ch) : expected === ch);
380
+ while (at < name.length) {
381
+ if (token < tokens.length && tokens[token] !== STAR && one(tokens[token], name[at])) { token += 1; at += 1; continue; }
382
+ if (tokens[token] === STAR) { star = token; token += 1; starAt = at; continue; }
383
+ if (star < 0) return false;
384
+ token = star + 1;
385
+ starAt += 1;
386
+ at = starAt;
387
+ }
388
+ while (tokens[token] === STAR) token += 1;
389
+ return token === tokens.length;
390
+ }
391
+
392
+ // Ordinary names: a glob that also matches one of these (`*`, `*.json`) says nothing about secrets on its own.
393
+ const ORDINARY_NAMES = ['README.md', 'package.json', 'index.js', 'main.ts', 'config.yaml', 'settings.yml', 'data.json',
394
+ 'notes.txt', 'main.go', 'app.py', 'Makefile', 'style.css', 'index.html', 'image.png'];
395
+
396
+ // Name-only judgement, used when the directory cannot be read: a literal secret name, or a glob aimed at secrets.
397
+ const nameLooksSensitive = (pattern) => {
398
+ const base = pattern.split('/').pop();
399
+ if (!hasGlob(base)) return isSensitive(base) && !isSafe(base);
400
+ const matches = segmentMatcher(base);
401
+ return SENSITIVE_NAMES.some(matches) && !ORDINARY_NAMES.some(matches);
402
+ };
403
+
404
+ // $name, ${name}, ${name:-default} and ${name-default}; ~ at the start is HOME. Anything else that expands ($(...),
405
+ // backticks, $1, $@, unknown variables) becomes UNRESOLVED. One left-to-right pass, and the output is capped: past the
406
+ // cap the budget is spent.
407
+ const BRACED = /^([A-Za-z_]\w*)(?::?[-=]([\s\S]*))?$/;
408
+ function substitute(text, vars, budget) {
409
+ const home = process.env.HOME || os.homedir();
410
+ let out = '';
411
+ let close = -1;
412
+ const emit = (value) => {
413
+ if (value === null || out.length + value.length > DYNAMIC_BUDGET.textLength) { budget.over = true; return false; }
414
+ out += value;
415
+ return true;
416
+ };
417
+ for (let index = 0; index < text.length;) {
418
+ const ch = text[index];
419
+ const next = text[index + 1];
420
+ let value = ch;
421
+ let width = 1;
422
+ if (index === 0 && ch === '~' && (next === undefined || next === '/')) value = home;
423
+ else if (ch === '$' && next === '{') {
424
+ // The first '}' after this point; reused while it stays ahead, so `${${${…` is not rescanned for each '${'.
425
+ if (close !== Infinity && close < index + 2) close = text.indexOf('}', index + 2);
426
+ if (close < 0) close = Infinity;
427
+ if (close === Infinity) { value = UNRESOLVED; width = text.length - index; } else {
428
+ const match = BRACED.exec(text.slice(index + 2, close));
429
+ const name = match?.[1];
430
+ value = !match ? UNRESOLVED : vars.has(name) ? vars.get(name) : name === 'HOME' ? home
431
+ : match[2] !== undefined ? match[2] : UNRESOLVED;
432
+ width = close + 1 - index;
433
+ }
434
+ } else if (ch === '$' && next === '(' && text[index + 2] === ')') { value = UNRESOLVED; width = 3; }
435
+ else if ((ch === '<' || ch === '>') && next === '(' && text[index + 2] === ')') { value = UNRESOLVED; width = 3; }
436
+ else if (ch === '`' && next === '`') { value = UNRESOLVED; width = 2; }
437
+ else if (ch === '$' && next !== undefined && '@*#?$!-0123456789'.includes(next)) { value = UNRESOLVED; width = 2; }
438
+ if (!emit(value)) return '';
439
+ index += width;
440
+ }
441
+ return out;
442
+ }
443
+
444
+ // Assignments visible to an operand, outermost scope first, each value substituted with the ones before it. A value
445
+ // past the text cap is kept as null, which spends the budget only if an operand actually uses it.
446
+ const listIds = new WeakMap();
447
+ let nextListId = 0;
448
+ function resolveVars(scopes, budget, memo) {
449
+ // Operands of the same segment see the same assignments; resolve those once.
450
+ const key = scopes.map(({ list, count }) => {
451
+ if (!listIds.has(list)) listIds.set(list, nextListId++);
452
+ return `${listIds.get(list)}:${count}`;
453
+ }).join(',');
454
+ if (memo.has(key)) return memo.get(key);
455
+ const vars = new Map();
456
+ memo.set(key, vars);
457
+ for (const { list, count } of scopes) {
458
+ for (let index = 0; index < count; index += 1) {
459
+ budget.assignments += 1;
460
+ if (overBudget(budget)) return vars;
461
+ const { name, value } = list[index];
462
+ const probe = { over: false };
463
+ const resolved = value.length > DYNAMIC_BUDGET.textLength ? '' : substitute(value, vars, probe);
464
+ vars.set(name, value.length > DYNAMIC_BUDGET.textLength || probe.over ? null : resolved);
465
+ }
466
+ }
467
+ return vars;
468
+ }
469
+
470
+ // Brace expansion {a,b}: the first innermost group holding a comma, found in one scan; variants and group depth capped.
471
+ function braces(text, budget, depth = 0) {
472
+ if (budget.over) return [];
473
+ if (depth > DYNAMIC_BUDGET.braceGroups) { budget.over = true; return []; }
474
+ let open = -1;
475
+ let comma = false;
476
+ let group = null;
477
+ for (let index = 0; index < text.length && !group; index += 1) {
478
+ const ch = text[index];
479
+ if (ch === '{') { open = index; comma = false; } else if (ch === ',' && open >= 0) comma = true;
480
+ else if (ch === '}' && open >= 0) { if (comma) group = [open, index]; open = -1; }
481
+ }
482
+ if (!group) return [text];
483
+ const [start, end] = group;
484
+ const out = [];
485
+ for (const part of text.slice(start + 1, end).split(',')) {
486
+ for (const rest of braces(text.slice(0, start) + part + text.slice(end + 1), budget, depth + 1)) {
487
+ out.push(rest);
488
+ if (out.length > DYNAMIC_BUDGET.braceVariants) { budget.over = true; return out; }
489
+ }
490
+ if (budget.over) return out;
491
+ }
492
+ return out;
493
+ }
494
+
495
+ // Expand a glob against the real directory tree. Every entry read and every literal segment stat-ed is charged to the
496
+ // shared budget. `**` (zsh, bash globstar) walks every directory below, itself included, on a slice of its own: a tree
497
+ // too big for that slice (`**/*.md` at a repository root, through node_modules) returns null, and the caller judges the
498
+ // pattern by name instead of asking for every such command.
499
+ function expandGlob(pattern, base, budget) {
500
+ if (!pattern.split('/').includes('**')) return expandParts(pattern, base, budget);
501
+ const slice = { entries: 0, assignments: 0, over: false,
502
+ deadline: Math.min(budget.deadline, Date.now() + DYNAMIC_BUDGET.globstarMilliseconds) };
503
+ const found = expandParts(pattern, base, slice);
504
+ if (slice.over) return null;
505
+ budget.entries += slice.entries;
506
+ return found;
507
+ }
508
+
509
+ function expandParts(pattern, base, budget) {
510
+ const absolute = pattern.startsWith('/');
511
+ let current = [absolute ? '/' : base];
512
+ const parts = pattern.split('/').filter((part, index) => part || index === 0 && !absolute);
513
+ for (const [index, part] of parts.entries()) {
514
+ if (!part) continue;
515
+ const last = index === parts.length - 1;
516
+ const next = [];
517
+ for (const directory of current) {
518
+ if (part === '**') { if (!walk(directory, next, last, budget)) return []; continue; }
519
+ if (!hasGlob(part)) {
520
+ budget.entries += 1;
521
+ if (overBudget(budget)) return [];
522
+ const joined = path.join(directory, part);
523
+ if (last || isDirectory(joined)) next.push(joined);
524
+ continue;
525
+ }
526
+ let entries;
527
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
528
+ budget.entries += entries.length;
529
+ if (overBudget(budget)) return [];
530
+ const matches = segmentMatcher(part);
531
+ for (const entry of entries) {
532
+ if (Date.now() > budget.deadline) { budget.over = true; return []; }
533
+ if (!matches(entry.name)) continue;
534
+ const joined = path.join(directory, entry.name);
535
+ if (last || entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(joined))) next.push(joined);
536
+ }
537
+ }
538
+ current = next;
539
+ if (!current.length || overBudget(budget)) return [];
540
+ }
541
+ return current;
542
+ }
543
+
544
+ // `**`: the directory and every non-hidden directory below it, or, as the last part, every non-hidden entry below.
545
+ function walk(root, out, last, budget) {
546
+ const pending = [root];
547
+ if (!last) out.push(root);
548
+ while (pending.length) {
549
+ const directory = pending.pop();
550
+ let entries;
551
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
552
+ budget.entries += entries.length;
553
+ if (overBudget(budget)) return false;
554
+ for (const entry of entries) {
555
+ if (entry.name.startsWith('.')) continue;
556
+ const joined = path.join(directory, entry.name);
557
+ if (entry.isDirectory()) { pending.push(joined); out.push(joined); } else if (last) out.push(joined);
558
+ }
559
+ }
560
+ return true;
561
+ }
562
+
563
+ function isDirectory(filePath) {
564
+ try { return fs.statSync(filePath).isDirectory(); } catch { return false; }
565
+ }
566
+
567
+ function dynamicSensitive(access, cwd) {
568
+ const found = [];
569
+ const budget = { entries: 0, assignments: 0, deadline: Date.now() + DYNAMIC_BUDGET.milliseconds, over: false };
570
+ if (access.decodedRead) found.push(DECODED_READ);
571
+ for (const name of access.pipeNames || []) {
572
+ if (name.length > DYNAMIC_BUDGET.textLength) budget.over = true;
573
+ if (overBudget(budget)) break;
574
+ if (nameLooksSensitive(name)) found.push(name);
575
+ }
576
+ const resolved = new Map();
577
+ for (const expansion of access.expansions || []) {
578
+ if (overBudget(budget)) break;
579
+ for (const literal of expansion.literals) if (isSensitive(literal) && !isSafe(literal)) found.push(literal);
580
+ if (expansion.text.length > DYNAMIC_BUDGET.textLength) { budget.over = true; break; }
581
+ const text = substitute(expansion.text, resolveVars(expansion.scopes || [], budget, resolved), budget);
582
+ for (const variant of braces(text, budget)) {
583
+ if (overBudget(budget)) break;
584
+ if (variant.includes(UNRESOLVED)) {
585
+ // `"$X/.env"`: whatever $X is, the name read is the literal tail. `.env$X`, `.en"$Y"v`: a name that starts with
586
+ // literal text reads, when the rest is empty, the name its literal parts spell. A name that starts with an
587
+ // unknown value (`$g.*`) says nothing about which file it is.
588
+ const tail = variant.slice(variant.lastIndexOf(UNRESOLVED) + 1);
589
+ if (tail.includes('/') && nameLooksSensitive(tail)) found.push(tail.replace(/^\/+/, ''));
590
+ const name = variant.slice(variant.lastIndexOf('/') + 1);
591
+ const joined = variant.split(UNRESOLVED).join('');
592
+ if (name && !name.startsWith(UNRESOLVED) && nameLooksSensitive(joined)) found.push(joined);
593
+ continue;
594
+ }
595
+ if (expansion.directory === null) {
596
+ if (nameLooksSensitive(variant)) found.push(variant);
597
+ continue;
598
+ }
599
+ const base = path.resolve(cwd, expansion.directory);
600
+ const candidates = hasGlob(variant) ? expandGlob(variant, base, budget) : [path.resolve(base, variant)];
601
+ if (candidates === null) {
602
+ if (nameLooksSensitive(variant)) found.push(variant);
603
+ continue;
604
+ }
605
+ for (const candidate of candidates) if (sensitiveRequest(candidate, cwd)) found.push(candidate);
606
+ }
607
+ }
608
+ if (budget.over) found.push(OVER_BUDGET);
609
+ return found;
610
+ }
611
+
612
+ // One hook call parses the same command for reads, writes and searches; parse it once.
613
+ const accessCache = new Map();
614
+ function cachedAccess(command) {
615
+ if (!accessCache.has(command)) {
616
+ if (accessCache.size >= 4) accessCache.clear();
617
+ accessCache.set(command, commandAccess(command));
618
+ }
619
+ return accessCache.get(command);
620
+ }
621
+
263
622
  function sensitivePaths(toolName, input, cwd) {
264
623
  const paths = extractPaths(toolName, input).filter(filePath => sensitiveRequest(filePath, cwd));
265
624
  const tool = String(toolName).toLowerCase();
625
+ if (['bash', 'exec_command', 'shell'].includes(tool)) paths.push(...dynamicSensitive(cachedAccess(scrubCommand(shellInput(input))), cwd));
266
626
  const access = extractWritePaths(tool, input);
267
627
  paths.push(...access.writes.filter(filePath => protectedWrite(filePath, cwd)),
268
628
  ...access.unknownWrites.filter(protectedUnknownWrite));
@@ -273,7 +633,7 @@ function sensitivePaths(toolName, input, cwd) {
273
633
  .filter(glob => typeof glob === 'string' && glob),
274
634
  }]
275
635
  : ['bash', 'exec_command', 'shell'].includes(tool)
276
- ? commandAccess(scrubCommand(shellInput(input))).searches : [];
636
+ ? cachedAccess(scrubCommand(shellInput(input))).searches : [];
277
637
  for (const search of searches) {
278
638
  if (!search.recursive) continue;
279
639
  // Negative globs and exclusion/type flags cannot prove a sensitive child is omitted.
@@ -64,4 +64,17 @@ function explicitFailure(payload) {
64
64
  return payload.success === false || failed(payload.tool_response);
65
65
  }
66
66
 
67
- module.exports = { canonicalTarget, packetAt, namedPackets, explicitFailure };
67
+ /**
68
+ * Every file a write tool reports touching: the Claude `file_path`, omp's `path`/`paths`
69
+ * (omp derives them from an edit's file headers), and the files an omp result says it
70
+ * changed. A glob names no file, so it is skipped.
71
+ */
72
+ function touchedPaths(payload) {
73
+ const input = payload.tool_input || {};
74
+ const details = (payload.tool_response && payload.tool_response.details) || {};
75
+ const values = [input.file_path, input.path, ...(Array.isArray(input.paths) ? input.paths : []), details.path,
76
+ ...[details.perFileResults, details.fileChanges].flatMap((list) => (Array.isArray(list) ? list.map((entry) => entry && entry.path) : []))];
77
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.trim() && !/[*?[\]{}]/.test(value)))];
78
+ }
79
+
80
+ module.exports = { canonicalTarget, packetAt, namedPackets, explicitFailure, touchedPaths };
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copyright (c) 2026 Haposoft. MIT License.
4
+ *
5
+ * SessionStart Hook — plugin-context.cjs (Claude Code plugin only)
6
+ *
7
+ * A plugin cannot ship CLAUDE.md or rules files as always-on context, so this
8
+ * hook injects the CafeKit core instructions as SessionStart additionalContext.
9
+ * The host caps that text at 10,000 characters; plugin/core.md is sized for it
10
+ * and points to the detailed rules files by absolute path.
11
+ *
12
+ * Registered only in the plugin's hooks.json; the npx install never runs it.
13
+ * Exit: 0 always (fail-open)
14
+ */
15
+
16
+ try {
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const os = require('os');
21
+
22
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
23
+ if (!pluginRoot) process.exit(0);
24
+
25
+ function writeAtomic(file, text) {
26
+ const temp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ fs.writeFileSync(temp, text);
29
+ fs.renameSync(temp, file);
30
+ }
31
+
32
+ // A user statusLine cannot use ${CLAUDE_PLUGIN_ROOT}, which moves on every
33
+ // update, so keep a stable launcher in the data directory pointing at the
34
+ // current version, and record where it is for /cf:setup-statusline.
35
+ function writeStatuslineLauncher(root) {
36
+ const data = process.env.CLAUDE_PLUGIN_DATA;
37
+ if (!data || !path.isAbsolute(data)) return;
38
+ try {
39
+ const target = JSON.stringify(path.join(root, 'status.cjs'));
40
+ writeAtomic(path.join(data, 'status.cjs'), [
41
+ '#!/usr/bin/env node',
42
+ '// Written by the CafeKit plugin at session start; runs the installed version\'s statusline.',
43
+ '// A removed or replaced version prints nothing; a broken one still reports its error.',
44
+ `if (!require('fs').existsSync(${target})) process.exit(0);`,
45
+ `require(${target});`,
46
+ '',
47
+ ].join('\n'));
48
+ writeAtomic(path.join(os.homedir(), '.claude', 'cafekit-plugin-data'), `${data}\n`);
49
+ } catch { /* the statusline is optional; never fail the session for it */ }
50
+ }
51
+
52
+ try { fs.readFileSync(0, 'utf8'); } catch { /* the payload is not needed */ }
53
+
54
+ let core = fs.readFileSync(path.join(__dirname, '..', 'plugin', 'core.md'), 'utf8')
55
+ // A function replacement keeps `$&`-style sequences in the path literal.
56
+ .replaceAll('${CLAUDE_PLUGIN_ROOT}', () => pluginRoot);
57
+
58
+ // The npx install writes this section into CLAUDE.md; the plugin takes it from its option.
59
+ const address = require('./lib/plugin-options.cjs').pluginOption('address');
60
+ if (address) {
61
+ core += `\n## Addressing (Context Overflow Indicator)\n\nAlways address the user as ${JSON.stringify(address)} throughout the conversation. If you stop doing so, the context has been compacted or truncated; tell the user to consider \`/clear\`.\n`;
62
+ }
63
+
64
+ writeStatuslineLauncher(pluginRoot);
65
+
66
+ process.stdout.write(JSON.stringify({
67
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: core },
68
+ }));
69
+ process.exit(0);
70
+ } catch (e) {
71
+ try {
72
+ const fs = require('fs');
73
+ const p = require('path');
74
+ const d = require('./lib/hook-state-dir.cjs').hookStateDir();
75
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
76
+ fs.appendFileSync(p.join(d, 'hook-log.jsonl'),
77
+ JSON.stringify({ ts: new Date().toISOString(), hook: 'plugin-context', status: 'crash', error: e.message }) + '\n');
78
+ } catch (_) { /* logging must not fail the session */ }
79
+ process.exit(0);
80
+ }
@@ -55,7 +55,8 @@ try {
55
55
  const projectRoot = resolveProjectRoot(data);
56
56
  const runtime = readRuntime(projectRoot);
57
57
 
58
- if (runtime.privacyBlock === false) process.exit(0);
58
+ // A plugin is trusted once for every repository, so a repository cannot switch it off.
59
+ if (runtime.privacyBlock === false && !require('./lib/plugin-options.cjs').isPlugin()) process.exit(0);
59
60
 
60
61
  const paths = sensitivePaths(toolName, toolInput, cwd);
61
62
  if (paths.length) askForPermission(`Sensitive file access requires approval: ${displayNames(paths)}`);
@@ -33,7 +33,10 @@ try {
33
33
  const payload = normalizeHookPayload(JSON.parse(stdin));
34
34
  const sessionId = payload.session_id || null;
35
35
  const root = projectRoot(payload);
36
- const runtime = readRuntime(root);
36
+ // An npx install always has runtime.json; under the plugin it is optional.
37
+ // Loaded only under the plugin, so npx installs need no extra hook file.
38
+ const plugin = process.env.CLAUDE_PLUGIN_ROOT ? require('./lib/plugin-options.cjs') : null;
39
+ const runtime = readRuntime(root) || (plugin ? {} : null);
37
40
  if (runtime === null) process.exit(0);
38
41
 
39
42
  // No session identity or reservation means no injection. Fail open.
@@ -41,7 +44,7 @@ try {
41
44
 
42
45
  // Language
43
46
  const thinkLang = runtime.locale?.thinkingLanguage || '';
44
- const respondLang = runtime.locale?.responseLanguage || '';
47
+ const respondLang = runtime.locale?.responseLanguage || (plugin ? plugin.pluginOption('response_language') : '');
45
48
  const effectThink = thinkLang || (respondLang ? 'en' : '');
46
49
 
47
50
  // Paths
@@ -115,7 +115,10 @@ try {
115
115
  const runtime = readRuntime(cwd);
116
116
 
117
117
  // Check CafeKit update in parallel with project detection (async, fail-open).
118
- const updateCheckPromise = checkCafeKitUpdate(runtimeDir(cwd)).catch(() => '');
118
+ // The plugin is updated by its marketplace, so an npx notice would be wrong there.
119
+ const updateCheckPromise = process.env.CLAUDE_PLUGIN_ROOT
120
+ ? Promise.resolve('')
121
+ : checkCafeKitUpdate(runtimeDir(cwd)).catch(() => '');
119
122
 
120
123
  // Project detection
121
124
  const projectType = runtime.project?.type !== 'auto'
@@ -14,7 +14,7 @@
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
  const { projectRoot } = require('./lib/project-root.cjs');
17
- const { packetAt, namedPackets, explicitFailure } = require('./lib/spec-touch.cjs');
17
+ const { packetAt, namedPackets, explicitFailure, touchedPaths } = require('./lib/spec-touch.cjs');
18
18
  const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
19
19
 
20
20
  function logCrash(error) {
@@ -119,9 +119,10 @@ try {
119
119
 
120
120
  if (payload.hook_event_name === 'PostToolUse') {
121
121
  // Record only; no resolve, no git, no tollgate cache.
122
- if (touchFilter && specsRoot && !explicitFailure(payload) && /^(Edit|Write|MultiEdit)$/.test(payload.tool_name || '')) {
123
- const name = packetAt(payload.tool_input && payload.tool_input.file_path, specsRoot, cwd);
124
- if (name) addTouched([name]);
122
+ // omp's ast_edit keeps its own name: aliasing it to Edit would lift the inspect scope check.
123
+ if (touchFilter && specsRoot && !explicitFailure(payload) && /^(Edit|Write|MultiEdit|ast_edit)$/.test(payload.tool_name || '')) {
124
+ const names = touchedPaths(payload).map((value) => packetAt(value, specsRoot, cwd)).filter(Boolean);
125
+ if (names.length) addTouched(names);
125
126
  }
126
127
  process.exit(0);
127
128
  }
@@ -86,7 +86,11 @@ try {
86
86
  function stateDir(cwd, sessionId, create = true) {
87
87
  try {
88
88
  let dir;
89
- if (fs.existsSync(runtimeDir(cwd))) {
89
+ const pluginRoot = require('./lib/hook-state-dir.cjs').pluginStateRoot();
90
+ if (pluginRoot) {
91
+ const hash = crypto.createHash('md5').update(cwd).digest('hex').slice(0, 12);
92
+ dir = path.join(pluginRoot, 'session-states', hash);
93
+ } else if (fs.existsSync(runtimeDir(cwd))) {
90
94
  dir = runtimePath(cwd, 'session-state');
91
95
  } else {
92
96
  const hash = crypto.createHash('md5').update(cwd).digest('hex').slice(0, 12);
@@ -6,6 +6,7 @@
6
6
  },
7
7
  "skills": {
8
8
  "required": [
9
+ "adv",
9
10
  "agent-browser",
10
11
  "ask",
11
12
  "brainstorm",
@@ -23,6 +24,7 @@
23
24
  "test",
24
25
  "ui-ux",
25
26
  "ui-ux-pro-max",
27
+ "vibe",
26
28
  "web-testing"
27
29
  ],
28
30
  "bundles": {
@@ -51,7 +53,8 @@
51
53
  "spec-maker.md",
52
54
  "ui-ux-designer.md",
53
55
  "deployer.md",
54
- "strategist.md"
56
+ "strategist.md",
57
+ "advisor.md"
55
58
  ]
56
59
  },
57
60
  "scripts": {
@@ -92,6 +95,7 @@
92
95
  "codex": ["agents/god_developer.toml"]
93
96
  },
94
97
  "runtimeFiles": [
98
+ "extensions/cafekit-bridge.mjs",
95
99
  "scripts/validate-spec-output.cjs",
96
100
  "scripts/spec-ground.cjs",
97
101
  "scripts/spec-authoring-digest.cjs",
@@ -170,6 +174,7 @@
170
174
  "hooks/lib/git.cjs",
171
175
  "hooks/lib/config.cjs",
172
176
  "hooks/lib/hook-state-dir.cjs",
177
+ "hooks/lib/plugin-options.cjs",
173
178
  "hooks/lib/runtime-dir.cjs",
174
179
  "hooks/lib/spec-touch.cjs",
175
180
  "hooks/lib/project-root.cjs",