5-phase-workflow 1.9.1 → 1.9.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 +23 -1
- package/bin/install.js +2 -0
- package/bin/sync-agents.js +639 -0
- package/package.json +1 -1
- package/src/commands/5/address-review-findings.md +94 -31
- package/src/commands/5/analyze-feature.md +159 -0
- package/src/commands/5/plan-feature.md +29 -39
- package/src/commands/5/plan-implementation.md +2 -2
- package/src/commands/5/synchronize-agents.md +60 -0
- package/src/templates/workflow/FEATURE-SPEC.md +60 -95
package/README.md
CHANGED
|
@@ -141,6 +141,7 @@ Claude Code exposes the workflow under the `/5:` namespace. Codex exposes the sa
|
|
|
141
141
|
| `/5:quick-implement` or `$5-quick-implement` | Fast | Streamlined workflow for small tasks |
|
|
142
142
|
| `/5:eject` or `$5-eject` | Utility | Permanently remove update infrastructure |
|
|
143
143
|
| `/5:unlock` or `$5-unlock` | Utility | Remove planning guard lock |
|
|
144
|
+
| `/5:synchronize-agents` or `$5-synchronize-agents` | Utility | Sync user content between Claude Code and Codex runtimes |
|
|
144
145
|
|
|
145
146
|
## Configuration
|
|
146
147
|
|
|
@@ -278,7 +279,8 @@ After installation, your `.claude/` directory will contain:
|
|
|
278
279
|
│ ├── quick-implement.md
|
|
279
280
|
│ ├── configure.md
|
|
280
281
|
│ ├── eject.md
|
|
281
|
-
│
|
|
282
|
+
│ ├── unlock.md
|
|
283
|
+
│ └── synchronize-agents.md
|
|
282
284
|
├── skills/ # Atomic operations
|
|
283
285
|
│ ├── build-project/
|
|
284
286
|
│ ├── run-tests/
|
|
@@ -398,6 +400,26 @@ This permanently removes the update infrastructure:
|
|
|
398
400
|
|
|
399
401
|
All other workflow files remain untouched. **This is irreversible.** To restore update functionality, reinstall with `npx 5-phase-workflow`.
|
|
400
402
|
|
|
403
|
+
### Synchronizing Runtimes
|
|
404
|
+
|
|
405
|
+
If you have both Claude Code and Codex installed, user-generated content (project-specific skills, custom commands, rules) only exists in the runtime where it was created. To sync this content bidirectionally:
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
# Claude Code
|
|
409
|
+
/5:synchronize-agents
|
|
410
|
+
|
|
411
|
+
# Codex
|
|
412
|
+
$5-synchronize-agents
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
This will:
|
|
416
|
+
- Sync project-specific skills (e.g., `create-controller`, `run-tests`) between `.claude/skills/` and `.codex/skills/` with appropriate format conversion
|
|
417
|
+
- Convert custom Claude commands to Codex skills
|
|
418
|
+
- Append `.claude/rules/` content to `.codex/instructions.md`
|
|
419
|
+
- Sync Codex-only skills back to Claude
|
|
420
|
+
|
|
421
|
+
Workflow-managed files and shared data (`.5/`) are not affected — those are handled by the installer.
|
|
422
|
+
|
|
401
423
|
## Development
|
|
402
424
|
|
|
403
425
|
### Running Tests
|
package/bin/install.js
CHANGED
|
@@ -847,6 +847,7 @@ function showCommandsHelp(isGlobal) {
|
|
|
847
847
|
log.info(' $5-reconfigure - Refresh docs/skills (no Q&A)');
|
|
848
848
|
log.info(' $5-eject - Eject from update mechanism');
|
|
849
849
|
log.info(' $5-unlock - Remove planning guard lock');
|
|
850
|
+
log.info(' $5-synchronize-agents - Sync user content between runtimes');
|
|
850
851
|
} else {
|
|
851
852
|
log.info('Available commands:');
|
|
852
853
|
log.info(' /5:plan-feature - Start feature planning (Phase 1)');
|
|
@@ -859,6 +860,7 @@ function showCommandsHelp(isGlobal) {
|
|
|
859
860
|
log.info(' /5:reconfigure - Refresh docs/skills (no Q&A)');
|
|
860
861
|
log.info(' /5:eject - Eject from update mechanism');
|
|
861
862
|
log.info(' /5:unlock - Remove planning guard lock');
|
|
863
|
+
log.info(' /5:synchronize-agents - Sync user content between runtimes');
|
|
862
864
|
}
|
|
863
865
|
log.info('');
|
|
864
866
|
log.info(`Config file: ${path.join(getDataPath(isGlobal), 'config.json')}`);
|
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// ANSI colors for terminal output
|
|
7
|
+
const colors = {
|
|
8
|
+
reset: '\x1b[0m',
|
|
9
|
+
bright: '\x1b[1m',
|
|
10
|
+
green: '\x1b[32m',
|
|
11
|
+
yellow: '\x1b[33m',
|
|
12
|
+
blue: '\x1b[34m',
|
|
13
|
+
red: '\x1b[31m',
|
|
14
|
+
dim: '\x1b[2m'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const log = {
|
|
18
|
+
info: (msg) => console.log(`${colors.blue}i${colors.reset} ${msg}`),
|
|
19
|
+
success: (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`),
|
|
20
|
+
warn: (msg) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`),
|
|
21
|
+
error: (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`),
|
|
22
|
+
header: (msg) => console.log(`\n${colors.bright}${msg}${colors.reset}\n`),
|
|
23
|
+
dim: (msg) => console.log(` ${colors.dim}${msg}${colors.reset}`)
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ── Workflow-managed file lists (must match install.js) ────────────────────
|
|
27
|
+
|
|
28
|
+
const WORKFLOW_MANAGED_SKILLS = new Set([
|
|
29
|
+
'configure-docs-index',
|
|
30
|
+
'configure-project',
|
|
31
|
+
'configure-skills',
|
|
32
|
+
'generate-readme'
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const WORKFLOW_MANAGED_AGENTS = new Set([
|
|
36
|
+
'component-executor.md'
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const RULES_SYNC_START = '<!-- 5-sync:rules-start -->';
|
|
40
|
+
const RULES_SYNC_END = '<!-- 5-sync:rules-end -->';
|
|
41
|
+
const AGENTS_SYNC_START = '<!-- 5-sync:agents-start -->';
|
|
42
|
+
const AGENTS_SYNC_END = '<!-- 5-sync:agents-end -->';
|
|
43
|
+
|
|
44
|
+
// ── Conversion functions (mirrors install.js logic) ────────────────────────
|
|
45
|
+
|
|
46
|
+
function extractFrontmatterAndBody(content) {
|
|
47
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
48
|
+
if (!match) return { frontmatter: null, body: content };
|
|
49
|
+
return { frontmatter: match[1], body: match[2] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractFrontmatterField(frontmatter, field) {
|
|
53
|
+
if (!frontmatter) return null;
|
|
54
|
+
const match = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
|
|
55
|
+
return match ? match[1].trim() : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function claudeToCodexContent(content) {
|
|
59
|
+
return content
|
|
60
|
+
.replace(/\/5:([a-z0-9-]+)/g, (_, name) => `$5-${name}`)
|
|
61
|
+
.replace(/\.claude\//g, '.codex/');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function codexToClaudeContent(content) {
|
|
65
|
+
return content
|
|
66
|
+
.replace(/\$5-([a-z0-9-]+)/g, (_, name) => `/5:${name}`)
|
|
67
|
+
.replace(/\.codex\//g, '.claude/')
|
|
68
|
+
.replace(/<codex_skill_adapter>[\s\S]*?<\/codex_skill_adapter>\n*/g, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getCodexSkillAdapterHeader(skillName) {
|
|
72
|
+
const invocation = `$${skillName}`;
|
|
73
|
+
return `<codex_skill_adapter>
|
|
74
|
+
## Skill Invocation
|
|
75
|
+
- This skill is invoked by mentioning \`${invocation}\`.
|
|
76
|
+
- Treat all user text after \`${invocation}\` as the skill argument.
|
|
77
|
+
|
|
78
|
+
## Tool Mapping (Claude Code → Codex)
|
|
79
|
+
This skill was authored for Claude Code. Map these tool references:
|
|
80
|
+
|
|
81
|
+
| Claude Code | Codex Equivalent |
|
|
82
|
+
|-------------|------------------|
|
|
83
|
+
| \`AskUserQuestion\` | Ask the user directly in conversation |
|
|
84
|
+
| \`Task(subagent_type="Explore")\` | Research the codebase yourself using available tools |
|
|
85
|
+
| \`Task(prompt="...")\` | \`spawn_agent(message="...")\` |
|
|
86
|
+
| \`Read\` | \`read_file\` |
|
|
87
|
+
| \`Write\` | \`write_file\` |
|
|
88
|
+
| \`Edit\` | \`patch\` |
|
|
89
|
+
| \`Bash\` | \`shell\` |
|
|
90
|
+
| \`Glob\` | \`glob\` / \`list_directory\` |
|
|
91
|
+
| \`Grep\` | \`grep\` / \`search\` |
|
|
92
|
+
| \`TaskCreate/TaskUpdate\` | Track progress internally |
|
|
93
|
+
| \`EnterPlanMode\` | Not available — use structured output instead |
|
|
94
|
+
</codex_skill_adapter>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function convertClaudeCommandToCodexSkill(content, skillName) {
|
|
98
|
+
const converted = claudeToCodexContent(content);
|
|
99
|
+
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
|
100
|
+
|
|
101
|
+
let description = `Custom command: ${skillName}`;
|
|
102
|
+
if (frontmatter) {
|
|
103
|
+
const maybeDesc = extractFrontmatterField(frontmatter, 'description');
|
|
104
|
+
if (maybeDesc) description = maybeDesc;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const shortDesc = description.length > 180 ? `${description.slice(0, 177)}...` : description;
|
|
108
|
+
const adapter = getCodexSkillAdapterHeader(skillName);
|
|
109
|
+
|
|
110
|
+
return `---
|
|
111
|
+
name: ${skillName}
|
|
112
|
+
description: ${description}
|
|
113
|
+
metadata:
|
|
114
|
+
short-description: ${shortDesc}
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
${adapter}
|
|
118
|
+
|
|
119
|
+
${body.trimStart()}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function convertClaudeSkillToCodex(content) {
|
|
123
|
+
return claudeToCodexContent(content);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function convertCodexSkillToClaude(content) {
|
|
127
|
+
return codexToClaudeContent(content);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── File helpers ────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function copyDirContents(src, dest, transformMd) {
|
|
133
|
+
if (!fs.existsSync(dest)) {
|
|
134
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const srcPath = path.join(src, entry.name);
|
|
139
|
+
const destPath = path.join(dest, entry.name);
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
copyDirContents(srcPath, destPath, transformMd);
|
|
142
|
+
} else if (transformMd && entry.name.endsWith('.md')) {
|
|
143
|
+
const content = fs.readFileSync(srcPath, 'utf8');
|
|
144
|
+
fs.writeFileSync(destPath, transformMd(content));
|
|
145
|
+
} else {
|
|
146
|
+
fs.copyFileSync(srcPath, destPath);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function dirContentsEqual(dirA, dirB, transformFn) {
|
|
152
|
+
if (!fs.existsSync(dirA) || !fs.existsSync(dirB)) return false;
|
|
153
|
+
|
|
154
|
+
const filesA = fs.readdirSync(dirA).sort();
|
|
155
|
+
const filesB = fs.readdirSync(dirB).sort();
|
|
156
|
+
if (filesA.length !== filesB.length) return false;
|
|
157
|
+
|
|
158
|
+
for (const file of filesA) {
|
|
159
|
+
if (!filesB.includes(file)) return false;
|
|
160
|
+
const pathA = path.join(dirA, file);
|
|
161
|
+
const pathB = path.join(dirB, file);
|
|
162
|
+
const statA = fs.statSync(pathA);
|
|
163
|
+
const statB = fs.statSync(pathB);
|
|
164
|
+
if (statA.isDirectory() !== statB.isDirectory()) return false;
|
|
165
|
+
if (statA.isDirectory()) {
|
|
166
|
+
if (!dirContentsEqual(pathA, pathB, transformFn)) return false;
|
|
167
|
+
} else {
|
|
168
|
+
let contentA = fs.readFileSync(pathA, 'utf8');
|
|
169
|
+
const contentB = fs.readFileSync(pathB, 'utf8');
|
|
170
|
+
if (file.endsWith('.md') && transformFn) {
|
|
171
|
+
contentA = transformFn(contentA);
|
|
172
|
+
}
|
|
173
|
+
if (contentA !== contentB) return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Inventory ──────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function findProjectRoot() {
|
|
182
|
+
// Walk up from cwd to find .5/ or .claude/ or .codex/
|
|
183
|
+
let dir = process.cwd();
|
|
184
|
+
while (dir !== path.dirname(dir)) {
|
|
185
|
+
if (fs.existsSync(path.join(dir, '.5')) ||
|
|
186
|
+
fs.existsSync(path.join(dir, '.claude')) ||
|
|
187
|
+
fs.existsSync(path.join(dir, '.codex'))) {
|
|
188
|
+
return dir;
|
|
189
|
+
}
|
|
190
|
+
dir = path.dirname(dir);
|
|
191
|
+
}
|
|
192
|
+
return process.cwd();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isClaudeInstalled(root) {
|
|
196
|
+
return fs.existsSync(path.join(root, '.claude', 'commands', '5', 'plan-feature.md'));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isCodexInstalled(root) {
|
|
200
|
+
return fs.existsSync(path.join(root, '.codex', 'skills', '5-plan-feature', 'SKILL.md'));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getClaudeUserSkills(root) {
|
|
204
|
+
const skillsDir = path.join(root, '.claude', 'skills');
|
|
205
|
+
if (!fs.existsSync(skillsDir)) return [];
|
|
206
|
+
return fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
207
|
+
.filter(d => d.isDirectory())
|
|
208
|
+
.map(d => d.name)
|
|
209
|
+
.filter(name => !WORKFLOW_MANAGED_SKILLS.has(name));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function getCodexUserSkills(root) {
|
|
213
|
+
const skillsDir = path.join(root, '.codex', 'skills');
|
|
214
|
+
if (!fs.existsSync(skillsDir)) return [];
|
|
215
|
+
return fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
216
|
+
.filter(d => d.isDirectory())
|
|
217
|
+
.map(d => d.name)
|
|
218
|
+
.filter(name => !name.startsWith('5-') && !WORKFLOW_MANAGED_SKILLS.has(name));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getClaudeCustomCommands(root) {
|
|
222
|
+
const commandsDir = path.join(root, '.claude', 'commands');
|
|
223
|
+
if (!fs.existsSync(commandsDir)) return [];
|
|
224
|
+
const namespaces = fs.readdirSync(commandsDir, { withFileTypes: true })
|
|
225
|
+
.filter(d => d.isDirectory() && d.name !== '5')
|
|
226
|
+
.map(d => d.name);
|
|
227
|
+
|
|
228
|
+
const commands = [];
|
|
229
|
+
for (const ns of namespaces) {
|
|
230
|
+
const nsDir = path.join(commandsDir, ns);
|
|
231
|
+
const files = fs.readdirSync(nsDir).filter(f => f.endsWith('.md'));
|
|
232
|
+
for (const file of files) {
|
|
233
|
+
commands.push({ namespace: ns, name: file.replace('.md', ''), file });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return commands;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function getClaudeCustomAgents(root) {
|
|
240
|
+
const agentsDir = path.join(root, '.claude', 'agents');
|
|
241
|
+
if (!fs.existsSync(agentsDir)) return [];
|
|
242
|
+
return fs.readdirSync(agentsDir)
|
|
243
|
+
.filter(f => f.endsWith('.md') && !WORKFLOW_MANAGED_AGENTS.has(f));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function getClaudeRules(root) {
|
|
247
|
+
const rulesDir = path.join(root, '.claude', 'rules');
|
|
248
|
+
if (!fs.existsSync(rulesDir)) return [];
|
|
249
|
+
return fs.readdirSync(rulesDir).filter(f => f.endsWith('.md'));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Sync action classification ─────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
function classifySkillActions(root) {
|
|
255
|
+
const claudeSkills = getClaudeUserSkills(root);
|
|
256
|
+
const codexSkills = getCodexUserSkills(root);
|
|
257
|
+
const claudeSet = new Set(claudeSkills);
|
|
258
|
+
const codexSet = new Set(codexSkills);
|
|
259
|
+
|
|
260
|
+
const actions = [];
|
|
261
|
+
|
|
262
|
+
// Claude → Codex
|
|
263
|
+
for (const skill of claudeSkills) {
|
|
264
|
+
const claudeDir = path.join(root, '.claude', 'skills', skill);
|
|
265
|
+
const codexDir = path.join(root, '.codex', 'skills', skill);
|
|
266
|
+
if (!codexSet.has(skill)) {
|
|
267
|
+
actions.push({ type: 'new', direction: 'claude-to-codex', category: 'skill', name: skill });
|
|
268
|
+
} else if (!dirContentsEqual(claudeDir, codexDir, convertClaudeSkillToCodex)) {
|
|
269
|
+
actions.push({ type: 'updated', direction: 'claude-to-codex', category: 'skill', name: skill });
|
|
270
|
+
} else {
|
|
271
|
+
actions.push({ type: 'in-sync', direction: 'both', category: 'skill', name: skill });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Codex → Claude
|
|
276
|
+
for (const skill of codexSkills) {
|
|
277
|
+
if (claudeSet.has(skill)) continue; // Already handled above
|
|
278
|
+
const claudeDir = path.join(root, '.claude', 'skills', skill);
|
|
279
|
+
if (!fs.existsSync(claudeDir)) {
|
|
280
|
+
actions.push({ type: 'new', direction: 'codex-to-claude', category: 'skill', name: skill });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return actions;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function classifyCommandActions(root) {
|
|
288
|
+
const commands = getClaudeCustomCommands(root);
|
|
289
|
+
const actions = [];
|
|
290
|
+
|
|
291
|
+
for (const cmd of commands) {
|
|
292
|
+
const skillName = `${cmd.namespace}-${cmd.name}`;
|
|
293
|
+
const codexSkillDir = path.join(root, '.codex', 'skills', skillName);
|
|
294
|
+
if (!fs.existsSync(codexSkillDir)) {
|
|
295
|
+
actions.push({ type: 'new', direction: 'claude-to-codex', category: 'command', name: `${cmd.namespace}/${cmd.name}`, skillName });
|
|
296
|
+
} else {
|
|
297
|
+
// Check if content changed
|
|
298
|
+
const claudeContent = fs.readFileSync(path.join(root, '.claude', 'commands', cmd.namespace, cmd.file), 'utf8');
|
|
299
|
+
const converted = convertClaudeCommandToCodexSkill(claudeContent, skillName);
|
|
300
|
+
const existing = fs.readFileSync(path.join(codexSkillDir, 'SKILL.md'), 'utf8');
|
|
301
|
+
if (converted !== existing) {
|
|
302
|
+
actions.push({ type: 'updated', direction: 'claude-to-codex', category: 'command', name: `${cmd.namespace}/${cmd.name}`, skillName });
|
|
303
|
+
} else {
|
|
304
|
+
actions.push({ type: 'in-sync', direction: 'both', category: 'command', name: `${cmd.namespace}/${cmd.name}` });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return actions;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function classifyRulesActions(root) {
|
|
313
|
+
const rules = getClaudeRules(root);
|
|
314
|
+
if (rules.length === 0) return [];
|
|
315
|
+
|
|
316
|
+
const instructionsPath = path.join(root, '.codex', 'instructions.md');
|
|
317
|
+
if (!fs.existsSync(instructionsPath)) {
|
|
318
|
+
return [{ type: 'new', direction: 'claude-to-codex', category: 'rules', name: `${rules.length} rule file(s)` }];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const instructions = fs.readFileSync(instructionsPath, 'utf8');
|
|
322
|
+
const newSection = buildRulesSection(root, rules);
|
|
323
|
+
|
|
324
|
+
// Extract existing section
|
|
325
|
+
const startIdx = instructions.indexOf(RULES_SYNC_START);
|
|
326
|
+
const endIdx = instructions.indexOf(RULES_SYNC_END);
|
|
327
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
328
|
+
return [{ type: 'new', direction: 'claude-to-codex', category: 'rules', name: `${rules.length} rule file(s)` }];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const existing = instructions.substring(startIdx, endIdx + RULES_SYNC_END.length);
|
|
332
|
+
if (existing === newSection) {
|
|
333
|
+
return [{ type: 'in-sync', direction: 'both', category: 'rules', name: `${rules.length} rule file(s)` }];
|
|
334
|
+
}
|
|
335
|
+
return [{ type: 'updated', direction: 'claude-to-codex', category: 'rules', name: `${rules.length} rule file(s)` }];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function classifyAgentActions(root) {
|
|
339
|
+
const agents = getClaudeCustomAgents(root);
|
|
340
|
+
if (agents.length === 0) return [];
|
|
341
|
+
|
|
342
|
+
const instructionsPath = path.join(root, '.codex', 'instructions.md');
|
|
343
|
+
if (!fs.existsSync(instructionsPath)) {
|
|
344
|
+
return [{ type: 'new', direction: 'claude-to-codex', category: 'agents', name: `${agents.length} agent(s)` }];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const instructions = fs.readFileSync(instructionsPath, 'utf8');
|
|
348
|
+
const newSection = buildAgentsSection(root, agents);
|
|
349
|
+
|
|
350
|
+
const startIdx = instructions.indexOf(AGENTS_SYNC_START);
|
|
351
|
+
const endIdx = instructions.indexOf(AGENTS_SYNC_END);
|
|
352
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
353
|
+
return [{ type: 'new', direction: 'claude-to-codex', category: 'agents', name: `${agents.length} agent(s)` }];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const existing = instructions.substring(startIdx, endIdx + AGENTS_SYNC_END.length);
|
|
357
|
+
if (existing === newSection) {
|
|
358
|
+
return [{ type: 'in-sync', direction: 'both', category: 'agents', name: `${agents.length} agent(s)` }];
|
|
359
|
+
}
|
|
360
|
+
return [{ type: 'updated', direction: 'claude-to-codex', category: 'agents', name: `${agents.length} agent(s)` }];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Build sync content ─────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
function buildRulesSection(root, ruleFiles) {
|
|
366
|
+
const rulesDir = path.join(root, '.claude', 'rules');
|
|
367
|
+
let section = `${RULES_SYNC_START}\n## Project Rules\n\n`;
|
|
368
|
+
section += '> Synced from .claude/rules/ — do not edit this section manually.\n';
|
|
369
|
+
section += '> Re-run /5:synchronize-agents (or $5-synchronize-agents) to update.\n\n';
|
|
370
|
+
|
|
371
|
+
for (const file of ruleFiles) {
|
|
372
|
+
const content = fs.readFileSync(path.join(rulesDir, file), 'utf8');
|
|
373
|
+
const { body } = extractFrontmatterAndBody(content);
|
|
374
|
+
const name = file.replace('.md', '');
|
|
375
|
+
section += `### ${name}\n\n${body.trim()}\n\n`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
section += RULES_SYNC_END;
|
|
379
|
+
return section;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function buildAgentsSection(root, agentFiles) {
|
|
383
|
+
const agentsDir = path.join(root, '.claude', 'agents');
|
|
384
|
+
let section = `${AGENTS_SYNC_START}\n## Custom Agent References\n\n`;
|
|
385
|
+
section += '> Synced from .claude/agents/ — do not edit this section manually.\n';
|
|
386
|
+
section += '> In Codex, use `spawn_agent()` with equivalent instructions.\n\n';
|
|
387
|
+
|
|
388
|
+
for (const file of agentFiles) {
|
|
389
|
+
const content = fs.readFileSync(path.join(agentsDir, file), 'utf8');
|
|
390
|
+
const converted = claudeToCodexContent(content);
|
|
391
|
+
const name = file.replace('.md', '');
|
|
392
|
+
section += `### ${name}\n\n${converted.trim()}\n\n`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
section += AGENTS_SYNC_END;
|
|
396
|
+
return section;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function updateInstructionsSection(instructionsContent, startMarker, endMarker, newSection) {
|
|
400
|
+
const startIdx = instructionsContent.indexOf(startMarker);
|
|
401
|
+
const endIdx = instructionsContent.indexOf(endMarker);
|
|
402
|
+
|
|
403
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
404
|
+
// Replace existing section
|
|
405
|
+
return instructionsContent.substring(0, startIdx) +
|
|
406
|
+
newSection +
|
|
407
|
+
instructionsContent.substring(endIdx + endMarker.length);
|
|
408
|
+
}
|
|
409
|
+
// Append
|
|
410
|
+
return instructionsContent.trimEnd() + '\n\n' + newSection + '\n';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Execute sync ───────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
function executeSync(root, actions) {
|
|
416
|
+
const counts = { 'claude-to-codex': { new: 0, updated: 0 }, 'codex-to-claude': { new: 0, updated: 0 }, synced: 0 };
|
|
417
|
+
|
|
418
|
+
for (const action of actions) {
|
|
419
|
+
if (action.type === 'in-sync') {
|
|
420
|
+
counts.synced++;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (action.category === 'skill') {
|
|
425
|
+
if (action.direction === 'claude-to-codex') {
|
|
426
|
+
syncSkillClaudeToCodex(root, action.name);
|
|
427
|
+
} else {
|
|
428
|
+
syncSkillCodexToClaude(root, action.name);
|
|
429
|
+
}
|
|
430
|
+
counts[action.direction][action.type]++;
|
|
431
|
+
} else if (action.category === 'command') {
|
|
432
|
+
syncCommandClaudeToCodex(root, action);
|
|
433
|
+
counts['claude-to-codex'][action.type]++;
|
|
434
|
+
}
|
|
435
|
+
// rules and agents are handled separately in batch
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Sync rules to instructions.md
|
|
439
|
+
const ruleActions = actions.filter(a => a.category === 'rules' && a.type !== 'in-sync');
|
|
440
|
+
if (ruleActions.length > 0) {
|
|
441
|
+
syncRulesToInstructions(root);
|
|
442
|
+
for (const a of ruleActions) counts['claude-to-codex'][a.type]++;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Sync agents to instructions.md
|
|
446
|
+
const agentActions = actions.filter(a => a.category === 'agents' && a.type !== 'in-sync');
|
|
447
|
+
if (agentActions.length > 0) {
|
|
448
|
+
syncAgentsToInstructions(root);
|
|
449
|
+
for (const a of agentActions) counts['claude-to-codex'][a.type]++;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return counts;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function syncSkillClaudeToCodex(root, skillName) {
|
|
456
|
+
const src = path.join(root, '.claude', 'skills', skillName);
|
|
457
|
+
const dest = path.join(root, '.codex', 'skills', skillName);
|
|
458
|
+
copyDirContents(src, dest, convertClaudeSkillToCodex);
|
|
459
|
+
log.dim(`skill: ${skillName} → .codex/skills/${skillName}/`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function syncSkillCodexToClaude(root, skillName) {
|
|
463
|
+
const src = path.join(root, '.codex', 'skills', skillName);
|
|
464
|
+
const dest = path.join(root, '.claude', 'skills', skillName);
|
|
465
|
+
copyDirContents(src, dest, convertCodexSkillToClaude);
|
|
466
|
+
log.dim(`skill: ${skillName} → .claude/skills/${skillName}/`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function syncCommandClaudeToCodex(root, action) {
|
|
470
|
+
const [ns, name] = action.name.split('/');
|
|
471
|
+
const srcFile = path.join(root, '.claude', 'commands', ns, `${name}.md`);
|
|
472
|
+
const content = fs.readFileSync(srcFile, 'utf8');
|
|
473
|
+
const converted = convertClaudeCommandToCodexSkill(content, action.skillName);
|
|
474
|
+
|
|
475
|
+
const destDir = path.join(root, '.codex', 'skills', action.skillName);
|
|
476
|
+
if (!fs.existsSync(destDir)) {
|
|
477
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
478
|
+
}
|
|
479
|
+
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
|
480
|
+
log.dim(`command: ${action.name} → .codex/skills/${action.skillName}/`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function syncRulesToInstructions(root) {
|
|
484
|
+
const rules = getClaudeRules(root);
|
|
485
|
+
const section = buildRulesSection(root, rules);
|
|
486
|
+
const instructionsPath = path.join(root, '.codex', 'instructions.md');
|
|
487
|
+
let content = fs.existsSync(instructionsPath) ? fs.readFileSync(instructionsPath, 'utf8') : '';
|
|
488
|
+
content = updateInstructionsSection(content, RULES_SYNC_START, RULES_SYNC_END, section);
|
|
489
|
+
fs.writeFileSync(instructionsPath, content);
|
|
490
|
+
log.dim(`rules: ${rules.length} file(s) → .codex/instructions.md`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function syncAgentsToInstructions(root) {
|
|
494
|
+
const agents = getClaudeCustomAgents(root);
|
|
495
|
+
const section = buildAgentsSection(root, agents);
|
|
496
|
+
const instructionsPath = path.join(root, '.codex', 'instructions.md');
|
|
497
|
+
let content = fs.existsSync(instructionsPath) ? fs.readFileSync(instructionsPath, 'utf8') : '';
|
|
498
|
+
content = updateInstructionsSection(content, AGENTS_SYNC_START, AGENTS_SYNC_END, section);
|
|
499
|
+
fs.writeFileSync(instructionsPath, content);
|
|
500
|
+
log.dim(`agents: ${agents.length} file(s) → .codex/instructions.md`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Display ────────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
function formatActionLabel(type) {
|
|
506
|
+
if (type === 'new') return `${colors.green}NEW${colors.reset}`;
|
|
507
|
+
if (type === 'updated') return `${colors.yellow}UPD${colors.reset}`;
|
|
508
|
+
return `${colors.dim}OK${colors.reset}`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function printSummary(actions) {
|
|
512
|
+
const claudeToCodex = actions.filter(a => a.direction === 'claude-to-codex');
|
|
513
|
+
const codexToClaude = actions.filter(a => a.direction === 'codex-to-claude');
|
|
514
|
+
const inSync = actions.filter(a => a.type === 'in-sync');
|
|
515
|
+
|
|
516
|
+
if (claudeToCodex.length > 0) {
|
|
517
|
+
console.log(' Claude → Codex:');
|
|
518
|
+
for (const a of claudeToCodex) {
|
|
519
|
+
console.log(` [${formatActionLabel(a.type)}] ${a.category}: ${a.name}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (codexToClaude.length > 0) {
|
|
524
|
+
console.log(' Codex → Claude:');
|
|
525
|
+
for (const a of codexToClaude) {
|
|
526
|
+
console.log(` [${formatActionLabel(a.type)}] ${a.category}: ${a.name}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (inSync.length > 0) {
|
|
531
|
+
console.log(` ${colors.dim}Already in sync: ${inSync.length} item(s)${colors.reset}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function printResults(counts) {
|
|
536
|
+
const c2c = counts['claude-to-codex'];
|
|
537
|
+
const c2cl = counts['codex-to-claude'];
|
|
538
|
+
const total = c2c.new + c2c.updated + c2cl.new + c2cl.updated;
|
|
539
|
+
|
|
540
|
+
if (total === 0 && counts.synced > 0) {
|
|
541
|
+
log.success(`Everything is already in sync (${counts.synced} item(s))`);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
console.log('');
|
|
546
|
+
if (c2c.new + c2c.updated > 0) {
|
|
547
|
+
console.log(` Claude → Codex: ${c2c.new} new, ${c2c.updated} updated`);
|
|
548
|
+
}
|
|
549
|
+
if (c2cl.new + c2cl.updated > 0) {
|
|
550
|
+
console.log(` Codex → Claude: ${c2cl.new} new, ${c2cl.updated} updated`);
|
|
551
|
+
}
|
|
552
|
+
if (counts.synced > 0) {
|
|
553
|
+
console.log(` ${colors.dim}Skipped: ${counts.synced} (already in sync)${colors.reset}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
function main() {
|
|
560
|
+
const args = process.argv.slice(2);
|
|
561
|
+
const dryRun = args.includes('--dry-run');
|
|
562
|
+
const quiet = args.includes('--quiet');
|
|
563
|
+
|
|
564
|
+
const root = findProjectRoot();
|
|
565
|
+
|
|
566
|
+
if (!quiet) {
|
|
567
|
+
log.header('Synchronize Agent Runtimes');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Step 1: Detect runtimes
|
|
571
|
+
const hasClaude = isClaudeInstalled(root);
|
|
572
|
+
const hasCodex = isCodexInstalled(root);
|
|
573
|
+
|
|
574
|
+
if (!hasClaude && !hasCodex) {
|
|
575
|
+
log.error('No runtime installations found.');
|
|
576
|
+
log.info('Install Claude Code: npx 5-phase-workflow');
|
|
577
|
+
log.info('Install Codex: npx 5-phase-workflow --codex');
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (!hasClaude) {
|
|
582
|
+
log.error('Claude Code runtime not installed.');
|
|
583
|
+
log.info('Install with: npx 5-phase-workflow');
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (!hasCodex) {
|
|
588
|
+
log.error('Codex runtime not installed.');
|
|
589
|
+
log.info('Install with: npx 5-phase-workflow --codex');
|
|
590
|
+
process.exit(1);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (!quiet) {
|
|
594
|
+
log.success('Both runtimes detected');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Step 2-3: Inventory and classify
|
|
598
|
+
const actions = [
|
|
599
|
+
...classifySkillActions(root),
|
|
600
|
+
...classifyCommandActions(root),
|
|
601
|
+
...classifyRulesActions(root),
|
|
602
|
+
...classifyAgentActions(root)
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
if (actions.length === 0) {
|
|
606
|
+
log.info('No user-generated content found to synchronize.');
|
|
607
|
+
process.exit(0);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const actionable = actions.filter(a => a.type !== 'in-sync');
|
|
611
|
+
|
|
612
|
+
if (actionable.length === 0) {
|
|
613
|
+
log.success(`Everything is already in sync (${actions.length} item(s))`);
|
|
614
|
+
process.exit(0);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Step 4: Show summary
|
|
618
|
+
if (!quiet) {
|
|
619
|
+
console.log('');
|
|
620
|
+
printSummary(actions);
|
|
621
|
+
console.log('');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (dryRun) {
|
|
625
|
+
log.info('Dry run — no changes made.');
|
|
626
|
+
process.exit(0);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Step 5: Execute
|
|
630
|
+
const counts = executeSync(root, actions);
|
|
631
|
+
|
|
632
|
+
// Step 6: Report
|
|
633
|
+
if (!quiet) {
|
|
634
|
+
log.header('Synchronization Complete');
|
|
635
|
+
printResults(counts);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
main();
|