@haposoft/cafekit 0.18.1 → 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.
- package/README.md +12 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- 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 +812 -12
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +198 -58
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- 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-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -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 +81 -21
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +147 -17
- package/bin/__tests__/omp-hooks.test.js +35 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +501 -0
- package/bin/__tests__/usage-hook-retired.test.js +29 -9
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +124 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +141 -12
- package/bin/lib/context.js +6 -1
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +93 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +134 -33
- package/bin/phases/codex-runtime.js +110 -85
- package/bin/phases/copy-payload.js +50 -4
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +5 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +213 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
- 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 +11 -7
- package/src/claude/hooks/inspect-block.cjs +20 -66
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
- package/src/claude/hooks/lib/privacy-paths.cjs +673 -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 +80 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +25 -206
- package/src/claude/hooks/rules.cjs +10 -29
- package/src/claude/hooks/session.cjs +37 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +44 -59
- package/src/claude/hooks/state.cjs +101 -52
- package/src/claude/migration-manifest.json +14 -2
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
- package/src/claude/scripts/provenance.cjs +54 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- 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/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +5 -3
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +9 -1
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +15 -8
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +116 -17
- 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/state-store.cjs +5 -4
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +40 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +11 -216
- package/src/codex/hooks/rules.cjs +14 -34
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +59 -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/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
- package/src/omp/hooks/privacy-block.cjs +26 -207
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -260,6 +260,99 @@ function parseGeneratedTomlString(content, key) {
|
|
|
260
260
|
return JSON.parse(match[1]);
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
test('installed Codex instructions reference all ten runtime rules', () => {
|
|
264
|
+
inTempProject(root => {
|
|
265
|
+
const result = install(root);
|
|
266
|
+
assert.equal(result.status, 0, result.stderr);
|
|
267
|
+
const instructions = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
|
|
268
|
+
const rules = ['workflow', 'ai-dev-rules', 'orchestrator', 'manage-docs', 'skill-workflow-routing', 'skill-domain-routing', 'review-audit-self-decision', 'process-management', 'state-sync', 'hook-protocols'];
|
|
269
|
+
for (const rule of rules) {
|
|
270
|
+
assert.ok(instructions.includes(`.codex/rules/${rule}.md`), rule);
|
|
271
|
+
assert.ok(fs.existsSync(path.join(root, '.codex/rules', `${rule}.md`)), rule);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('installed Codex orchestrator describes high reasoning on the session model', () => {
|
|
277
|
+
inTempProject(root => {
|
|
278
|
+
const source = fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/rules/orchestrator.md'));
|
|
279
|
+
const result = install(root);
|
|
280
|
+
assert.equal(result.status, 0, result.stderr);
|
|
281
|
+
const content = fs.readFileSync(path.join(root, '.codex/rules/orchestrator.md'), 'utf8');
|
|
282
|
+
assert.doesNotMatch(content, /strongest|below[^\n]*fable/);
|
|
283
|
+
assert.match(content, /high reasoning effort/);
|
|
284
|
+
assert.match(content, /session model unless[^.]*configur/i);
|
|
285
|
+
assert.deepEqual(fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/rules/orchestrator.md')), source);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('installed hook protocols share malformed-output handling and Codex receipt guidance', () => {
|
|
290
|
+
inTempProject(root => {
|
|
291
|
+
const result = installPlatforms(root, ['claude', 'codex']);
|
|
292
|
+
assert.equal(result.status, 0, result.stderr);
|
|
293
|
+
const sentence = 'If hook output is malformed, stop and report the exact failure instead of guessing.';
|
|
294
|
+
for (const runtime of ['.claude', '.codex']) {
|
|
295
|
+
const content = fs.readFileSync(path.join(root, runtime, 'rules/hook-protocols.md'), 'utf8');
|
|
296
|
+
assert.ok(content.includes(sentence), runtime);
|
|
297
|
+
}
|
|
298
|
+
assert.match(fs.readFileSync(path.join(root, '.codex/rules/hook-protocols.md'), 'utf8'), /state-sync\.md/);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('installed Claude ignore rules preserve env samples and ignore temporary files', () => {
|
|
303
|
+
inTempProject(root => {
|
|
304
|
+
const result = installPlatforms(root, ['claude']);
|
|
305
|
+
assert.equal(result.status, 0, result.stderr);
|
|
306
|
+
const lines = fs.readFileSync(path.join(root, '.claude/.gitignore'), 'utf8').split(/\r?\n/);
|
|
307
|
+
for (const line of ['!.env.sample', '!.env.template', '*.tmp']) assert.ok(lines.includes(line), line);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const READ_ONLY_ROLE_SUFFIX = '\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.';
|
|
312
|
+
|
|
313
|
+
function assertInstalledAgentFields(content, name) {
|
|
314
|
+
const source = fs.readFileSync(path.join(PACKAGE_ROOT, 'src/claude/agents', `${name.replace(/_/g, '-')}.md`), 'utf8');
|
|
315
|
+
const { frontmatter, body } = require('../lib/codex-frontmatter').splitFrontmatter(source);
|
|
316
|
+
for (const key of ['name', 'description', 'developer_instructions']) {
|
|
317
|
+
assert.equal((content.match(new RegExp(`^${key} =`, 'gm')) || []).length, 1);
|
|
318
|
+
}
|
|
319
|
+
assert.equal(parseGeneratedTomlString(content, 'name'), name);
|
|
320
|
+
assert.equal(parseGeneratedTomlString(content, 'description'), normalizeCodexBody(frontmatter.description));
|
|
321
|
+
const suffix = ['brainstormer', 'code_auditor', 'inspector', 'researcher'].includes(name)
|
|
322
|
+
? READ_ONLY_ROLE_SUFFIX : '';
|
|
323
|
+
const expectedBody = name === 'git_ops'
|
|
324
|
+
? body.slice(0, body.indexOf('When instantiated as a team member'))
|
|
325
|
+
+ body.split('\n').find(line => line.includes('NEVER arbitrarily use `force push`')).replace(/^3\. /, '- ') + '\n'
|
|
326
|
+
: body;
|
|
327
|
+
assert.equal(parseGeneratedTomlString(content, 'developer_instructions'), normalizeCodexBody(expectedBody).trim() + suffix);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const name of ['brainstormer', 'code_auditor', 'inspector', 'researcher']) {
|
|
331
|
+
test(`installed ${name} agent has exactly one read-only sandbox setting`, () => {
|
|
332
|
+
inTempProject(root => {
|
|
333
|
+
const result = install(root);
|
|
334
|
+
assert.equal(result.status, 0, result.stderr);
|
|
335
|
+
const content = fs.readFileSync(path.join(root, '.codex/agents', `${name}.toml`), 'utf8');
|
|
336
|
+
assert.deepEqual(content.match(/^sandbox_mode\s*=.*$/gm) || [], ['sandbox_mode = "read-only"']);
|
|
337
|
+
assert.ok(parseGeneratedTomlString(content, 'developer_instructions').endsWith(READ_ONLY_ROLE_SUFFIX));
|
|
338
|
+
assertInstalledAgentFields(content, name);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
test('installed implementation agents retain their sandbox defaults', () => {
|
|
344
|
+
inTempProject(root => {
|
|
345
|
+
const result = install(root);
|
|
346
|
+
assert.equal(result.status, 0, result.stderr);
|
|
347
|
+
for (const name of ['implementer', 'test_runner', 'debugger', 'git_ops']) {
|
|
348
|
+
const content = fs.readFileSync(path.join(root, '.codex/agents', `${name}.toml`), 'utf8');
|
|
349
|
+
assert.doesNotMatch(content, /^sandbox_mode\s*=/m);
|
|
350
|
+
assert.doesNotMatch(parseGeneratedTomlString(content, 'developer_instructions'), /## Read-only role/);
|
|
351
|
+
assertInstalledAgentFields(content, name);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
263
356
|
function replaceGeneratedTomlString(content, key, value) {
|
|
264
357
|
const pattern = new RegExp(`^${key} = .+$`, 'gm');
|
|
265
358
|
const matches = [...content.matchAll(pattern)];
|
|
@@ -816,7 +909,7 @@ function brainstormProjectionIssues(files) {
|
|
|
816
909
|
}
|
|
817
910
|
if (!skill.includes('Persist only approved decisions and semantics, only with user authority')
|
|
818
911
|
|| !skill.includes('redact live secrets, credentials, private keys, access tokens, and unnecessary PII;')
|
|
819
|
-
|| !framework.includes('Do not write "user selected" unless direct user text or the
|
|
912
|
+
|| !framework.includes('Do not write "user selected" unless direct user text or the question in the turn\'s final answer confirms it.')) {
|
|
820
913
|
issues.add('approval-persistence');
|
|
821
914
|
}
|
|
822
915
|
for (const [issue, clause] of Object.entries(adaptiveClauses)) {
|
|
@@ -824,9 +917,9 @@ function brainstormProjectionIssues(files) {
|
|
|
824
917
|
}
|
|
825
918
|
const visible = `${skill}\n${framework}\n${agent}`;
|
|
826
919
|
if (/\bAskUserQuestion\b/.test(visible)
|
|
827
|
-
|| /\b(?:a|an|one|each|every|another|this|that|the|[0-9]+)\s+a
|
|
828
|
-
|| /\ban
|
|
829
|
-
|| /\ba
|
|
920
|
+
|| /\b(?:a|an|one|each|every|another|this|that|the|[0-9]+)\s+a question in the turn\'s final answer\b/i.test(visible)
|
|
921
|
+
|| /\ban question in the turn\'s final answer\b/i.test(visible)
|
|
922
|
+
|| /\ba question in the turn\'s final answer\s+(?:calls|batches)\b/i.test(visible)) {
|
|
830
923
|
issues.add('projection-grammar');
|
|
831
924
|
}
|
|
832
925
|
return [...issues].sort();
|
|
@@ -1379,26 +1472,26 @@ test('Codex payload transform emits native skill and subagent syntax', () => {
|
|
|
1379
1472
|
|
|
1380
1473
|
test('Codex payload transform keeps structured user-input grammar across determiners', () => {
|
|
1381
1474
|
const cases = [
|
|
1382
|
-
['standalone bare token', 'AskUserQuestion', 'a
|
|
1383
|
-
['standalone code token', '`AskUserQuestion`', 'a
|
|
1384
|
-
['standalone multi-backtick token', '``AskUserQuestion``', 'a
|
|
1385
|
-
['standalone padded multi-backtick token', '`` AskUserQuestion ``', 'a
|
|
1386
|
-
['one', 'one AskUserQuestion call', 'one
|
|
1387
|
-
['one multi-backtick', 'one ``AskUserQuestion`` call', 'one
|
|
1388
|
-
['one padded multi-backtick', 'one `` AskUserQuestion `` call', 'one
|
|
1389
|
-
['each', 'each AskUserQuestion call', 'each
|
|
1390
|
-
['every', 'every AskUserQuestion batch', 'every
|
|
1391
|
-
['another', 'another AskUserQuestion call', 'another
|
|
1392
|
-
['this', 'this AskUserQuestion call', 'this
|
|
1393
|
-
['that', 'that AskUserQuestion call', 'that
|
|
1394
|
-
['a', 'a AskUserQuestion call', 'a
|
|
1395
|
-
['an', 'an AskUserQuestion call', 'a
|
|
1396
|
-
['capitalized each', 'Each AskUserQuestion call', 'Each
|
|
1397
|
-
['capitalized an', 'An AskUserQuestion call', 'A
|
|
1398
|
-
['the', 'the AskUserQuestion call', 'the
|
|
1399
|
-
['numeric', '27 AskUserQuestion calls', '27
|
|
1400
|
-
['plural calls', 'AskUserQuestion calls', '
|
|
1401
|
-
['plural batches', 'AskUserQuestion batches', '
|
|
1475
|
+
['standalone bare token', 'AskUserQuestion', 'a question in the turn\'s final answer'],
|
|
1476
|
+
['standalone code token', '`AskUserQuestion`', 'a question in the turn\'s final answer'],
|
|
1477
|
+
['standalone multi-backtick token', '``AskUserQuestion``', 'a question in the turn\'s final answer'],
|
|
1478
|
+
['standalone padded multi-backtick token', '`` AskUserQuestion ``', 'a question in the turn\'s final answer'],
|
|
1479
|
+
['one', 'one AskUserQuestion call', 'one question in the turn\'s final answer call'],
|
|
1480
|
+
['one multi-backtick', 'one ``AskUserQuestion`` call', 'one question in the turn\'s final answer call'],
|
|
1481
|
+
['one padded multi-backtick', 'one `` AskUserQuestion `` call', 'one question in the turn\'s final answer call'],
|
|
1482
|
+
['each', 'each AskUserQuestion call', 'each question in the turn\'s final answer call'],
|
|
1483
|
+
['every', 'every AskUserQuestion batch', 'every question in the turn\'s final answer batch'],
|
|
1484
|
+
['another', 'another AskUserQuestion call', 'another question in the turn\'s final answer call'],
|
|
1485
|
+
['this', 'this AskUserQuestion call', 'this question in the turn\'s final answer call'],
|
|
1486
|
+
['that', 'that AskUserQuestion call', 'that question in the turn\'s final answer call'],
|
|
1487
|
+
['a', 'a AskUserQuestion call', 'a question in the turn\'s final answer call'],
|
|
1488
|
+
['an', 'an AskUserQuestion call', 'a question in the turn\'s final answer call'],
|
|
1489
|
+
['capitalized each', 'Each AskUserQuestion call', 'Each question in the turn\'s final answer call'],
|
|
1490
|
+
['capitalized an', 'An AskUserQuestion call', 'A question in the turn\'s final answer call'],
|
|
1491
|
+
['the', 'the AskUserQuestion call', 'the question in the turn\'s final answer call'],
|
|
1492
|
+
['numeric', '27 AskUserQuestion calls', '27 question in the turn\'s final answer calls'],
|
|
1493
|
+
['plural calls', 'AskUserQuestion calls', 'question in the turn\'s final answer calls'],
|
|
1494
|
+
['plural batches', 'AskUserQuestion batches', 'question in the turn\'s final answer batches']
|
|
1402
1495
|
];
|
|
1403
1496
|
|
|
1404
1497
|
for (const [name, input, expected] of cases) {
|
|
@@ -1406,27 +1499,27 @@ test('Codex payload transform keeps structured user-input grammar across determi
|
|
|
1406
1499
|
assert.equal(actual, expected, name);
|
|
1407
1500
|
assert.equal(normalizeCodexBody(actual, '/fixture/instructions.md'), expected, `${name} idempotence`);
|
|
1408
1501
|
assert.doesNotMatch(actual, /\bAskUserQuestion\b/, `${name} Claude-only token`);
|
|
1409
|
-
assert.doesNotMatch(actual, /\ban
|
|
1502
|
+
assert.doesNotMatch(actual, /\ban question in the turn\'s final answer\b/i, `${name} incompatible article`);
|
|
1410
1503
|
assert.doesNotMatch(
|
|
1411
1504
|
actual,
|
|
1412
|
-
/\b(?:a|an|one|each|every|another|this|that|the|[0-9]+)\s+a
|
|
1505
|
+
/\b(?:a|an|one|each|every|another|this|that|the|[0-9]+)\s+a question in the turn\'s final answer\b/i,
|
|
1413
1506
|
`${name} doubled article`
|
|
1414
1507
|
);
|
|
1415
1508
|
}
|
|
1416
1509
|
|
|
1417
1510
|
assert.equal(
|
|
1418
1511
|
normalizeCodexBody('one\nAskUserQuestion call', '/fixture/instructions.md'),
|
|
1419
|
-
'one\
|
|
1512
|
+
'one\nquestion in the turn\'s final answer call',
|
|
1420
1513
|
'a single Markdown soft break must preserve its determiner relationship'
|
|
1421
1514
|
);
|
|
1422
1515
|
assert.equal(
|
|
1423
1516
|
normalizeCodexBody('AskUserQuestion\nCalls', '/fixture/instructions.md'),
|
|
1424
|
-
'
|
|
1517
|
+
'question in the turn\'s final answer\nCalls',
|
|
1425
1518
|
'a single Markdown soft break must preserve a plural relationship'
|
|
1426
1519
|
);
|
|
1427
1520
|
assert.equal(
|
|
1428
1521
|
normalizeCodexBody('one\n\nAskUserQuestion call', '/fixture/instructions.md'),
|
|
1429
|
-
'one\n\na
|
|
1522
|
+
'one\n\na question in the turn\'s final answer call',
|
|
1430
1523
|
'a paragraph break must not bind a prior determiner'
|
|
1431
1524
|
);
|
|
1432
1525
|
assert.equal(
|
|
@@ -1504,9 +1597,9 @@ test('Codex structured-input corpus oracle stays differential and production-awa
|
|
|
1504
1597
|
assert.deepEqual(actualPaths, expectedPaths, 'every source occurrence needs an explicit projection oracle');
|
|
1505
1598
|
|
|
1506
1599
|
const instructionExpectedSnippets = new Map([
|
|
1507
|
-
['src/claude/skills/git/SKILL.md', 'Present options via a
|
|
1508
|
-
['src/claude/skills/scout/SKILL.md', '**Fallback to a
|
|
1509
|
-
['src/claude/skills/sync/references/rebind-and-audit.md', 'End by asking with a
|
|
1600
|
+
['src/claude/skills/git/SKILL.md', 'Present options via a question in the turn\'s final answer — header'],
|
|
1601
|
+
['src/claude/skills/scout/SKILL.md', '**Fallback to a question in the turn\'s final answer:**'],
|
|
1602
|
+
['src/claude/skills/sync/references/rebind-and-audit.md', 'End by asking with a question in the turn\'s final answer when the host has']
|
|
1510
1603
|
]);
|
|
1511
1604
|
for (const [relativePath, expectedSnippet] of instructionExpectedSnippets) {
|
|
1512
1605
|
const sourcePath = path.join(PACKAGE_ROOT, relativePath);
|
|
@@ -1515,7 +1608,7 @@ test('Codex structured-input corpus oracle stays differential and production-awa
|
|
|
1515
1608
|
const withSentinel = source.replace(/`?\bAskUserQuestion\b`?/g, sentinel);
|
|
1516
1609
|
assert.notEqual(withSentinel, source, `${relativePath} differential control must replace a token`);
|
|
1517
1610
|
const expected = normalizeCodexBody(withSentinel, sourcePath)
|
|
1518
|
-
.replaceAll(sentinel, 'a
|
|
1611
|
+
.replaceAll(sentinel, 'a question in the turn\'s final answer');
|
|
1519
1612
|
const actual = normalizeCodexBody(source, sourcePath);
|
|
1520
1613
|
assert.equal(actual, expected, `${relativePath} independent differential oracle`);
|
|
1521
1614
|
assert.match(actual, new RegExp(expectedSnippet.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
@@ -1524,7 +1617,7 @@ test('Codex structured-input corpus oracle stays differential and production-awa
|
|
|
1524
1617
|
const specMaker = fs.readFileSync(SPEC_MAKER_SOURCE_PATH, 'utf8');
|
|
1525
1618
|
const convertedAgent = convertCodexAgentContent(specMaker, path.basename(SPEC_MAKER_SOURCE_PATH));
|
|
1526
1619
|
assert.doesNotMatch(convertedAgent, /\bAskUserQuestion\b/);
|
|
1527
|
-
assert.doesNotMatch(convertedAgent, /
|
|
1620
|
+
assert.doesNotMatch(convertedAgent, /question in the turn\'s final answer/);
|
|
1528
1621
|
|
|
1529
1622
|
const sessionPath = path.join(PACKAGE_ROOT, 'src/claude/hooks/session.cjs');
|
|
1530
1623
|
const session = normalizeCodexBody(fs.readFileSync(sessionPath, 'utf8'), sessionPath);
|
|
@@ -1996,6 +2089,45 @@ function sessionLauncher(projectRoot) {
|
|
|
1996
2089
|
return { config, command: config.hooks.SessionStart[0].hooks[0].command };
|
|
1997
2090
|
}
|
|
1998
2091
|
|
|
2092
|
+
test('parity regression: moved Codex checkout rebinds both launcher fields and preserves metadata', () => {
|
|
2093
|
+
inTempProject((root) => {
|
|
2094
|
+
const oldRoot = path.join(root, "old ' quoted project"), newRoot = path.join(root, "new ' quoted project");
|
|
2095
|
+
fs.mkdirSync(oldRoot);
|
|
2096
|
+
assert.equal(install(oldRoot).status, 0);
|
|
2097
|
+
const configPath = path.join(oldRoot, '.codex/hooks.json');
|
|
2098
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
2099
|
+
config.hooks.SessionStart[0].matcher = 'custom-startup';
|
|
2100
|
+
config.hooks.SessionStart[0].hooks[0].timeout = 41;
|
|
2101
|
+
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
2102
|
+
fs.renameSync(oldRoot, newRoot);
|
|
2103
|
+
const movedConfig = path.join(newRoot, '.codex/hooks.json');
|
|
2104
|
+
const before = fs.readFileSync(movedConfig, 'utf8');
|
|
2105
|
+
assert.equal(install(newRoot, ['--dry-run']).status, 0);
|
|
2106
|
+
assert.equal(fs.readFileSync(movedConfig, 'utf8'), before, 'dry run modified launchers');
|
|
2107
|
+
const upgraded = install(newRoot);
|
|
2108
|
+
assert.equal(upgraded.status, 0, `${upgraded.stdout}\n${upgraded.stderr}`);
|
|
2109
|
+
const rebound = sessionLauncher(newRoot);
|
|
2110
|
+
assert.equal(rebound.config.hooks.SessionStart[0].matcher, 'custom-startup');
|
|
2111
|
+
assert.equal(rebound.config.hooks.SessionStart[0].hooks[0].timeout, 41);
|
|
2112
|
+
const launched = runPosixLauncher(rebound.command, newRoot);
|
|
2113
|
+
assert.equal(launched.status, 0, launched.stderr);
|
|
2114
|
+
assert.match(launched.stdout, /Session startup\./);
|
|
2115
|
+
const windowsCommand = rebound.config.hooks.SessionStart[0].hooks[0].commandWindows.replace(/^node /, `"${process.execPath}" `);
|
|
2116
|
+
const launchedWindows = spawnSync(windowsCommand, { cwd: newRoot, encoding: 'utf8', input: JSON.stringify({ session_id: 'moved-windows', hook_event_name: 'SessionStart', source: 'startup', cwd: newRoot }), shell: true });
|
|
2117
|
+
assert.equal(launchedWindows.status, 0, launchedWindows.stderr);
|
|
2118
|
+
assert.match(launchedWindows.stdout, /Session startup\./);
|
|
2119
|
+
for (const { handler } of allHookLaunchers(rebound.config)) {
|
|
2120
|
+
const encoded = handler.commandWindows.match(/\s([A-Za-z0-9_-]+)$/)?.[1];
|
|
2121
|
+
assert.ok(encoded);
|
|
2122
|
+
const target = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
2123
|
+
assert.equal(path.dirname(target), fs.realpathSync(path.join(newRoot, '.codex/hooks')));
|
|
2124
|
+
}
|
|
2125
|
+
const stable = fs.readFileSync(movedConfig, 'utf8');
|
|
2126
|
+
assert.equal(install(newRoot).status, 0);
|
|
2127
|
+
assert.equal(fs.readFileSync(movedConfig, 'utf8'), stable, 'reinstall changed stable hooks config');
|
|
2128
|
+
});
|
|
2129
|
+
});
|
|
2130
|
+
|
|
1999
2131
|
test('Codex POSIX hook launchers run without Git and outside the repository root', () => {
|
|
2000
2132
|
inTempProject((root) => {
|
|
2001
2133
|
// A launcher that asks Git for its root gets nothing here and the outer root one
|
|
@@ -2194,7 +2326,10 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
|
|
|
2194
2326
|
);
|
|
2195
2327
|
assert.equal(catalog.status, 0, catalog.stderr);
|
|
2196
2328
|
const catalogData = JSON.parse(catalog.stdout);
|
|
2197
|
-
|
|
2329
|
+
// Explicit-only skills run only when named, so the catalog leaves them out of automatic routing.
|
|
2330
|
+
const explicitOnly = skillFiles.filter((file) => /^disable-model-invocation:\s*true\s*$/m.test(fs.readFileSync(file, 'utf8')));
|
|
2331
|
+
assert.ok(explicitOnly.length >= 1);
|
|
2332
|
+
assert.equal(catalogData.total, skillNames.length - explicitOnly.length);
|
|
2198
2333
|
assert.equal(
|
|
2199
2334
|
catalogData.root,
|
|
2200
2335
|
fs.realpathSync(path.join(root, '.agents', 'skills'))
|
|
@@ -2248,7 +2383,7 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
|
|
|
2248
2383
|
const questionSkill = path.join(root, '.agents', 'skills', 'ask', 'SKILL.md');
|
|
2249
2384
|
fs.appendFileSync(questionSkill, '\nUSER-CODEX-SENTINEL\n');
|
|
2250
2385
|
// Fresh installs are exact. Refresh/upgrade intentionally documents the
|
|
2251
|
-
//
|
|
2386
|
+
// Untracked obsolete files and edited current skills both stay on refresh.
|
|
2252
2387
|
const obsoleteSpecsRule = path.join(
|
|
2253
2388
|
root, '.agents', 'skills', 'specs', 'rules', 'design-principles.md'
|
|
2254
2389
|
);
|
|
@@ -2261,8 +2396,8 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
|
|
|
2261
2396
|
assert.match(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
|
|
2262
2397
|
assert.equal(
|
|
2263
2398
|
fs.existsSync(obsoleteSpecsRule),
|
|
2264
|
-
|
|
2265
|
-
'Codex refresh
|
|
2399
|
+
true,
|
|
2400
|
+
'Codex refresh preserves untracked obsolete skill files'
|
|
2266
2401
|
);
|
|
2267
2402
|
|
|
2268
2403
|
const metadataPath = path.join(root, '.codex', 'cafekit.json');
|
|
@@ -2277,9 +2412,11 @@ test('Codex installed Specs and spec-maker reject adaptive coverage mutations',
|
|
|
2277
2412
|
assert.match(fs.readFileSync(questionSkill, 'utf8'), /USER-CODEX-SENTINEL/);
|
|
2278
2413
|
assert.equal(
|
|
2279
2414
|
fs.existsSync(obsoleteSpecsRule),
|
|
2280
|
-
|
|
2281
|
-
'Codex upgrade
|
|
2415
|
+
true,
|
|
2416
|
+
'Codex upgrade preserves untracked obsolete skill files'
|
|
2282
2417
|
);
|
|
2418
|
+
assert.match(sameVersion.stdout + sameVersion.stderr, /Preserved 1 obsolete runtime/);
|
|
2419
|
+
assert.match(upgrade.stdout + upgrade.stderr, /Preserved 1 obsolete runtime/);
|
|
2283
2420
|
assert.ok(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8').startsWith(userInstructions));
|
|
2284
2421
|
|
|
2285
2422
|
const forced = install(root, ['--force-overwrite']);
|
|
@@ -2509,7 +2646,7 @@ test('Codex installed Brainstorm skill reference and agent preserve proportional
|
|
|
2509
2646
|
{
|
|
2510
2647
|
name: 'decision-provenance-removed',
|
|
2511
2648
|
source: 'framework',
|
|
2512
|
-
from: 'Do not write "user selected" unless direct user text or the
|
|
2649
|
+
from: 'Do not write "user selected" unless direct user text or the question in the turn\'s final answer\n confirms it.',
|
|
2513
2650
|
to: 'Write "user selected" for inferred defaults.',
|
|
2514
2651
|
expected: ['approval-persistence']
|
|
2515
2652
|
},
|
|
@@ -2517,7 +2654,7 @@ test('Codex installed Brainstorm skill reference and agent preserve proportional
|
|
|
2517
2654
|
name: 'installed-grammar-corruption',
|
|
2518
2655
|
source: 'skill',
|
|
2519
2656
|
from: '## Completion bar',
|
|
2520
|
-
to: 'one a
|
|
2657
|
+
to: 'one a question in the turn\'s final answer call\n\n## Completion bar',
|
|
2521
2658
|
expected: ['projection-grammar']
|
|
2522
2659
|
}
|
|
2523
2660
|
];
|
|
@@ -3079,28 +3216,32 @@ test('Codex installed code_auditor carries no Strict attestation marker', () =>
|
|
|
3079
3216
|
});
|
|
3080
3217
|
});
|
|
3081
3218
|
|
|
3082
|
-
test('reinstall
|
|
3083
|
-
inTempProject(
|
|
3219
|
+
test('reinstall preserves untracked Codex and omp runtime files', () => {
|
|
3220
|
+
for (const platform of ['codex', 'omp']) inTempProject(root => {
|
|
3084
3221
|
const first = installPlatforms(root, ['codex', 'omp']);
|
|
3085
3222
|
assert.equal(first.status, 0, `${first.stdout}\n${first.stderr}`);
|
|
3223
|
+
const runtime = `.${platform}`;
|
|
3086
3224
|
const retired = ['change-firewall.cjs', 'spec-final-state.cjs'];
|
|
3087
|
-
for (const
|
|
3088
|
-
|
|
3089
|
-
}
|
|
3225
|
+
for (const name of retired) fs.writeFileSync(path.join(root, runtime, 'scripts', name), 'legacy\n');
|
|
3226
|
+
// omp installs no skills, so a reinstall of omp alone must leave .agents/skills to Codex.
|
|
3090
3227
|
const retiredSkillFiles = ['code-review/references/verification-gate.md', 'specs/references/archive-workflow.md'];
|
|
3091
|
-
for (const rel of retiredSkillFiles) fs.writeFileSync(path.join(root, '.agents', 'skills', rel), 'legacy\n');
|
|
3092
|
-
const again = installPlatforms(root, ['codex', 'omp'], ['--force-overwrite']);
|
|
3093
|
-
assert.equal(again.status, 0, `${again.stdout}\n${again.stderr}`);
|
|
3094
3228
|
for (const rel of retiredSkillFiles) {
|
|
3095
|
-
|
|
3229
|
+
const target = path.join(root, '.agents/skills', rel);
|
|
3230
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
3231
|
+
fs.writeFileSync(target, 'legacy\n');
|
|
3096
3232
|
}
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3233
|
+
const again = installPlatforms(root, [platform], ['--force-overwrite']);
|
|
3234
|
+
assert.equal(again.status, 0, `${again.stdout}\n${again.stderr}`);
|
|
3235
|
+
const output = again.stdout + again.stderr;
|
|
3236
|
+
for (const rel of retiredSkillFiles) assert.equal(fs.existsSync(path.join(root, '.agents/skills', rel)), true, rel);
|
|
3237
|
+
for (const name of retired) assert.equal(fs.existsSync(path.join(root, runtime, 'scripts', name)), true, name);
|
|
3238
|
+
if (platform === 'codex') assert.match(output, /Preserved 4 obsolete runtime/);
|
|
3239
|
+
else {
|
|
3240
|
+
assert.match(output, /Preserved 2 obsolete runtime/);
|
|
3241
|
+
assert.doesNotMatch(output, /\.agents\/skills/);
|
|
3103
3242
|
}
|
|
3243
|
+
assert.equal(fs.existsSync(path.join(root, '.agents/skills/code-review/SKILL.md')), true);
|
|
3244
|
+
assert.equal(fs.existsSync(path.join(root, runtime, 'scripts/spec-resolver.cjs')), true);
|
|
3104
3245
|
});
|
|
3105
3246
|
});
|
|
3106
3247
|
|
|
@@ -3222,4 +3363,3 @@ test('Claude and Codex installed rules preserve the ported review and process gu
|
|
|
3222
3363
|
}
|
|
3223
3364
|
});
|
|
3224
3365
|
});
|
|
3225
|
-
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, before, after } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const REPO = path.resolve(__dirname, '../../../..');
|
|
11
|
+
const SRC = path.resolve(__dirname, '../../src');
|
|
12
|
+
const SCRIPT = path.resolve(__dirname, '../../scripts/build-codex-plugin.mjs');
|
|
13
|
+
let lib;
|
|
14
|
+
let claudeLib;
|
|
15
|
+
let work;
|
|
16
|
+
let tree;
|
|
17
|
+
|
|
18
|
+
function walk(root, base = root) {
|
|
19
|
+
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
20
|
+
const full = path.join(root, entry.name);
|
|
21
|
+
return entry.isDirectory() ? walk(full, base) : [path.relative(base, full)];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
before(async () => {
|
|
26
|
+
lib = await import(SCRIPT);
|
|
27
|
+
claudeLib = await import(path.resolve(__dirname, '../../scripts/build-claude-plugin.mjs'));
|
|
28
|
+
work = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-plugin-build-'));
|
|
29
|
+
tree = lib.build(path.join(work, 'cf-codex'));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
after(() => fs.rmSync(work, { recursive: true, force: true }));
|
|
33
|
+
|
|
34
|
+
test('inventory-and-policy: the Claude plugin skill list, Codex names, and explicit-only policies', () => {
|
|
35
|
+
const skills = fs.readdirSync(path.join(tree, 'skills')).sort();
|
|
36
|
+
// The Claude plugin's skills plus the Codex-only ones.
|
|
37
|
+
assert.deepEqual(skills, [...claudeLib.inScopeSkills(), ...lib.CODEX_ONLY_SKILLS].sort());
|
|
38
|
+
const { isExplicitOnlySkill, CODEX_POLICY_FILE, CODEX_EXPLICIT_ONLY_POLICY } = require('../phases/copy-payload');
|
|
39
|
+
for (const skill of skills) {
|
|
40
|
+
const text = fs.readFileSync(path.join(tree, 'skills', skill, 'SKILL.md'), 'utf8');
|
|
41
|
+
assert.match(text, new RegExp(`^---\\nname: cf-${skill}\\n`), `${skill} keeps the installer's Codex name`);
|
|
42
|
+
const policy = path.join(tree, 'skills', skill, CODEX_POLICY_FILE);
|
|
43
|
+
const skillSource = lib.CODEX_ONLY_SKILLS.includes(skill) ? path.join(SRC, 'codex/plugin/skills', skill) : path.join(SRC, 'claude/skills', skill);
|
|
44
|
+
if (isExplicitOnlySkill(skillSource)) {
|
|
45
|
+
assert.equal(fs.readFileSync(policy, 'utf8'), CODEX_EXPLICIT_ONLY_POLICY, `${skill} policy`);
|
|
46
|
+
} else {
|
|
47
|
+
assert.ok(!fs.existsSync(policy), `${skill} has an unexpected policy file`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('path-allowlist: Markdown reaches plugin files through {CAFEKIT_ROOT}, never an install path', () => {
|
|
53
|
+
const offenders = [];
|
|
54
|
+
for (const file of walk(tree).filter((f) => f.endsWith('.md'))) {
|
|
55
|
+
const text = fs.readFileSync(path.join(tree, file), 'utf8');
|
|
56
|
+
if (/\.codex[/\\](skills|references|scripts|rules|hooks|output-styles)\b/.test(text)) offenders.push(`${file}: .codex runtime path`);
|
|
57
|
+
if (/\.agents[/\\]skills\b/.test(text)) offenders.push(`${file}: .agents/skills path`);
|
|
58
|
+
if (/(?<!~\/)\.codex\/agents\//.test(text)) offenders.push(`${file}: project .codex/agents path`);
|
|
59
|
+
if (/\.claude[/\\](skills|references|scripts|rules)\b/.test(text)) offenders.push(`${file}: .claude runtime path`);
|
|
60
|
+
if (/\{CAFEKIT_ROOT\}\/scripts\//.test(text)) offenders.push(`${file}: script reached by path instead of {cafekit:<name>}`);
|
|
61
|
+
for (const [, rel] of text.matchAll(/\{CAFEKIT_ROOT\}\/([^\s`'")]+)/g)) {
|
|
62
|
+
const target = rel.replace(/[.,:;]+$/, '');
|
|
63
|
+
if (/[<*]/.test(target)) continue;
|
|
64
|
+
if (!fs.existsSync(path.join(tree, target))) offenders.push(`${file}: {CAFEKIT_ROOT}/${target} does not exist`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
assert.deepEqual(offenders, []);
|
|
68
|
+
const commands = lib.scriptCommands(tree);
|
|
69
|
+
assert.ok(commands.includes('provenance'), 'provenance is not in the command table');
|
|
70
|
+
for (const name of commands) assert.ok(fs.existsSync(path.join(tree, 'scripts', `${name}.cjs`)), `${name} has no script`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('manifest-and-marketplace: plugin.json and the repo marketplace carry the keys Codex reads', () => {
|
|
74
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(tree, '.codex-plugin/plugin.json'), 'utf8'));
|
|
75
|
+
assert.equal(manifest.name, 'cf');
|
|
76
|
+
assert.equal(manifest.version, require('../../package.json').version);
|
|
77
|
+
for (const key of ['description', 'author', 'license', 'interface']) assert.ok(manifest[key], `plugin.json lacks ${key}`);
|
|
78
|
+
const market = lib.marketplace();
|
|
79
|
+
assert.equal(typeof market.name, 'string');
|
|
80
|
+
const entry = market.plugins.find((p) => p.name === 'cf');
|
|
81
|
+
assert.deepEqual(entry.source, { source: 'local', path: './plugins/cf-codex' });
|
|
82
|
+
assert.ok(entry.policy?.installation && entry.policy?.authentication && entry.category, 'entry lacks policy or category');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('marketplace-not-ignored: git can commit .agents/plugins/marketplace.json', () => {
|
|
86
|
+
// --no-index: a tracked file would otherwise always read as not ignored.
|
|
87
|
+
const run = spawnSync('git', ['check-ignore', '-q', '--no-index', '.agents/plugins/marketplace.json'], { cwd: REPO });
|
|
88
|
+
assert.equal(run.status, 1, 'the marketplace file is ignored by git');
|
|
89
|
+
const skills = spawnSync('git', ['check-ignore', '-q', '--no-index', '.agents/skills/specs/SKILL.md'], { cwd: REPO });
|
|
90
|
+
assert.equal(skills.status, 0, 'npx-installed .agents/skills must stay ignored');
|
|
91
|
+
// A reinstall must not append `.agents/` after `!.agents/plugins/` and hide the marketplace again.
|
|
92
|
+
const { hasPattern } = require('../phases/root-config');
|
|
93
|
+
assert.ok(hasPattern(['.agents/*', '!.agents/plugins/'], '.agents/'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('claude-marketplace-guard: the Codex marketplace never points at the Claude plugin tree', () => {
|
|
97
|
+
const entry = lib.marketplace().plugins.find((p) => p.name === 'cf');
|
|
98
|
+
assert.notEqual(path.normalize(entry.source.path), path.normalize('./plugins/cf'));
|
|
99
|
+
assert.ok(!fs.existsSync(path.join(REPO, 'plugins/cf/.codex-plugin')), 'the Claude tree must not carry a Codex manifest');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('check-detects-drift: --check fails after one byte changes in a fresh copy', () => {
|
|
103
|
+
const copy = lib.build(path.join(work, 'copy'));
|
|
104
|
+
const clean = spawnSync('node', [SCRIPT, '--check', copy], { encoding: 'utf8' });
|
|
105
|
+
assert.equal(clean.status, 0, clean.stderr);
|
|
106
|
+
fs.appendFileSync(path.join(copy, 'skills/specs/SKILL.md'), 'x');
|
|
107
|
+
const drift = spawnSync('node', [SCRIPT, '--check', copy], { encoding: 'utf8' });
|
|
108
|
+
assert.equal(drift.status, 1);
|
|
109
|
+
assert.match(drift.stderr, /changed skills\/specs\/SKILL\.md/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── Runtime and hooks.json ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const RUNTIME_DIRS = ['hooks', 'rules', 'output-styles'];
|
|
115
|
+
|
|
116
|
+
function npxInstall() {
|
|
117
|
+
const repo = fs.mkdtempSync(path.join(work, 'npx-'));
|
|
118
|
+
spawnSync('git', ['init', '-q'], { cwd: repo });
|
|
119
|
+
const run = spawnSync('node', [path.resolve(__dirname, '../install.js'), '--platform', 'codex', '-y'], {
|
|
120
|
+
cwd: repo, encoding: 'utf8', env: { ...process.env, HOME: fs.mkdtempSync(path.join(work, 'home-')) },
|
|
121
|
+
});
|
|
122
|
+
assert.equal(run.status, 0, run.stderr || run.stdout);
|
|
123
|
+
return repo;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function runtimeFiles(root) {
|
|
127
|
+
return RUNTIME_DIRS.flatMap((dir) => (fs.existsSync(path.join(root, dir)) ? walk(path.join(root, dir)).map((f) => `${dir}/${f}`) : []))
|
|
128
|
+
.concat(fs.existsSync(path.join(root, 'runtime.schema.json')) ? ['runtime.schema.json'] : [])
|
|
129
|
+
.filter((f) => f !== 'hooks/hooks.json' && !f.includes('/.logs/'))
|
|
130
|
+
.sort();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test('runtime-matches-npx: the plugin runtime holds the same files and bytes as an npx Codex install', () => {
|
|
134
|
+
const installed = path.join(npxInstall(), '.codex');
|
|
135
|
+
const expected = runtimeFiles(installed);
|
|
136
|
+
assert.deepEqual(runtimeFiles(tree), expected);
|
|
137
|
+
for (const file of expected) {
|
|
138
|
+
assert.ok(fs.readFileSync(path.join(tree, file)).equals(fs.readFileSync(path.join(installed, file))), `${file} differs from the npx install`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('requires-resolve: every relative require in the plugin hooks resolves inside the tree', () => {
|
|
143
|
+
const missing = [];
|
|
144
|
+
for (const file of walk(path.join(tree, 'hooks')).filter((f) => f.endsWith('.cjs'))) {
|
|
145
|
+
const full = path.join(tree, 'hooks', file);
|
|
146
|
+
for (const [, spec] of fs.readFileSync(full, 'utf8').matchAll(/require\(\s*['"](\.{1,2}\/[^'"]+)['"]\s*\)/g)) {
|
|
147
|
+
const target = path.resolve(path.dirname(full), spec);
|
|
148
|
+
if (![target, `${target}.cjs`, `${target}.js`].some((t) => fs.existsSync(t))) missing.push(`hooks/${file} → ${spec}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
assert.deepEqual(missing, []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function pluginHandlers() {
|
|
155
|
+
const { hooks } = JSON.parse(fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8'));
|
|
156
|
+
return Object.entries(hooks).flatMap(([event, groups]) => groups.flatMap((group) => group.hooks.map((h) => ({ event, ...h }))));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test('hook-commands-fallback: every plugin hook has a POSIX command with a node PATH fallback and a Windows command', () => {
|
|
160
|
+
const template = JSON.parse(fs.readFileSync(path.join(SRC, 'codex/hooks.json'), 'utf8')).hooks;
|
|
161
|
+
const handlers = pluginHandlers();
|
|
162
|
+
// The template's runtime hooks plus the one plugin-only SessionStart hook.
|
|
163
|
+
const pluginOnly = handlers.filter((h) => h.command.includes('/plugin/hooks/'));
|
|
164
|
+
assert.deepEqual(pluginOnly.map((h) => h.event), ['SessionStart']);
|
|
165
|
+
assert.equal(handlers.length - pluginOnly.length, Object.values(template).flat().flatMap((g) => g.hooks).length);
|
|
166
|
+
for (const handler of handlers) {
|
|
167
|
+
const [, dir, script] = handler.command.match(/((?:plugin\/)?hooks)\/([a-z0-9-]+\.cjs)"$/) || [];
|
|
168
|
+
assert.ok(script && fs.existsSync(path.join(tree, dir, script)), `bad command ${handler.command}`);
|
|
169
|
+
assert.match(handler.command, /^PATH="\$PATH":'[^']+'(:'[^']+')* node "\$\{PLUGIN_ROOT\}\/(plugin\/)?hooks\/[a-z0-9-]+\.cjs"$/);
|
|
170
|
+
assert.equal(typeof handler.commandWindows, 'string');
|
|
171
|
+
assert.ok(handler.commandWindows.includes(script) && handler.commandWindows.includes('PLUGIN_ROOT'), handler.commandWindows);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('no-build-machine-paths: hooks.json names no absolute path of the machine that built it', () => {
|
|
176
|
+
const text = fs.readFileSync(path.join(tree, 'hooks/hooks.json'), 'utf8');
|
|
177
|
+
for (const local of [REPO, os.homedir(), path.dirname(process.execPath)]) assert.ok(!text.includes(local), `hooks.json contains ${local}`);
|
|
178
|
+
const windows = pluginHandlers()[0].commandWindows;
|
|
179
|
+
const decoded = spawnSync('node', ['-e', `${windows.replace(/^node -e "|"$/g, '').replace(/require\('module'\)\.runMain\(\)/, 'console.log(process.argv[1])')}`], {
|
|
180
|
+
encoding: 'utf8', env: { ...process.env, PLUGIN_ROOT: path.join(work, 'win-root') },
|
|
181
|
+
});
|
|
182
|
+
assert.equal(decoded.stdout.trim(), path.join(work, 'win-root', 'hooks', pluginHandlers()[0].command.match(/hooks\/([a-z0-9-]+\.cjs)"$/)[1]));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('npx-hook-commands-unchanged: the npx install still gets absolute project launchers', () => {
|
|
186
|
+
const repo = npxInstall();
|
|
187
|
+
const { hooks } = JSON.parse(fs.readFileSync(path.join(repo, '.codex/hooks.json'), 'utf8'));
|
|
188
|
+
for (const handler of Object.values(hooks).flat().flatMap((g) => g.hooks)) {
|
|
189
|
+
assert.match(handler.command, /node '[^']+\/\.codex\/hooks\/[a-z0-9-]+\.cjs'$/);
|
|
190
|
+
assert.ok(handler.command.includes(fs.realpathSync(repo)));
|
|
191
|
+
assert.ok(!handler.command.includes('PLUGIN_ROOT'));
|
|
192
|
+
const encoded = handler.commandWindows.split(' ').pop();
|
|
193
|
+
assert.ok(Buffer.from(encoded, 'base64url').toString('utf8').startsWith(path.join(fs.realpathSync(repo), '.codex', 'hooks')), 'npx Windows launcher lost its project path');
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const SAMPLE_PAYLOADS = {
|
|
198
|
+
SessionStart: { source: 'startup' },
|
|
199
|
+
UserPromptSubmit: { prompt: 'hello' },
|
|
200
|
+
PreToolUse: { tool_name: 'Bash', tool_input: { command: 'ls' } },
|
|
201
|
+
PermissionRequest: { tool_name: 'Bash', tool_input: { command: 'ls' } },
|
|
202
|
+
PostToolUse: { tool_name: 'apply_patch', tool_input: { command: '' }, tool_response: {} },
|
|
203
|
+
Stop: { stop_hook_active: false },
|
|
204
|
+
SubagentStart: { agent_type: 'debugger' },
|
|
205
|
+
SubagentStop: { agent_type: 'debugger', stop_hook_active: false },
|
|
206
|
+
PreCompact: { trigger: 'manual' },
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
test('hook-smoke: every generated plugin launcher runs its hook to exit 0 without a crash log', () => {
|
|
210
|
+
// A private root holding the tree, so every crash log a hook writes lands under it.
|
|
211
|
+
const sandbox = fs.mkdtempSync(path.join(work, 'smoke-'));
|
|
212
|
+
const smokeTree = lib.build(path.join(sandbox, 'plugin', 'cf-codex'));
|
|
213
|
+
const repo = path.join(sandbox, 'repo');
|
|
214
|
+
fs.mkdirSync(repo);
|
|
215
|
+
spawnSync('git', ['init', '-q'], { cwd: repo });
|
|
216
|
+
const data = path.join(sandbox, 'data');
|
|
217
|
+
const failures = [];
|
|
218
|
+
for (const handler of pluginHandlers()) {
|
|
219
|
+
const payload = { session_id: 'codex-smoke', hook_event_name: handler.event, cwd: repo, ...(SAMPLE_PAYLOADS[handler.event] || {}) };
|
|
220
|
+
const run = spawnSync('sh', ['-c', handler.command], {
|
|
221
|
+
cwd: repo, input: JSON.stringify(payload), encoding: 'utf8', timeout: 20000,
|
|
222
|
+
env: { ...process.env, HOME: path.join(sandbox, 'home'), PLUGIN_ROOT: smokeTree, PLUGIN_DATA: data, CLAUDE_PLUGIN_ROOT: smokeTree, CLAUDE_PLUGIN_DATA: data },
|
|
223
|
+
});
|
|
224
|
+
if (run.status !== 0) failures.push(`${handler.event} ${handler.command}: exit ${run.status} ${run.stderr.slice(0, 200)}`);
|
|
225
|
+
}
|
|
226
|
+
const crashLogs = walk(sandbox).filter((f) => f.endsWith('hook-log.jsonl'))
|
|
227
|
+
.filter((f) => /"status":"crash"/.test(fs.readFileSync(path.join(sandbox, f), 'utf8')));
|
|
228
|
+
for (const log of crashLogs) failures.push(`crash logged in ${log}: ${fs.readFileSync(path.join(sandbox, log), 'utf8').slice(0, 200)}`);
|
|
229
|
+
assert.deepEqual(failures, []);
|
|
230
|
+
});
|