@nurix/etna 0.19.22 → 0.19.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +6 -5
  2. package/data/harness-index.json +1 -1
  3. package/data/harnesses/code/.vscode/settings.json +9 -0
  4. package/data/harnesses/code/CLAUDE.md +16 -1
  5. package/data/harnesses/code/agents/brainstorm.md +11 -11
  6. package/data/harnesses/code/agents/change-review.md +4 -4
  7. package/data/harnesses/code/agents/maintenance.md +4 -3
  8. package/data/harnesses/code/agents/retro.md +45 -0
  9. package/data/harnesses/code/codex/agents/brainstorm.toml +11 -11
  10. package/data/harnesses/code/codex/agents/change-review.toml +4 -5
  11. package/data/harnesses/code/codex/agents/maintenance.toml +2 -1
  12. package/data/harnesses/code/codex/agents/retro.toml +43 -0
  13. package/data/harnesses/code/harness.json +2 -2
  14. package/data/harnesses/code/rules/database.md +29 -1
  15. package/data/harnesses/code/skills/documentation/SKILL.md +1 -0
  16. package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +3 -1
  17. package/data/harnesses/code/skills/documentation/templates/refinement-ledger.md +33 -0
  18. package/data/harnesses/code/skills/documentation/templates/structure.md +2 -0
  19. package/data/harnesses/code/skills/test-plan/SKILL.md +82 -0
  20. package/data/harnesses/discover/.vscode/settings.json +9 -0
  21. package/data/harnesses/discover/harness.json +1 -1
  22. package/data/harnesses/discussion/.vscode/settings.json +9 -0
  23. package/data/harnesses/discussion/harness.json +2 -2
  24. package/data/harnesses/discussion/rules/artifacts.md +1 -1
  25. package/data/harnesses/discussion/rules/method.md +2 -0
  26. package/data/harnesses/etna/.vscode/settings.json +9 -0
  27. package/data/harnesses/etna/CLAUDE.md +2 -0
  28. package/data/harnesses/etna/agents/brainstorm.md +11 -11
  29. package/data/harnesses/etna/agents/change-review.md +4 -4
  30. package/data/harnesses/etna/agents/maintenance.md +4 -3
  31. package/data/harnesses/etna/codex/agents/brainstorm.toml +11 -11
  32. package/data/harnesses/etna/codex/agents/change-review.toml +4 -5
  33. package/data/harnesses/etna/codex/agents/maintenance.toml +2 -1
  34. package/data/harnesses/etna/harness.json +2 -2
  35. package/data/harnesses/etna/skills/documentation/SKILL.md +1 -0
  36. package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +3 -1
  37. package/data/harnesses/etna/skills/documentation/templates/refinement-ledger.md +33 -0
  38. package/data/harnesses/etna/skills/documentation/templates/structure.md +2 -0
  39. package/data/harnesses/hr/.vscode/settings.json +9 -0
  40. package/data/harnesses/hr/harness.json +2 -2
  41. package/data/harnesses/kb-agi/.vscode/settings.json +9 -0
  42. package/data/harnesses/kb-agi/harness.json +1 -1
  43. package/lib/codex/README.md +17 -7
  44. package/lib/codex/agents-md.mjs +85 -6
  45. package/lib/codex/hooks.mjs +25 -1
  46. package/lib/codex/index.mjs +4 -3
  47. package/lib/codex/plugin-manifest.mjs +9 -3
  48. package/lib/codex/subagents.mjs +2 -2
  49. package/lib/codex/validate-codex-agents.mjs +126 -0
  50. package/package.json +2 -2
  51. package/lib/codex/gen-agents.mjs +0 -157
@@ -1,9 +1,14 @@
1
1
  // packages/cli/lib/codex/agents-md.mjs
2
- // CLAUDE.md → AGENTS.md — an exact copy (Codex's confirmed CLAUDE.md analog).
3
- // Mirrors the installer's CLAUDE.md safety net: overwrite in place when git can
4
- // recover it, otherwise back up the existing file and warn.
2
+ // CLAUDE.md → AGENTS.md — the authored policy copied byte-for-byte, then a small GENERATED
3
+ // Codex runtime adapter appended. The policy is runtime-neutral; the adapter maps its
4
+ // capability hooks to Codex's native locations and, crucially, routes Codex to the markdown
5
+ // rules under .claude/rules/ that Codex does NOT auto-load. The adapter is derived from the
6
+ // bundled harness inventory (rules/skills/agents actually shipped), carries no user state, and
7
+ // is regenerated whole on every reinstall alongside the rest of the harness-owned AGENTS.md.
8
+ // Mirrors the installer's CLAUDE.md safety net: overwrite in place when git can recover it,
9
+ // otherwise back up the existing file and warn.
5
10
 
6
- import { existsSync, lstatSync, renameSync, cpSync } from 'node:fs';
11
+ import { existsSync, lstatSync, renameSync, readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
7
12
  import { join } from 'node:path';
8
13
 
9
14
  function isSymlink(path) {
@@ -14,7 +19,75 @@ function isSymlink(path) {
14
19
  }
15
20
  }
16
21
 
17
- export function projectAgentsMd({ targetDir, bundledClaudeMd, projectHasGit }) {
22
+ // Sorted base names of *.<ext> files directly under dir (e.g. rules/*.md → rule names).
23
+ function listByExt(dir, ext) {
24
+ if (!existsSync(dir)) return [];
25
+ return readdirSync(dir)
26
+ .filter((f) => f.endsWith(ext))
27
+ .map((f) => f.slice(0, -ext.length))
28
+ .sort();
29
+ }
30
+
31
+ // Sorted names of immediate subdirectories of dir (e.g. skills/<name>/).
32
+ function listSubdirs(dir) {
33
+ if (!existsSync(dir)) return [];
34
+ return readdirSync(dir)
35
+ .filter((f) => {
36
+ try {
37
+ return statSync(join(dir, f)).isDirectory();
38
+ } catch {
39
+ return false;
40
+ }
41
+ })
42
+ .sort();
43
+ }
44
+
45
+ // Render an inventory list as inline code names, or a runtime-neutral fallback when empty.
46
+ function nameList(names) {
47
+ return names.length ? names.map((n) => `\`${n}\``).join(', ') : '_(none installed)_';
48
+ }
49
+
50
+ // Compose the generated Codex runtime adapter from the bundled harness inventory. Returns "" when
51
+ // the bundle carries no rules, skills, or codex agents — nothing to adapt, so nothing is appended.
52
+ export function buildCodexAdapter(bundledHarnessDir) {
53
+ const rules = listByExt(join(bundledHarnessDir, 'rules'), '.md');
54
+ const skills = listSubdirs(join(bundledHarnessDir, 'skills'));
55
+ const agents = listByExt(join(bundledHarnessDir, 'codex', 'agents'), '.toml');
56
+ if (!rules.length && !skills.length && !agents.length) return '';
57
+
58
+ const lines = [
59
+ '---',
60
+ '',
61
+ '## Codex runtime adapter (generated)',
62
+ '',
63
+ 'This project is also installed for OpenAI Codex. The runtime-neutral policy above applies unchanged;',
64
+ "this section maps its capability hooks to Codex's native locations. It is generated from the installed",
65
+ 'harness inventory and replaced whole on every reinstall — do not hand-edit.',
66
+ '',
67
+ ];
68
+ if (skills.length) {
69
+ lines.push(
70
+ `- **Skills** — Codex scans \`.agents/skills/\` (a mirror of \`.claude/skills/\`); invoke a skill by its name. Installed: ${nameList(skills)}.`,
71
+ );
72
+ }
73
+ if (agents.length) {
74
+ lines.push(
75
+ `- **Custom agents** — Codex reads \`.codex/agents/*.toml\`; dispatch a subagent by its name. Installed: ${nameList(agents)}.`,
76
+ );
77
+ }
78
+ if (rules.length) {
79
+ lines.push(
80
+ `- **Project rules** — Codex does NOT auto-load the markdown rules the policy references. Read the relevant file under \`.claude/rules/\` before the work it governs: ${nameList(rules)}.`,
81
+ );
82
+ }
83
+ lines.push(
84
+ '- **Ownership index** — regenerated each session by the `.codex/hooks.json` SessionStart hook; if it is absent from context, derive it by scanning `docs/features/` frontmatter.',
85
+ );
86
+ lines.push('');
87
+ return lines.join('\n');
88
+ }
89
+
90
+ export function projectAgentsMd({ targetDir, bundledClaudeMd, bundledHarnessDir, projectHasGit }) {
18
91
  if (!existsSync(bundledClaudeMd)) return null;
19
92
  const dest = join(targetDir, 'AGENTS.md');
20
93
  const existed = existsSync(dest) || isSymlink(dest);
@@ -30,6 +103,12 @@ export function projectAgentsMd({ targetDir, bundledClaudeMd, projectHasGit }) {
30
103
  warning = `No .git here, so no version-control safety net — backed up existing AGENTS.md → ${backup}`;
31
104
  }
32
105
  }
33
- cpSync(bundledClaudeMd, dest, { force: true });
106
+ // Policy copied byte-for-byte; the generated adapter appended after a blank-line separator.
107
+ // The policy prefix is left exactly as authored — only a separator + adapter follow it.
108
+ const policy = readFileSync(bundledClaudeMd, 'utf8');
109
+ const adapter = bundledHarnessDir ? buildCodexAdapter(bundledHarnessDir) : '';
110
+ const separator = policy.endsWith('\n') ? '\n' : '\n\n';
111
+ const body = adapter ? policy + separator + adapter : policy;
112
+ writeFileSync(dest, body);
34
113
  return { installed: 'AGENTS.md → ./', notice, warning };
35
114
  }
@@ -3,10 +3,32 @@
3
3
  // filtered to type:"command" (the only hook type Codex executes — "prompt"/"agent"
4
4
  // entries are parsed but skipped). A standalone file, not a config.toml [hooks]
5
5
  // table, since the settings/config surface is out of scope for Codex here.
6
+ //
7
+ // Command translation: the source command locates its script via $CLAUDE_PROJECT_DIR,
8
+ // which Claude Code exports but Codex does not — under Codex the token expands empty
9
+ // and `node "/.claude/hooks/…"` exits 1, failing SessionStart. So every command that
10
+ // references the token is prefixed with a runtime-neutral root resolution: `git
11
+ // rev-parse` (walks up from any nested working directory) with a `pwd` fallback for a
12
+ // non-Git project root. The resolved root is exported back as CLAUDE_PROJECT_DIR so the
13
+ // script's own project-dir read (generate-ownership-index.mjs resolveProjectDir) also
14
+ // points at the project root, not the session's nested cwd. The Claude source hooks.json
15
+ // is left untouched — it stays valid for the CLI-installed .claude/settings.json path.
6
16
 
7
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
18
  import { join, dirname } from 'node:path';
9
19
 
20
+ const PROJECT_DIR_TOKEN = /\$\{?CLAUDE_PROJECT_DIR\}?/;
21
+
22
+ // Prefix a $CLAUDE_PROJECT_DIR-dependent command with a Codex-safe root resolution.
23
+ // Commands that don't reference the token pass through unchanged.
24
+ export function toCodexCommand(command) {
25
+ if (typeof command !== 'string' || !PROJECT_DIR_TOKEN.test(command)) return command;
26
+ return (
27
+ 'export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; ' +
28
+ command
29
+ );
30
+ }
31
+
10
32
  export function projectHooks({ targetDir, bundledHarnessDir }) {
11
33
  const srcJson = join(bundledHarnessDir, 'hooks', 'hooks.json');
12
34
  if (!existsSync(srcJson)) return null;
@@ -20,7 +42,9 @@ export function projectHooks({ targetDir, bundledHarnessDir }) {
20
42
  for (const [event, groups] of Object.entries(parsed.hooks || {})) {
21
43
  const kept = [];
22
44
  for (const group of groups) {
23
- const hooks = (group.hooks || []).filter((h) => h.type === 'command');
45
+ const hooks = (group.hooks || [])
46
+ .filter((h) => h.type === 'command')
47
+ .map((h) => ({ ...h, command: toCodexCommand(h.command) }));
24
48
  if (hooks.length) kept.push({ ...group, hooks });
25
49
  }
26
50
  if (kept.length) out[event] = kept;
@@ -39,11 +39,12 @@ export function projectCodexArtifacts({
39
39
  owned.push(ownedPath);
40
40
  };
41
41
 
42
- // AGENTS.md — exact copy of CLAUDE.md, with the CLAUDE.md backup-if-no-git rule.
43
- collect(projectAgentsMd({ targetDir, bundledClaudeMd, projectHasGit }), 'AGENTS.md');
42
+ // AGENTS.md — CLAUDE.md copied byte-for-byte + a generated Codex runtime adapter derived from
43
+ // the bundled harness inventory (see agents-md.mjs), with the CLAUDE.md backup-if-no-git rule.
44
+ collect(projectAgentsMd({ targetDir, bundledClaudeMd, bundledHarnessDir, projectHasGit }), 'AGENTS.md');
44
45
  // .agents/skills — full-rebuilt mirror of the installed (already ship-filtered) .claude/skills.
45
46
  collect(projectSkills({ targetDir, projectHasGit }), '.agents/skills');
46
- // .codex/agents — copy the shipped, pre-generated *.toml (see gen-agents.mjs).
47
+ // .codex/agents — copy the shipped, pre-authored *.toml (see docs/architecture/harness/authoring-agents.md).
47
48
  collect(copyCodexAgents({ targetDir, bundledHarnessDir, projectHasGit }), '.codex/agents');
48
49
  // .codex/hooks.json — Claude hooks.json filtered to type:"command".
49
50
  collect(projectHooks({ targetDir, bundledHarnessDir }), '.codex/hooks.json');
@@ -19,8 +19,13 @@ export function buildCodexPluginJson(fm, { hasHooks = false } = {}) {
19
19
  return out;
20
20
  }
21
21
 
22
- // The repo marketplace catalog Codex reads at .agents/plugins/marketplace.json.
23
- // Each plugin is a local source; the first keyword (if any) seeds a category.
22
+ // The repo marketplace catalog Codex reads at .agents/plugins/marketplace.json. Each plugin is a
23
+ // LOCAL source (source.path relative to the catalog root) carrying an explicit install policy:
24
+ // · installation AVAILABLE — offered, not force-installed (INSTALLED_BY_DEFAULT / NOT_AVAILABLE
25
+ // are the other documented tokens; these plugins are opt-in).
26
+ // · authentication NONE — they bundle only skills + hooks, no credentials or external auth.
27
+ // The first keyword (if any) seeds a category. (Enum tokens per codex-support.md → Open Questions;
28
+ // AVAILABLE/NONE are the correct pair for an opt-in, no-auth plugin.)
24
29
  export function buildCodexMarketplace(plugins) {
25
30
  return {
26
31
  name: 'nurixlabs',
@@ -29,7 +34,8 @@ export function buildCodexMarketplace(plugins) {
29
34
  .sort((a, b) => a.name.localeCompare(b.name))
30
35
  .map((p) => ({
31
36
  name: p.name,
32
- source: `./plugins/${p.name}`,
37
+ source: { path: `./plugins/${p.name}` },
38
+ policy: { installation: 'AVAILABLE', authentication: 'NONE' },
33
39
  ...(p.frontmatter?.keywords?.length ? { category: p.frontmatter.keywords[0] } : {}),
34
40
  })),
35
41
  };
@@ -1,6 +1,6 @@
1
1
  // packages/cli/lib/codex/subagents.mjs
2
- // Copy the harness's pre-generated Codex subagent TOML — bundle codex/agents/*.toml,
3
- // authored on a Codex-authenticated laptop by gen-agents.mjs — into the project's
2
+ // Copy the harness's pre-authored Codex subagent TOML — bundle codex/agents/*.toml,
3
+ // authored per docs/architecture/harness/authoring-agents.md — into the project's
4
4
  // .codex/agents/. Full rebuild each run. The consumer needs NO Codex CLI: the TOML
5
5
  // ships ready, so a missing local Codex never blocks an install.
6
6
 
@@ -0,0 +1,126 @@
1
+ // packages/cli/lib/codex/validate-codex-agents.mjs
2
+ // The Codex-twin validation gate — decoupled from any generator. A `<name>.codex.toml`
3
+ // twin is now authored by the AI following docs/architecture/harness/authoring-agents.md,
4
+ // so this is the check that keeps a malformed or Claude-only twin from shipping: the twins
5
+ // are copied UNPARSED into the bundle (build-data.js) and a consumer's .codex/agents/, so
6
+ // nothing else catches a bad one before Codex fails to load it in that repo. Run it after
7
+ // authoring/editing a twin (the authoring-agents.md checklist mandates it) and, ideally, as
8
+ // a fail-loud publish gate.
9
+ //
10
+ // Checks each twin for: valid TOML (python3 tomllib when present), the required keys
11
+ // (name/description/developer_instructions), and runtime-neutrality (no Claude-only dispatch
12
+ // primitives or fixed model tiers). Also flags a source agent with NO twin (out of sync).
13
+ //
14
+ // Usage:
15
+ // node packages/cli/lib/codex/validate-codex-agents.mjs # shared catalog + all harnesses
16
+ // node packages/cli/lib/codex/validate-codex-agents.mjs --quiet # errors only
17
+ // Exits 0 when every twin is valid, 1 otherwise.
18
+
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { dirname, join, resolve, basename } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { spawnSync } from 'node:child_process';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const repoRoot = resolve(__dirname, '..', '..', '..', '..');
26
+ const quiet = process.argv.slice(2).includes('--quiet');
27
+
28
+ // Every source agent .md and its expected twin path: the shared catalog plus every harness's
29
+ // owned agents. The .md is the authoritative set — a listed agent with no twin is out of sync.
30
+ function collectAgents() {
31
+ const out = [];
32
+ const push = (dir) => {
33
+ if (!existsSync(dir)) return;
34
+ for (const f of readdirSync(dir)) {
35
+ if (!f.endsWith('.md')) continue;
36
+ const name = basename(f, '.md');
37
+ out.push({ name, mdPath: join(dir, f), tomlPath: join(dir, `${name}.codex.toml`) });
38
+ }
39
+ };
40
+ push(join(repoRoot, 'agents'));
41
+ const harnessDir = join(repoRoot, 'harness');
42
+ if (existsSync(harnessDir)) {
43
+ for (const harness of readdirSync(harnessDir)) {
44
+ if (harness === 'common') continue; // not a harness — the shared root-files folder
45
+ push(join(harnessDir, harness, 'agents'));
46
+ }
47
+ }
48
+ return out.sort((a, b) => a.tomlPath.localeCompare(b.tomlPath));
49
+ }
50
+
51
+ // TOML parse via python3's tomllib when present; '' = valid, a message = invalid, null =
52
+ // python3 unavailable (the key + runtime-neutral regex checks still run, so the gate degrades
53
+ // gracefully rather than passing blind — but CI/macOS both ship python3, so parse runs there).
54
+ function tomlError(toml) {
55
+ const res = spawnSync('python3', ['-c', 'import tomllib,sys; tomllib.loads(sys.stdin.read())'], {
56
+ input: toml,
57
+ encoding: 'utf8',
58
+ });
59
+ if (res.error && res.error.code === 'ENOENT') return null;
60
+ return res.status === 0 ? '' : (res.stderr || 'invalid TOML').trim();
61
+ }
62
+
63
+ // A Codex twin runs under Codex, which has neither the Task tool nor Claude model aliases. The
64
+ // md body is copied verbatim into developer_instructions, so a hit here is a hit in the SOURCE
65
+ // agent. The dropped-tools comment lists bare names (`Task`, `Skill`) which are fine — only the
66
+ // prose forms (`Task tool`/`Skill tool`) and model-tier aliases are rejected.
67
+ const CLAUDE_ONLY_PATTERNS = [
68
+ { re: /Task tool/i, label: '"Task tool" (name the runtime-neutral outcome, not the Claude tool)' },
69
+ { re: /Skill tool/i, label: '"Skill tool" (refer to the skill by name, not the Claude tool)' },
70
+ { re: /subagent_type/i, label: '`subagent_type` (a Claude Agent-tool parameter)' },
71
+ { re: /\bhaiku\b/i, label: '"haiku" (a fixed Claude model tier — say "cheapest capable tier")' },
72
+ { re: /\bopus\b/i, label: '"opus" (a fixed Claude model tier — say "strongest tier")' },
73
+ { re: /\bsonnet\b/i, label: '"sonnet" (a fixed Claude model tier)' },
74
+ ];
75
+
76
+ const REQUIRED_KEYS = ['name', 'description', 'developer_instructions'];
77
+
78
+ // Return the list of problems with one twin (empty ⇒ valid).
79
+ function validateTwin(tomlPath) {
80
+ const problems = [];
81
+ const toml = readFileSync(tomlPath, 'utf8');
82
+ for (const key of REQUIRED_KEYS) {
83
+ if (!new RegExp(`^\\s*${key}\\s*=`, 'm').test(toml)) problems.push(`missing \`${key} =\` key`);
84
+ }
85
+ const err = tomlError(toml);
86
+ if (err) problems.push(`invalid TOML: ${err.split('\n').pop().slice(0, 160)}`);
87
+ for (const { re, label } of CLAUDE_ONLY_PATTERNS) {
88
+ if (re.test(toml)) problems.push(`Claude-only primitive: ${label}`);
89
+ }
90
+ return problems;
91
+ }
92
+
93
+ function main() {
94
+ const agents = collectAgents();
95
+ if (!agents.length) {
96
+ console.error('validate-codex-agents: no agents/*.md found.');
97
+ process.exit(1);
98
+ }
99
+ let ok = 0;
100
+ const failures = [];
101
+ for (const { name, tomlPath } of agents) {
102
+ if (!existsSync(tomlPath)) {
103
+ failures.push(`${name}: no twin at ${relPath(tomlPath)} (source .md has no .codex.toml — out of sync)`);
104
+ continue;
105
+ }
106
+ const problems = validateTwin(tomlPath);
107
+ if (problems.length) {
108
+ failures.push(`${relPath(tomlPath)}:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
109
+ } else {
110
+ ok += 1;
111
+ if (!quiet) console.log(` ok ${relPath(tomlPath)}`);
112
+ }
113
+ }
114
+ if (failures.length) {
115
+ console.error(`\nvalidate-codex-agents: ${failures.length} invalid, ${ok} ok of ${agents.length} twin(s):\n`);
116
+ for (const f of failures) console.error(` ✗ ${f}`);
117
+ process.exit(1);
118
+ }
119
+ console.log(`\nvalidate-codex-agents: all ${ok} twin(s) valid.`);
120
+ }
121
+
122
+ function relPath(p) {
123
+ return p.startsWith(repoRoot) ? p.slice(repoRoot.length + 1) : p;
124
+ }
125
+
126
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.22",
3
+ "version": "0.19.24",
4
4
  "description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "build:data": "node scripts/build-data.js",
23
23
  "prepack": "node scripts/build-data.js",
24
- "gen:codex-agents": "node lib/codex/gen-agents.mjs"
24
+ "validate:codex-agents": "node lib/codex/validate-codex-agents.mjs"
25
25
  },
26
26
  "repository": {
27
27
  "type": "git",
@@ -1,157 +0,0 @@
1
- // packages/cli/lib/codex/gen-agents.mjs
2
- // AUTHORING-TIME generator — runs ON A LAPTOP with the Codex CLI installed and
3
- // authenticated, NEVER in CI. For each Claude subagent (agents/<name>.md in the
4
- // shared catalog, plus harness/*/agents/*.md with --include-harness) it runs
5
- // `codex exec` to translate the markdown into an OpenAI Codex subagent TOML and
6
- // writes a committed sibling agents/<name>.codex.toml. The shippers (build-data.js,
7
- // publish.mjs) then carry those committed files; the installer copies them into a
8
- // consumer's .codex/agents/ with no Codex CLI required downstream.
9
- //
10
- // Usage:
11
- // node packages/cli/lib/codex/gen-agents.mjs # shared catalog, only stale/missing
12
- // node packages/cli/lib/codex/gen-agents.mjs --force # regenerate all shared agents
13
- // node packages/cli/lib/codex/gen-agents.mjs --include-harness # + harness/*/agents/*.md
14
-
15
- import {
16
- existsSync,
17
- readdirSync,
18
- readFileSync,
19
- writeFileSync,
20
- statSync,
21
- } from 'node:fs';
22
- import { dirname, join, resolve, basename } from 'node:path';
23
- import { fileURLToPath } from 'node:url';
24
- import { spawnSync } from 'node:child_process';
25
-
26
- const __dirname = dirname(fileURLToPath(import.meta.url));
27
- const repoRoot = resolve(__dirname, '..', '..', '..', '..');
28
-
29
- // Pinned conversion prompt — deterministic instructions so re-runs stay stable.
30
- // Codex does the semantic md→toml; the body is copied verbatim, tools are dropped
31
- // (no Codex equivalent) and recorded in a leading comment.
32
- const CONVERSION_PROMPT = [
33
- 'Convert the Claude Code subagent definition provided on stdin into an OpenAI Codex subagent TOML document.',
34
- 'Output ONLY the raw TOML — no prose, no markdown code fences, nothing before or after.',
35
- 'Set `name` from the frontmatter `name`.',
36
- 'Set `description` as a TOML basic string (double quotes) from the frontmatter `description`, escaping any interior double-quote as \\".',
37
- 'Put the ENTIRE markdown body (everything after the closing frontmatter ---) VERBATIM into a TOML multi-line',
38
- "LITERAL string delimited by triple SINGLE quotes: developer_instructions = '''<body>'''.",
39
- 'A literal string is verbatim — do NOT escape anything inside it, and do NOT use triple double-quotes (backslashes like \\s in the body must survive unchanged).',
40
- 'If frontmatter `model` is a real model id, set `model`; if it is `inherit`, omit `model`.',
41
- 'If frontmatter has a `tools:` line, do NOT translate it — instead put a single comment line',
42
- '`# dropped Claude tools: <the list>` as the first line of the file.',
43
- 'Emit no keys other than name, description, model (optional), developer_instructions.',
44
- ].join(' ');
45
-
46
- const args = process.argv.slice(2);
47
- const force = args.includes('--force');
48
- const includeHarness = args.includes('--include-harness');
49
-
50
- function die(msg) {
51
- console.error(`gen-codex-agents: ${msg}`);
52
- process.exit(1);
53
- }
54
-
55
- // Codex must be present + authenticated — this step cannot run in CI.
56
- function assertCodexReady() {
57
- const which = spawnSync('codex', ['--version'], { encoding: 'utf8' });
58
- if (which.status !== 0) {
59
- die('the `codex` CLI is not installed / not on PATH. This generator runs only on an authoring laptop.');
60
- }
61
- }
62
-
63
- // Every agents/<name>.md to convert: the shared catalog, plus harness-owned agents on request.
64
- function collectAgentFiles() {
65
- const files = [];
66
- const shared = join(repoRoot, 'agents');
67
- if (existsSync(shared)) {
68
- for (const f of readdirSync(shared)) {
69
- if (f.endsWith('.md')) files.push(join(shared, f));
70
- }
71
- }
72
- if (includeHarness) {
73
- const harnessDir = join(repoRoot, 'harness');
74
- if (existsSync(harnessDir)) {
75
- for (const harness of readdirSync(harnessDir)) {
76
- if (harness === 'common') continue; // not a harness — the shared root-files folder
77
- const agentsDir = join(harnessDir, harness, 'agents');
78
- if (!existsSync(agentsDir)) continue;
79
- for (const f of readdirSync(agentsDir)) {
80
- if (f.endsWith('.md')) files.push(join(agentsDir, f));
81
- }
82
- }
83
- }
84
- }
85
- return files.sort();
86
- }
87
-
88
- // A regen is due when forced, the toml is missing, or the md is newer than the toml.
89
- function needsRegen(mdPath, tomlPath) {
90
- if (force || !existsSync(tomlPath)) return true;
91
- return statSync(mdPath).mtimeMs > statSync(tomlPath).mtimeMs;
92
- }
93
-
94
- // Strip an accidental ```toml fence if the model wrapped its output despite the prompt.
95
- function stripFences(text) {
96
- const trimmed = text.trim();
97
- const fence = trimmed.match(/^```(?:toml)?\n([\s\S]*?)\n```$/);
98
- return (fence ? fence[1] : trimmed) + '\n';
99
- }
100
-
101
- // Validate that the produced TOML parses. Uses python3's tomllib when present
102
- // (returns true/error string); returns null when python3 is unavailable so the
103
- // caller proceeds best-effort rather than hard-failing on a missing dev tool.
104
- function tomlError(toml) {
105
- const res = spawnSync('python3', ['-c', 'import tomllib,sys; tomllib.loads(sys.stdin.read())'], {
106
- input: toml,
107
- encoding: 'utf8',
108
- });
109
- if (res.error && res.error.code === 'ENOENT') return null;
110
- return res.status === 0 ? '' : (res.stderr || 'invalid TOML').trim();
111
- }
112
-
113
- function convert(mdPath) {
114
- const md = readFileSync(mdPath, 'utf8');
115
- const res = spawnSync('codex', ['exec', '--sandbox', 'read-only', CONVERSION_PROMPT], {
116
- input: md,
117
- encoding: 'utf8',
118
- maxBuffer: 32 * 1024 * 1024,
119
- });
120
- if (res.status !== 0) {
121
- throw new Error(`codex exec failed for ${mdPath}: ${(res.stderr || '').slice(-400)}`);
122
- }
123
- const toml = stripFences(res.stdout || '');
124
- if (!/^\s*name\s*=/m.test(toml)) {
125
- throw new Error(`codex output for ${mdPath} has no \`name =\` key — refusing to write. Got:\n${toml.slice(0, 200)}`);
126
- }
127
- const err = tomlError(toml);
128
- if (err) {
129
- throw new Error(`codex output for ${mdPath} is not valid TOML — refusing to write:\n${err.slice(0, 300)}`);
130
- }
131
- return toml;
132
- }
133
-
134
- assertCodexReady();
135
- const files = collectAgentFiles();
136
- if (!files.length) die('no agents/*.md found to convert.');
137
-
138
- let wrote = 0;
139
- let skipped = 0;
140
- for (const mdPath of files) {
141
- const name = basename(mdPath, '.md');
142
- const tomlPath = join(dirname(mdPath), `${name}.codex.toml`);
143
- if (!needsRegen(mdPath, tomlPath)) {
144
- skipped += 1;
145
- continue;
146
- }
147
- process.stdout.write(`converting ${name} … `);
148
- try {
149
- writeFileSync(tomlPath, convert(mdPath));
150
- wrote += 1;
151
- console.log('ok');
152
- } catch (e) {
153
- console.log('FAILED');
154
- console.error(` ${e.message}`);
155
- }
156
- }
157
- console.log(`\ngen-codex-agents: wrote ${wrote}, skipped ${skipped} (up-to-date) of ${files.length} agent(s).`);