@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
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { test } = require('node:test');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
const Module = require('node:module');
|
|
10
|
+
const { convertCodexAgentContent, normalizeCodexBody } = require('../lib/codex-install');
|
|
11
|
+
const { sha256 } = require('../lib/manifest');
|
|
12
|
+
|
|
13
|
+
const ROOT = path.resolve(__dirname, '../..');
|
|
14
|
+
const STRATEGIST = fs.readFileSync(path.join(ROOT, 'src/claude/agents/strategist.md'), 'utf8');
|
|
15
|
+
const { splitFrontmatter } = require('../lib/codex-frontmatter');
|
|
16
|
+
|
|
17
|
+
function withInstalledSkills(check) {
|
|
18
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-routing-description-'));
|
|
19
|
+
try {
|
|
20
|
+
const result = spawnSync(process.execPath, [path.join(ROOT, 'bin/install.js'), '--platform', 'codex', '--with-document-skills', '-y'], { cwd: temp, encoding: 'utf8' });
|
|
21
|
+
assert.equal(result.status, 0, result.stderr);
|
|
22
|
+
check(temp);
|
|
23
|
+
} finally {
|
|
24
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function installedInstructionText(temp) {
|
|
29
|
+
const read = directory => fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
30
|
+
const file = path.join(directory, entry.name);
|
|
31
|
+
if (entry.isDirectory()) return read(file);
|
|
32
|
+
if (!/\.(?:md|toml)$/.test(file)) return [];
|
|
33
|
+
let content = fs.readFileSync(file, 'utf8');
|
|
34
|
+
if (file.endsWith('.toml')) {
|
|
35
|
+
content = content.replace(/^developer_instructions = (.+)$/m, (_line, value) => JSON.parse(value));
|
|
36
|
+
}
|
|
37
|
+
return [[path.relative(temp, file), content.replace(/\s+/g, ' ')]];
|
|
38
|
+
});
|
|
39
|
+
return [...read(path.join(temp, '.agents/skills')), ...read(path.join(temp, '.codex'))];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const [label, needle] of [
|
|
43
|
+
['duplicate runtime roots', /(\.codex\/?)[\s,`]+\1/],
|
|
44
|
+
['duplicate instruction filenames', /(`?AGENTS\.md`?)[\s,]+\1/],
|
|
45
|
+
['AskUserQuestion', /AskUserQuestion/],
|
|
46
|
+
['structured user-input', /structured user-input/],
|
|
47
|
+
['native input tool', /native input tool/],
|
|
48
|
+
['native structured', /native structured/],
|
|
49
|
+
['Codex CLI own worktree', /Codex CLI's own worktree/],
|
|
50
|
+
['runtime-specific nested worktree path', /\.codex\/worktrees\//]
|
|
51
|
+
]) {
|
|
52
|
+
test(`installed Codex instruction corpus excludes ${label}`, () => {
|
|
53
|
+
withInstalledSkills(temp => {
|
|
54
|
+
const matches = installedInstructionText(temp).filter(([_file, text]) => needle.test(text)).map(([file]) => file);
|
|
55
|
+
assert.deepEqual(matches, [], label);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const needle of ['send_message(type', 'shutdown_', 'task-state inspection']) {
|
|
61
|
+
test(`installed Codex agents omit Team protocol ${needle}`, () => {
|
|
62
|
+
withInstalledSkills(temp => {
|
|
63
|
+
for (const name of ['git_ops', 'project_manager']) {
|
|
64
|
+
const text = fs.readFileSync(path.join(temp, '.codex/agents', `${name}.toml`), 'utf8');
|
|
65
|
+
assert.ok(!text.includes(needle), name);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('installed Codex agents retain force-push and planning-owner boundaries', () => {
|
|
72
|
+
withInstalledSkills(temp => {
|
|
73
|
+
const texts = new Map(installedInstructionText(temp));
|
|
74
|
+
assert.ok(texts.get('.codex/agents/git_ops.toml').includes('NEVER arbitrarily use `force push` unless explicitly and securely designated.'));
|
|
75
|
+
assert.ok(texts.get('.codex/agents/project_manager.toml').includes('You aggregate after GATE-REVIEW and during explicitly invoked execution. An incomplete packet returns to the planning owner; do not expand it yourself.'));
|
|
76
|
+
assert.doesNotMatch(texts.get('.codex/agents/git_ops.toml'), /## Team Mode\s+3\. /);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('installed Brainstorm asks a question in the final answer without a runtime possessive', () => {
|
|
81
|
+
withInstalledSkills(temp => {
|
|
82
|
+
for (const relative of ['SKILL.md', 'references/question-framework.md']) {
|
|
83
|
+
const text = fs.readFileSync(path.join(temp, '.agents/skills/brainstorm', relative), 'utf8').replace(/\s+/g, ' ');
|
|
84
|
+
assert.ok(text.includes("Use a question in the turn's final answer when available."), relative);
|
|
85
|
+
assert.ok(!text.includes("the runtime's question"), relative);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('installed Codex host lists retain Claude names alongside converted single-host paths', () => {
|
|
91
|
+
withInstalledSkills(temp => {
|
|
92
|
+
const texts = new Map(installedInstructionText(temp));
|
|
93
|
+
assert.match(texts.get('.agents/skills/git/references/worktree-blueprint.md'), /\.claude/);
|
|
94
|
+
assert.match(texts.get('.agents/skills/ask/SKILL.md'), /CLAUDE\.md/);
|
|
95
|
+
assert.match(texts.get('.agents/skills/docs/references/init-workflow.md'), /\.codex\/scripts\//);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('host-list preservation applies only to instruction assets including a missing source path', () => {
|
|
100
|
+
const source = 'Copy `.claude/`, `.codex/`; read `CLAUDE.md`, `AGENTS.md`; run `.claude/scripts/check.cjs`.';
|
|
101
|
+
const expected = 'Copy `.claude/`, `.codex/`; read `CLAUDE.md`, `AGENTS.md`; run `.codex/scripts/check.cjs`.';
|
|
102
|
+
assert.equal(normalizeCodexBody(source), expected);
|
|
103
|
+
assert.equal(normalizeCodexBody(source, 'guide.md'), expected);
|
|
104
|
+
assert.equal(normalizeCodexBody(source, 'script.cjs'), 'Copy `.codex/`, `.codex/`; read `AGENTS.md`, `AGENTS.md`; run `.codex/scripts/check.cjs`.');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('standalone host filenames do not prevent conversion of a single-host file path on the same line', () => {
|
|
108
|
+
assert.equal(normalizeCodexBody('Read `CLAUDE.md`, `AGENTS.md`; load `.claude/CLAUDE.md`.', 'guide.md'),
|
|
109
|
+
'Read `CLAUDE.md`, `AGENTS.md`; load `.codex/AGENTS.md`.');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('installed Codex catalog help labels the shared skills root for Codex', () => {
|
|
113
|
+
withInstalledSkills(temp => {
|
|
114
|
+
const result = spawnSync(process.execPath, ['.codex/scripts/generate-skill-catalog.cjs', '--help'], { cwd: temp, encoding: 'utf8' });
|
|
115
|
+
assert.equal(result.status, 0, result.stderr);
|
|
116
|
+
assert.doesNotMatch(result.stdout.replace(/\s+/g, ' '), /\.agents\/skills for Claude/);
|
|
117
|
+
assert.match(result.stdout, /\.agents\/skills for Codex/);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('installed skill descriptions carry Use when within the routing length limit', () => {
|
|
122
|
+
withInstalledSkills(temp => {
|
|
123
|
+
let checked = 0;
|
|
124
|
+
for (const name of fs.readdirSync(path.join(temp, '.agents/skills'))) {
|
|
125
|
+
const installed = path.join(temp, '.agents/skills', name, 'SKILL.md');
|
|
126
|
+
const source = path.join(ROOT, 'src/claude/skills', name, 'SKILL.md');
|
|
127
|
+
if (!fs.existsSync(installed) || !fs.existsSync(source)) continue;
|
|
128
|
+
const { frontmatter } = splitFrontmatter(fs.readFileSync(source, 'utf8'));
|
|
129
|
+
if (!frontmatter.when_to_use) continue;
|
|
130
|
+
const expected = `${normalizeCodexBody(frontmatter.description)} Use when: ${normalizeCodexBody(frontmatter.when_to_use)}`;
|
|
131
|
+
if (expected.length > 1024) continue;
|
|
132
|
+
const actual = JSON.parse(fs.readFileSync(installed, 'utf8').match(/^description: (.+)$/m)[1]);
|
|
133
|
+
const boundary = name === 'ask' ? ' A request to fix or change something is still answered without editing any file; point the user to $cf-fix.' : '';
|
|
134
|
+
assert.equal(actual, expected + boundary, name);
|
|
135
|
+
assert.ok(actual.length <= 1024, name);
|
|
136
|
+
checked++;
|
|
137
|
+
}
|
|
138
|
+
assert.ok(checked >= 18);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('brainstorm description stays unchanged when Use when exceeds the routing length limit', () => {
|
|
143
|
+
withInstalledSkills(temp => {
|
|
144
|
+
const source = splitFrontmatter(fs.readFileSync(path.join(ROOT, 'src/claude/skills/brainstorm/SKILL.md'), 'utf8')).frontmatter;
|
|
145
|
+
assert.ok(`${source.description} Use when: ${source.when_to_use}`.length > 1024);
|
|
146
|
+
const installed = splitFrontmatter(fs.readFileSync(path.join(temp, '.agents/skills/brainstorm/SKILL.md'), 'utf8')).frontmatter;
|
|
147
|
+
assert.equal(installed.description, source.description);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
for (const name of ['agent-browser', 'chrome-devtools', 'web-testing']) {
|
|
152
|
+
test(`installed ${name} description serializes Use when as a JSON string`, () => {
|
|
153
|
+
withInstalledSkills(temp => {
|
|
154
|
+
const content = fs.readFileSync(path.join(temp, '.agents/skills', name, 'SKILL.md'), 'utf8');
|
|
155
|
+
const description = JSON.parse(content.match(/^description: (.+)$/m)[1]);
|
|
156
|
+
const source = splitFrontmatter(fs.readFileSync(path.join(ROOT, 'src/claude/skills', name, 'SKILL.md'), 'utf8')).frontmatter;
|
|
157
|
+
assert.ok(description.endsWith(`Use when: ${normalizeCodexBody(source.when_to_use)}`));
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
test('Codex catalog emits the fix Use when text only once', () => {
|
|
163
|
+
withInstalledSkills(temp => {
|
|
164
|
+
const result = spawnSync(process.execPath, ['.codex/scripts/generate-skill-catalog.cjs', '--skills'], { cwd: temp, encoding: 'utf8' });
|
|
165
|
+
assert.equal(result.status, 0, result.stderr);
|
|
166
|
+
const when = normalizeCodexBody(splitFrontmatter(fs.readFileSync(path.join(ROOT, 'src/claude/skills/fix/SKILL.md'), 'utf8')).frontmatter.when_to_use);
|
|
167
|
+
assert.equal(result.stdout.split(when).length - 1, 1);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('Use when projection applies without a source path and preserves quoted metadata', () => {
|
|
172
|
+
const source = '---\nname: cf:example\ndescription: Choose "one" option.\nwhen_to_use: When evidence is available.\n---\nBody\n';
|
|
173
|
+
const projected = normalizeCodexBody(source);
|
|
174
|
+
assert.equal(JSON.parse(projected.match(/^description: (.+)$/m)[1]), 'Choose "one" option. Use when: When evidence is available.');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('strategist inherits native model, configures high reasoning and keeps advisory boundaries', () => {
|
|
178
|
+
const projected = convertCodexAgentContent(STRATEGIST, 'strategist.md');
|
|
179
|
+
assert.match(projected, /^model_reasoning_effort = "high"$/m);
|
|
180
|
+
assert.doesNotMatch(projected, /^model\s*=/m);
|
|
181
|
+
assert.doesNotMatch(projected, /fable|opus|sonnet|haiku|CLAUDE_CODE_SUBAGENT_MODEL|strongest available model|model override/);
|
|
182
|
+
assert.match(projected, /inherits the session model/);
|
|
183
|
+
assert.match(projected, /Advisory-only/);
|
|
184
|
+
assert.match(projected, /never implement, scaffold, or edit project files/);
|
|
185
|
+
assert.match(projected, /spawn_agent\(agent_type=\\"explorer\\", fork_turns=\\"none\\"/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('instruction projection converts Explore shorthand and native model guidance without rewriting executable code', () => {
|
|
189
|
+
const instruction = '`Task(Explore)`; set `CLAUDE_CODE_SUBAGENT_MODEL` in project settings.';
|
|
190
|
+
const converted = normalizeCodexBody(instruction, 'guide.md');
|
|
191
|
+
assert.doesNotMatch(converted, /Task\(Explore\)|CLAUDE_CODE_SUBAGENT_MODEL/);
|
|
192
|
+
assert.match(converted, /spawn_agent\(agent_type="explorer", fork_turns="none"/);
|
|
193
|
+
assert.match(converted, /configured agent model/);
|
|
194
|
+
assert.equal(normalizeCodexBody(instruction, 'script.js'), instruction);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
for (const state of ['pristine', 'modified', 'untracked', 'dry-run']) {
|
|
198
|
+
test(`retired Codex init.json migration: ${state}`, () => {
|
|
199
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-projection-'));
|
|
200
|
+
const relative = '.agents/skills/specs/templates/init.json';
|
|
201
|
+
const target = path.join(temp, relative);
|
|
202
|
+
const original = '{"legacy":true}\n';
|
|
203
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
204
|
+
fs.writeFileSync(target, state === 'modified' ? '{"custom":true}\n' : original);
|
|
205
|
+
const custom = path.join(path.dirname(target), 'my-custom-template.json');
|
|
206
|
+
fs.writeFileSync(custom, '{"keep":true}\n');
|
|
207
|
+
const before = fs.readFileSync(target, 'utf8');
|
|
208
|
+
const runner = `
|
|
209
|
+
const { copyPlatformFiles } = require(${JSON.stringify(path.join(ROOT, 'bin/phases/copy-payload'))});
|
|
210
|
+
const { createTracker } = require(${JSON.stringify(path.join(ROOT, 'bin/lib/manifest'))});
|
|
211
|
+
const tracker = createTracker('.codex', 'test', { recordRoot: '.', allowedRoots: ['.codex', '.agents'] });
|
|
212
|
+
const ctx = {
|
|
213
|
+
dryRun: ${state === 'dry-run'}, options: {},
|
|
214
|
+
manifest: { skills: { required: [] }, agents: { required: [] } },
|
|
215
|
+
ownership: { '.codex': { files: ${JSON.stringify(state === 'untracked' ? {} : { [relative]: { sha256: sha256(original) } })} } },
|
|
216
|
+
trackers: { codex: tracker },
|
|
217
|
+
ui: { detail() {}, warn() {}, error() {} },
|
|
218
|
+
results: { installedSkills: 0, copied: 0, updated: 0, unchanged: 0, preserved: 0, missingDependencies: 0, preservedFiles: [] }
|
|
219
|
+
};
|
|
220
|
+
copyPlatformFiles(ctx, 'codex');
|
|
221
|
+
if (${JSON.stringify(state)} !== 'dry-run' && !tracker._pruned.has(${JSON.stringify(relative)})) throw new Error('ownership baseline was not pruned');
|
|
222
|
+
`;
|
|
223
|
+
try {
|
|
224
|
+
const result = spawnSync(process.execPath, ['-e', runner], { cwd: temp, encoding: 'utf8' });
|
|
225
|
+
assert.equal(result.status, 0, result.stderr);
|
|
226
|
+
if (state !== 'dry-run') assert.equal(fs.existsSync(target), false);
|
|
227
|
+
else assert.equal(fs.readFileSync(target, 'utf8'), before);
|
|
228
|
+
assert.equal(fs.readFileSync(custom, 'utf8'), '{"keep":true}\n');
|
|
229
|
+
} finally {
|
|
230
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test('shared and projected provenance ignore native generated roots while retaining source changes', () => {
|
|
236
|
+
const sourcePath = path.join(ROOT, 'src/claude/scripts/provenance.cjs');
|
|
237
|
+
const source = fs.readFileSync(sourcePath, 'utf8');
|
|
238
|
+
const projected = new Module(sourcePath);
|
|
239
|
+
projected._compile(normalizeCodexBody(source, sourcePath), sourcePath);
|
|
240
|
+
for (const runtime of [require(sourcePath), projected.exports]) {
|
|
241
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-projection-'));
|
|
242
|
+
const paths = ['.claude/runtime.json', '.claude/hooks/.logs/session.json', '.claude/.logs/events.json',
|
|
243
|
+
'.codex/session-state/session/context.json', '.codex/hooks/.privacy/request.json'];
|
|
244
|
+
const write = (relative, data) => {
|
|
245
|
+
const target = path.join(temp, relative);
|
|
246
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
247
|
+
fs.writeFileSync(target, data);
|
|
248
|
+
};
|
|
249
|
+
const git = (...args) => {
|
|
250
|
+
const result = spawnSync('git', ['-C', temp, ...args], { encoding: 'utf8' });
|
|
251
|
+
assert.equal(result.status, 0, result.stderr);
|
|
252
|
+
return result.stdout;
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
write('app.js', 'export const value = 1;\n');
|
|
256
|
+
write('specs/feature/plan.md', '# Feature\n');
|
|
257
|
+
for (const relative of paths) write(relative, '{}\n');
|
|
258
|
+
git('init', '--quiet'); git('add', '.');
|
|
259
|
+
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', 'commit', '--quiet', '-m', 'baseline');
|
|
260
|
+
const input = { projectRoot: temp, specsRoot: path.join(temp, 'specs'),
|
|
261
|
+
specFile: path.join(temp, 'specs/feature/plan.md'), featureName: 'feature', runtimeSession: 'fixture' };
|
|
262
|
+
const baseline = runtime.deriveRuntimeProvenance(input).Head;
|
|
263
|
+
const capturedGenerated = [];
|
|
264
|
+
for (const relative of paths) {
|
|
265
|
+
write(relative, '{"generated":true}\n');
|
|
266
|
+
assert.match(git('status', '--porcelain', '--', relative), /M/);
|
|
267
|
+
if (runtime.deriveRuntimeProvenance(input).Head !== baseline) capturedGenerated.push(relative);
|
|
268
|
+
write(relative, '{}\n');
|
|
269
|
+
}
|
|
270
|
+
write('app.js', 'export const value = 2;\n');
|
|
271
|
+
assert.notEqual(runtime.deriveRuntimeProvenance(input).Head, baseline);
|
|
272
|
+
assert.deepEqual(capturedGenerated, [], 'generated state changed Head');
|
|
273
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('optional env functions read fake native runtime global env on Claude and projected Codex', () => {
|
|
278
|
+
const python = `import ast, json, sys
|
|
279
|
+
from pathlib import Path
|
|
280
|
+
from types import SimpleNamespace
|
|
281
|
+
case = json.load(sys.stdin)
|
|
282
|
+
tree = ast.parse(case['source'])
|
|
283
|
+
function = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == case['function'])
|
|
284
|
+
env = {}
|
|
285
|
+
loaded = []
|
|
286
|
+
def load_dotenv(target):
|
|
287
|
+
loaded.append(str(target))
|
|
288
|
+
env['GEMINI_API_KEY'] = Path(target).read_text().strip()
|
|
289
|
+
scope = {'Path': Path, 'Optional': __import__('typing').Optional, '__file__': case['script'],
|
|
290
|
+
'os': SimpleNamespace(getenv=lambda key: env.get(key)), 'load_dotenv': load_dotenv}
|
|
291
|
+
exec(compile(ast.Module(body=[function], type_ignores=[]), '<fixture>', 'exec'), scope)
|
|
292
|
+
result = scope[case['function']]()
|
|
293
|
+
assert (result if case['function'] == 'find_api_key' else env.get('GEMINI_API_KEY')) == 'fixture-native-key', loaded
|
|
294
|
+
assert loaded == [case['expected']], loaded
|
|
295
|
+
`;
|
|
296
|
+
for (const [file, functionName] of [['media_optimizer.py', 'load_env_files'], ['document_converter.py', 'find_api_key']]) {
|
|
297
|
+
const sourcePath = path.join(ROOT, 'src/claude/skills/ai-multimodal/scripts', file);
|
|
298
|
+
const original = fs.readFileSync(sourcePath, 'utf8');
|
|
299
|
+
for (const native of ['claude', 'codex']) {
|
|
300
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-native-env-'));
|
|
301
|
+
try {
|
|
302
|
+
const expected = path.join(temp, `.${native}`, '.env');
|
|
303
|
+
fs.mkdirSync(path.dirname(expected), { recursive: true });
|
|
304
|
+
fs.writeFileSync(expected, 'fixture-native-key');
|
|
305
|
+
const script = path.join(temp, native === 'claude' ? '.claude/skills' : '.agents/skills', 'ai-multimodal/scripts', file);
|
|
306
|
+
const result = spawnSync('python3', ['-c', python], { encoding: 'utf8', input: JSON.stringify({
|
|
307
|
+
source: native === 'claude' ? original : normalizeCodexBody(original, sourcePath),
|
|
308
|
+
function: functionName, script, expected
|
|
309
|
+
}) });
|
|
310
|
+
assert.equal(result.status, 0, `${native}/${file}: ${result.stderr}`);
|
|
311
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
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
|
+
const { test } = require('node:test');
|
|
10
|
+
const SOURCE = path.resolve(__dirname, '../../src');
|
|
11
|
+
|
|
12
|
+
function fixture({ specs = 'specs', packets = ['auth-flow'] } = {}) {
|
|
13
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-codex-quiet-'));
|
|
14
|
+
const hooks = path.join(root, '.codex/hooks');
|
|
15
|
+
fs.cpSync(path.join(SOURCE, 'codex/hooks'), hooks, { recursive: true });
|
|
16
|
+
for (const name of ['privacy-paths.cjs', 'privacy-command-analysis.cjs']) {
|
|
17
|
+
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib', name), path.join(hooks, 'lib', name));
|
|
18
|
+
}
|
|
19
|
+
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/spec-touch.cjs'), path.join(hooks, 'lib/spec-touch.cjs'));
|
|
20
|
+
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/secret-keywords.cjs'), path.join(hooks, 'lib/secret-keywords.cjs'));
|
|
21
|
+
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/update-check.cjs'), path.join(hooks, 'lib/update-check.cjs'));
|
|
22
|
+
fs.copyFileSync(path.join(SOURCE, 'claude/hooks/lib/runtime-path-safety.cjs'), path.join(hooks, 'lib/runtime-path-safety.cjs'));
|
|
23
|
+
fs.cpSync(path.join(SOURCE, 'claude/scripts'), path.join(root, '.codex/scripts'), { recursive: true });
|
|
24
|
+
fs.writeFileSync(path.join(root, '.codex/runtime.json'), JSON.stringify({ paths: { specs } }));
|
|
25
|
+
fs.writeFileSync(path.join(root, '.gitignore'), '.codex/\n');
|
|
26
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'probe@example.invalid'], ['config', 'user.name', 'Probe'], ['add', '.gitignore'], ['commit', '-qm', 'base']]) {
|
|
27
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
28
|
+
assert.equal(result.status, 0, result.stderr);
|
|
29
|
+
}
|
|
30
|
+
for (const name of packets) {
|
|
31
|
+
const dir = path.join(root, specs, name); fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'plan.md'), '# Plan\nSpecs-Contract: process-first-ready-v1\n');
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'task-01-work.md'), '# Work\nStatus: pending\n## Dependencies\n- none\n## Verification Plan\n- Command: node --test\n');
|
|
34
|
+
}
|
|
35
|
+
const run = (hook, payload, cwd = root) => {
|
|
36
|
+
const result = spawnSync(process.execPath, [path.join(hooks, `${hook}.cjs`)], {
|
|
37
|
+
cwd, input: JSON.stringify({ cwd, ...payload }), encoding: 'utf8',
|
|
38
|
+
});
|
|
39
|
+
assert.equal(result.status, 0, result.stderr);
|
|
40
|
+
return result.stdout.trim();
|
|
41
|
+
};
|
|
42
|
+
return { root, hooks, specs, run, prompt: (id, prompt, more = {}) => run('spec-state', { hook_event_name: 'UserPromptSubmit', session_id: id, prompt, ...more }), cleanup: () => fs.rmSync(root, { recursive: true, force: true }) };
|
|
43
|
+
}
|
|
44
|
+
function within(options, fn) { const fx = fixture(options); try { fn(fx); } finally { fx.cleanup(); } }
|
|
45
|
+
// Native shape pinned by openai/codex apply-patch tests: string stdout summary,
|
|
46
|
+
// not the empty object code mode returns (core/src/tools/context.rs).
|
|
47
|
+
function patch(fx, session, command, response, cwd = fx.root) {
|
|
48
|
+
if (response === undefined) {
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const line of command.split('\n')) {
|
|
51
|
+
const header = line.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
|
|
52
|
+
if (header) files.push(`${{ Add: 'A', Update: 'M', Delete: 'D' }[header[1]]} ${header[2]}`);
|
|
53
|
+
else if (line.startsWith('*** Move to: ')) files[files.length - 1] = `M ${line.slice(13)}`;
|
|
54
|
+
}
|
|
55
|
+
response = `Success. Updated the following files:\n${files.join('\n')}\n`;
|
|
56
|
+
}
|
|
57
|
+
return fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: session, tool_name: 'apply_patch', tool_input: { command }, tool_response: response }, cwd);
|
|
58
|
+
}
|
|
59
|
+
function snapshot(fx, id, content, generated = new Date().toISOString()) {
|
|
60
|
+
const dir = path.join(fx.root, '.codex/session-state', crypto.createHash('sha256').update(id).digest('hex'));
|
|
61
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
62
|
+
fs.writeFileSync(path.join(dir, 'latest.md'), `# Session State\n<!-- Generated: ${generated} -->\n${content}\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test('untouched sessions remain quiet even beside malformed and multiple active packets', () => {
|
|
66
|
+
within({ packets: ['auth-flow', 'billing-2'] }, (fx) => {
|
|
67
|
+
fs.writeFileSync(path.join(fx.root, 'specs/billing-2/task-01-work.md'), 'Status: invented\n');
|
|
68
|
+
assert.equal(fx.prompt('a', 'Explain README'), '');
|
|
69
|
+
assert.match(fx.prompt('a', '$cf-develop auth-flow'), /Spec state changed: `auth-flow`/);
|
|
70
|
+
assert.equal(fx.prompt('b', 'Continue'), '');
|
|
71
|
+
assert.match(fx.prompt('a', 'Continue'), /auth-flow/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
test('named packets narrow relevant ambiguity and explicit targets are remembered', () => {
|
|
75
|
+
within({ packets: ['auth-flow', 'billing-2'] }, (fx) => {
|
|
76
|
+
fs.mkdirSync(path.join(fx.root, 'specs/_shared'));
|
|
77
|
+
fs.writeFileSync(path.join(fx.root, 'specs/_shared/active-feature.json'), '{"featureName":"billing-2"}');
|
|
78
|
+
assert.match(fx.prompt('a', '/cf:develop auth-flow'), /Spec state changed: `auth-flow`/);
|
|
79
|
+
assert.match(fx.prompt('a', 'specs/billing-2'), /Spec state changed: `billing-2`/);
|
|
80
|
+
assert.match(fx.prompt('a', 'Continue'), /Multiple active specs/);
|
|
81
|
+
assert.match(fx.prompt('b', 'Continue', { explicitPath: 'specs/auth-flow/plan.md' }), /auth-flow/);
|
|
82
|
+
assert.match(fx.prompt('b', 'Continue'), /auth-flow/);
|
|
83
|
+
assert.match(fx.prompt('c', 'Continue', { explicitFeature: 'billing-2' }), /billing-2/);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
test('native successful add/update/delete/move patches touch silently without loading policy or resolver', () => {
|
|
87
|
+
within({ packets: ['auth-flow', 'billing-2'] }, (fx) => {
|
|
88
|
+
fs.writeFileSync(path.join(fx.root, '.codex/scripts/workflow-policy.cjs'), 'throw new Error("must not load");');
|
|
89
|
+
fs.writeFileSync(path.join(fx.root, '.codex/scripts/spec-resolver.cjs'), 'throw new Error("must not resolve");');
|
|
90
|
+
for (const [session, body] of [
|
|
91
|
+
['add', '*** Add File: specs/auth-flow/new.md\n+new'],
|
|
92
|
+
['update', '*** Update File: specs/auth-flow/plan.md\n@@\n-old\n+new'],
|
|
93
|
+
['delete', '*** Delete File: specs/auth-flow/deleted.md'],
|
|
94
|
+
['move', '*** Update File: specs/auth-flow/old.md\n*** Move to: specs/billing-2/new.md\n@@\n-old\n+new'],
|
|
95
|
+
]) assert.equal(patch(fx, session, `*** Begin Patch\n${body}\n*** End Patch`), '');
|
|
96
|
+
const records = fs.readdirSync(path.join(fx.hooks, '.logs')).filter((name) => name.startsWith('spec-touched-'));
|
|
97
|
+
assert.equal(records.length, 4);
|
|
98
|
+
assert.ok(records.some((name) => JSON.parse(fs.readFileSync(path.join(fx.hooks, '.logs', name))).length === 2));
|
|
99
|
+
assert.ok(!fs.existsSync(path.join(fx.hooks, '.logs/hook-log.jsonl')));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
test('failed, malformed, shell and unrelated tool payloads never touch', () => {
|
|
103
|
+
within({}, (fx) => {
|
|
104
|
+
const command = '*** Begin Patch\n*** Add File: specs/auth-flow/new.md\n+new\n*** End Patch';
|
|
105
|
+
for (const response of [null, {}, 'unknown', 'Error: Failed to apply patch', { content: [{ type: 'text', text: 'Failed to find expected lines' }] }, { isError: true }, { success: false }, { exit_code: 1 }, { error: 'failed' }]) assert.equal(patch(fx, 'a', command, response), '');
|
|
106
|
+
for (const invalid of ['echo specs/auth-flow', '*** Begin Patch\n*** Add File: specs/auth-flow/new.md\n*** End Patch']) assert.equal(patch(fx, 'a', invalid), '');
|
|
107
|
+
fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: 'a', tool_name: 'apply_patch', tool_input: { command } });
|
|
108
|
+
fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: 'a', tool_name: 'exec_command', tool_input: { command } });
|
|
109
|
+
assert.equal(fx.prompt('a', 'Continue'), '');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
test('custom root, nested cwd, deleted paths and symlink containment preserve canonical ownership', () => {
|
|
113
|
+
within({ specs: 'work/packets' }, (fx) => {
|
|
114
|
+
const nested = path.join(fx.root, 'nested'); fs.mkdirSync(nested);
|
|
115
|
+
assert.match(fx.prompt('absolute', path.join(fx.root, 'work/packets/auth-flow/plan.md')), /auth-flow/);
|
|
116
|
+
assert.equal(patch(fx, 'a', '*** Begin Patch\n*** Delete File: ../work/packets/auth-flow/deleted.md\n*** End Patch', 'Success. Updated the following files:\nD ../work/packets/auth-flow/deleted.md\n', nested), '');
|
|
117
|
+
assert.match(fx.prompt('a', 'Continue'), /auth-flow/);
|
|
118
|
+
assert.match(fx.prompt('b', 'work/packets/auth-flow'), /auth-flow/);
|
|
119
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-quiet-outside-'));
|
|
120
|
+
try {
|
|
121
|
+
fs.symlinkSync(outside, path.join(fx.root, 'work/packets/escape'));
|
|
122
|
+
patch(fx, 'c', '*** Begin Patch\n*** Add File: work/packets/escape/new.md\n+new\n*** End Patch');
|
|
123
|
+
assert.equal(fx.prompt('c', 'Continue'), '');
|
|
124
|
+
assert.match(fx.prompt('explicit', 'Continue', { explicitPath: 'work/packets/auth-flow/task-01-work.md' }), /auth-flow/);
|
|
125
|
+
} finally { fs.rmSync(outside, { recursive: true, force: true }); }
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
test('legacy-compatible writes touch; failed writes and no-session observer do not share an unknown session', () => {
|
|
129
|
+
within({}, (fx) => {
|
|
130
|
+
for (const tool of ['Edit', 'Write', 'MultiEdit']) {
|
|
131
|
+
assert.equal(fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: tool, tool_name: tool, tool_input: { file_path: 'specs/auth-flow/plan.md' }, tool_response: { ok: true } }), '');
|
|
132
|
+
assert.match(fx.prompt(tool, 'Continue'), /auth-flow/);
|
|
133
|
+
}
|
|
134
|
+
fx.run('spec-state', { hook_event_name: 'PostToolUse', session_id: 'failed', tool_name: 'Write', tool_input: { file_path: 'specs/auth-flow/plan.md' }, tool_response: { success: false } });
|
|
135
|
+
assert.equal(fx.prompt('failed', 'Continue'), '');
|
|
136
|
+
fx.run('spec-state', { hook_event_name: 'PostToolUse', tool_name: 'Write', tool_input: { file_path: 'specs/auth-flow/plan.md' } });
|
|
137
|
+
assert.equal(fx.prompt('new', 'Continue'), '');
|
|
138
|
+
assert.match(fx.prompt(undefined, 'Continue'), /runtime_session must be a non-empty/, 'session-less prompt preserves the original provenance failure without inventing identity');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
test('legacy migration notice stays once per session independently of packet touch', () => {
|
|
142
|
+
within({ packets: [] }, (fx) => {
|
|
143
|
+
const dir = path.join(fx.root, 'specs/old'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'spec.json'), '{}');
|
|
144
|
+
assert.match(fx.prompt('a', 'Hello'), /legacy Specs packet/);
|
|
145
|
+
assert.equal(fx.prompt('a', 'Hello'), '');
|
|
146
|
+
assert.match(fx.prompt('b', 'Hello'), /legacy Specs packet/);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
test('state hides placeholders and invalid/expired timestamps but restores meaningful per-session content', () => {
|
|
150
|
+
within({ packets: [] }, (fx) => {
|
|
151
|
+
const filler = '## What Worked\n- (No completed tasks recorded)\n## Left\n- (No pending tasks recorded)\n## Agent\n- Completed at 12:30:40\n- Completed without a result message.\n## Files\n- (No file changes detected)';
|
|
152
|
+
snapshot(fx, 'a', filler);
|
|
153
|
+
assert.equal(fx.run('state', { hook_event_name: 'SessionStart', session_id: 'a' }), '');
|
|
154
|
+
for (const timestamp of ['invalid', new Date(Date.now() - 8 * 86400000).toISOString()]) {
|
|
155
|
+
snapshot(fx, 'a', '- real work', timestamp);
|
|
156
|
+
assert.equal(fx.run('state', { hook_event_name: 'SessionStart', session_id: 'a' }), '');
|
|
157
|
+
}
|
|
158
|
+
for (const content of ['- [ ] Build API', '## Agent Result\nVerified endpoint', '## Latest Assistant Message\nSaved edits', '## Key Files Modified\n- src/api.js']) {
|
|
159
|
+
snapshot(fx, 'a', content);
|
|
160
|
+
assert.match(fx.run('state', { hook_event_name: 'SessionStart', session_id: 'a' }), /Prior Execution Context/);
|
|
161
|
+
assert.equal(fx.run('state', { hook_event_name: 'SessionStart', session_id: 'b' }), '');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
test('template keeps on-demand docs file and registers native mutation observer separately', () => {
|
|
166
|
+
const template = JSON.parse(fs.readFileSync(path.join(SOURCE, 'codex/hooks.json')));
|
|
167
|
+
assert.ok(!JSON.stringify(template.hooks.SessionStart).includes('docs-sync.cjs'));
|
|
168
|
+
assert.ok(fs.existsSync(path.join(SOURCE, 'codex/hooks/docs-sync.cjs')));
|
|
169
|
+
assert.ok(template.hooks.PostToolUse.some((entry) => entry.matcher.includes('apply_patch') && entry.hooks.some((hook) => hook.command.includes('spec-state.cjs'))));
|
|
170
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { test } = require('node:test');
|
|
7
|
+
const { normalizeCodexBody, CODEX_RESEARCH_DEPTH_MARKER, CODEX_RESEARCH_DEPTH_NOTE } = require('../lib/codex-install');
|
|
8
|
+
const { splitFrontmatter } = require('../lib/codex-frontmatter');
|
|
9
|
+
|
|
10
|
+
const SKILLS = path.resolve(__dirname, '../../src/claude/skills');
|
|
11
|
+
const researchPath = path.join(SKILLS, 'research/SKILL.md');
|
|
12
|
+
const research = fs.readFileSync(researchPath, 'utf8');
|
|
13
|
+
|
|
14
|
+
function skillFiles(directory) {
|
|
15
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
16
|
+
const file = path.join(directory, entry.name);
|
|
17
|
+
return entry.isDirectory() ? skillFiles(file) : entry.name === 'SKILL.md' ? [file] : [];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertMarker() {
|
|
22
|
+
assert.equal(typeof CODEX_RESEARCH_DEPTH_MARKER, 'string');
|
|
23
|
+
assert.ok(CODEX_RESEARCH_DEPTH_MARKER.length > 0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('codex research projection carries the depth note immediately after unchanged frontmatter', () => {
|
|
27
|
+
assertMarker();
|
|
28
|
+
assert.equal(typeof CODEX_RESEARCH_DEPTH_NOTE, 'string');
|
|
29
|
+
const plain = normalizeCodexBody(research);
|
|
30
|
+
const projected = normalizeCodexBody(research, researchPath);
|
|
31
|
+
assert.match(projected, /^---\r?\n/);
|
|
32
|
+
const before = splitFrontmatter(plain), after = splitFrontmatter(projected);
|
|
33
|
+
assert.equal(after.frontmatter.name, 'cf-research');
|
|
34
|
+
assert.equal(after.frontmatter.description, before.frontmatter.description);
|
|
35
|
+
assert.deepEqual(after.frontmatter, before.frontmatter);
|
|
36
|
+
assert.equal(projected.slice(0, projected.length - after.body.length), plain.slice(0, plain.length - before.body.length));
|
|
37
|
+
assert.equal(after.body, `\n${CODEX_RESEARCH_DEPTH_NOTE}\n\n${before.body}`);
|
|
38
|
+
assert.equal(projected.split(CODEX_RESEARCH_DEPTH_MARKER).length - 1, 1);
|
|
39
|
+
assert.ok(CODEX_RESEARCH_DEPTH_NOTE.includes(CODEX_RESEARCH_DEPTH_MARKER));
|
|
40
|
+
assert.match(CODEX_RESEARCH_DEPTH_NOTE, /known option/);
|
|
41
|
+
assert.match(CODEX_RESEARCH_DEPTH_NOTE, /Quick/);
|
|
42
|
+
assert.match(CODEX_RESEARCH_DEPTH_NOTE, /outdated supporting rationale/i);
|
|
43
|
+
assert.match(CODEX_RESEARCH_DEPTH_NOTE, /changes the recommendation/);
|
|
44
|
+
assert.match(CODEX_RESEARCH_DEPTH_NOTE, /conflicting evidence/i);
|
|
45
|
+
assert.doesNotMatch(CODEX_RESEARCH_DEPTH_NOTE, /\b(?:Write|Edit|Read|Bash|WebSearch|WebFetch|AskUserQuestion|Drizzle|Prisma|BullMQ|pg-boss|billing|ADR-004)\b/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('research marker is absent from every other projected skill including ask', () => {
|
|
49
|
+
assertMarker();
|
|
50
|
+
const others = skillFiles(SKILLS).filter(file => file !== researchPath);
|
|
51
|
+
assert.ok(others.includes(path.join(SKILLS, 'ask/SKILL.md')));
|
|
52
|
+
for (const file of others) {
|
|
53
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
54
|
+
const projected = normalizeCodexBody(source, file);
|
|
55
|
+
assert.ok(!projected.includes(CODEX_RESEARCH_DEPTH_MARKER), file);
|
|
56
|
+
// Ask intentionally has its own boundary; all other skill bodies stay identical.
|
|
57
|
+
if (![path.join(SKILLS, 'ask/SKILL.md'), path.join(SKILLS, 'specs/SKILL.md')].includes(file)) assert.equal(projected, normalizeCodexBody(source), file);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('research note is scoped to the exact skill source path and supports Windows separators', () => {
|
|
62
|
+
assertMarker();
|
|
63
|
+
for (const sourcePath of ['', 'skills/research/templates/x.md', 'other-skills/research/SKILL.md']) {
|
|
64
|
+
const projected = normalizeCodexBody(research, sourcePath);
|
|
65
|
+
assert.ok(!projected.includes(CODEX_RESEARCH_DEPTH_MARKER), sourcePath);
|
|
66
|
+
assert.equal(projected, normalizeCodexBody(research), sourcePath);
|
|
67
|
+
}
|
|
68
|
+
const windows = normalizeCodexBody(research, 'C:\\project\\skills\\research\\SKILL.md');
|
|
69
|
+
assert.ok(windows.includes(CODEX_RESEARCH_DEPTH_MARKER));
|
|
70
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const SRC = path.resolve(__dirname, '../../src');
|
|
9
|
+
const SHIM = /^(?:['"]use strict['"];?\s*)?module\.exports\s*=\s*require\(['"]\.\.\/\.\.\/\.\.\/claude\/hooks\/lib\/([^'"/]+)['"]\);?\s*$/;
|
|
10
|
+
|
|
11
|
+
function layout() {
|
|
12
|
+
return require('../phases/codex-runtime').codexRuntimeLayout();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const destinations = (entries) => entries.map((entry) => entry.dest);
|
|
16
|
+
|
|
17
|
+
test('shims-resolved: every shim lib ships as the real Claude library, never as the shim', () => {
|
|
18
|
+
const libDir = path.join(SRC, 'codex/hooks/lib');
|
|
19
|
+
const shims = fs.readdirSync(libDir).filter((name) => (fs.readFileSync(path.join(libDir, name), 'utf8').match(SHIM) || [])[1] === name);
|
|
20
|
+
assert.equal(shims.length, 8, `expected 8 shim libraries, found ${shims.join(', ')}`);
|
|
21
|
+
const entries = layout();
|
|
22
|
+
for (const name of shims) {
|
|
23
|
+
const shipped = entries.filter((entry) => entry.dest === `hooks/lib/${name}`);
|
|
24
|
+
assert.equal(shipped.length, 1, `hooks/lib/${name} shipped ${shipped.length} times`);
|
|
25
|
+
assert.equal(shipped[0].src, path.join(SRC, 'claude/hooks/lib', name));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('hooks-copied: every non-shim Codex hook file ships from the Codex source', () => {
|
|
30
|
+
const entries = layout();
|
|
31
|
+
const hooksDir = path.join(SRC, 'codex/hooks');
|
|
32
|
+
for (const name of fs.readdirSync(hooksDir).filter((n) => n !== 'lib')) {
|
|
33
|
+
const entry = entries.find((e) => e.dest === `hooks/${name}`);
|
|
34
|
+
assert.ok(entry, `hooks/${name} missing`);
|
|
35
|
+
assert.equal(entry.src, path.join(hooksDir, name));
|
|
36
|
+
}
|
|
37
|
+
const libDir = path.join(hooksDir, 'lib');
|
|
38
|
+
for (const name of fs.readdirSync(libDir).filter((n) => !SHIM.test(fs.readFileSync(path.join(libDir, n), 'utf8')))) {
|
|
39
|
+
const entry = entries.find((e) => e.dest === `hooks/lib/${name}`);
|
|
40
|
+
assert.ok(entry, `hooks/lib/${name} missing`);
|
|
41
|
+
assert.equal(entry.src, path.join(libDir, name));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('groups-installed: every layout group is one the installer writes', () => {
|
|
46
|
+
const { LAYOUT_GROUPS } = require('../phases/codex-runtime');
|
|
47
|
+
const known = LAYOUT_GROUPS.map(([name]) => name);
|
|
48
|
+
for (const entry of layout()) assert.ok(known.includes(entry.group), `${entry.dest} has unknown group ${entry.group}`);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rules-composed: Claude rules minus Codex overrides, plus Codex rules, with the right transform', () => {
|
|
52
|
+
const entries = layout().filter((entry) => entry.dest.startsWith('rules/'));
|
|
53
|
+
const overrides = fs.readdirSync(path.join(SRC, 'codex/rules'));
|
|
54
|
+
const claudeRules = fs.readdirSync(path.join(SRC, 'claude/rules')).filter((name) => !overrides.includes(name));
|
|
55
|
+
assert.deepEqual(destinations(entries).sort(), [...claudeRules, ...overrides].map((name) => `rules/${name}`).sort());
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const override = overrides.includes(path.basename(entry.dest));
|
|
58
|
+
assert.equal(entry.src, path.join(SRC, override ? 'codex/rules' : 'claude/rules', path.basename(entry.dest)));
|
|
59
|
+
assert.equal(entry.transform, override ? null : 'codex-body', `${entry.dest} transform`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('styles-and-schema: output styles carry the Codex transform and the schema ships once', () => {
|
|
64
|
+
const entries = layout();
|
|
65
|
+
const styles = entries.filter((entry) => entry.dest.startsWith('output-styles/'));
|
|
66
|
+
assert.deepEqual(destinations(styles).sort(), fs.readdirSync(path.join(SRC, 'claude/output-styles')).map((n) => `output-styles/${n}`).sort());
|
|
67
|
+
assert.ok(styles.every((entry) => entry.transform === 'codex-body'));
|
|
68
|
+
const schema = entries.filter((entry) => entry.dest === 'runtime.schema.json');
|
|
69
|
+
assert.equal(schema.length, 1);
|
|
70
|
+
assert.equal(schema[0].src, path.join(SRC, 'claude/runtime.schema.json'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('sources-exist: every layout source is a regular file and every destination is unique and relative', () => {
|
|
74
|
+
const entries = layout();
|
|
75
|
+
assert.equal(new Set(destinations(entries)).size, entries.length, 'duplicate destination');
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
assert.ok(fs.lstatSync(entry.src).isFile(), `${entry.src} is not a regular file`);
|
|
78
|
+
assert.ok(!path.isAbsolute(entry.dest) && !entry.dest.split('/').includes('..'), `${entry.dest} escapes the runtime root`);
|
|
79
|
+
assert.equal(typeof entry.group, 'string');
|
|
80
|
+
}
|
|
81
|
+
});
|