create-byan-agent 2.41.0 → 2.42.0

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/CHANGELOG.md CHANGED
@@ -9,6 +9,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.42.0] - 2026-07-06
13
+
14
+ ### Fixed - Shipped Claude Code hooks no longer spam MODULE_NOT_FOUND
15
+ - **Every hook command in the shipped `.claude/settings.json` is now
16
+ self-guarded.** The hooks run on every tool (empty matcher); a bare
17
+ `node "$CLAUDE_PROJECT_DIR"/.claude/hooks/X.js` threw `MODULE_NOT_FOUND` on
18
+ every tool call whenever the script was not resolvable — a non-BYAN project, an
19
+ empty `$CLAUDE_PROJECT_DIR`, a partial install, or version drift. Each command
20
+ is now `p="$CLAUDE_PROJECT_DIR/.claude/hooks/X.js"; [ -f "$p" ] || exit 0; exec
21
+ node "$p"`: a missing script no-ops with exit 0 (the tool proceeds), a present
22
+ script runs via `exec` so its exit code is preserved (a blocking PreToolUse /
23
+ Stop guard still blocks). Covered by
24
+ `install/__tests__/hook-invocation-guard.test.js` (guarded-shape on all 24
25
+ commands + runtime sh simulation: absent/empty -> 0, blocker -> 2, stdin
26
+ passthrough).
27
+
28
+ ### Changed - Handoff auto-import instructions
29
+ - **Claude and Codex activation surfaces now know how to handle
30
+ `importe depuis claude` / `importe depuis codex` automatically.** `CLAUDE.md`
31
+ and BYAN skills instruct the assistant to run
32
+ `byan-handoff latest --from <source> --prompt` and resume from the generated
33
+ context without relying on native assistant memory.
34
+
35
+ ### Added - Portable Claude/Codex Markdown handoff
36
+ - **BYAN can now export/import project state as a portable Markdown handoff for
37
+ switching between Claude Code and Codex.** The new `byan-handoff` CLI writes
38
+ handoffs under `_byan-output/handoffs/`, embeds a parseable
39
+ `json byan-handoff` block, and can print a compact resume prompt via
40
+ `byan-handoff latest --from <source> --prompt` or
41
+ `byan-handoff import <file> --prompt`.
42
+ A new `project-handoff` BYAN workflow documents the limit-switch protocol.
43
+ Covered by `install/__tests__/project-handoff.test.js`.
44
+
45
+ ### Changed - RTK offered on Codex-selected installs
46
+ - **Yanstaller now offers RTK when the selected target includes Codex, not only
47
+ Claude Code.** Claude Code keeps the transparent `rtk init -g --auto-patch`
48
+ hook path. Codex installs get the verified native `rtk` binary and an explicit
49
+ no-transparent-hook status, matching BYAN's current Codex adapter model.
50
+ Covered by `install/__tests__/rtk-integration.test.js`.
51
+
52
+ ### Added - Codex native skills in yanstaller
53
+ - **Codex installs now get real native skills, not just project prompt stubs.**
54
+ When the yanstaller target includes Codex (Codex-only or Claude+Codex), it
55
+ writes the BYAN MCP entry to `~/.codex/config.toml` and copies BYAN skill
56
+ folders into `~/.codex/skills`, creating `~/.codex` when Codex was explicitly
57
+ selected. The project template also ships `.codex/skills/byan/SKILL.md`, so a
58
+ fresh Codex install has a native skill named `byan` in addition to the
59
+ Claude-derived BYAN specialty skills. This closes the gap where a fresh
60
+ machine had `.codex/prompts/` and MCP wiring but no native `$byan` / BYAN skill
61
+ surface for delegation. Covered by `install/__tests__/codex-native-setup.test.js`.
62
+
12
63
  ### Added - Codex auto-delegation (opt-in, native)
13
64
  - **BYAN now proposes handing delegable work to Codex on your ChatGPT
14
65
  subscription (no API credit) when Claude nears its 5h limit.** A
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const {
7
+ buildHandoff,
8
+ latestHandoff,
9
+ parseMarkdown,
10
+ renderMarkdown,
11
+ renderResumePrompt,
12
+ writeHandoff,
13
+ } = require('../lib/project-handoff');
14
+
15
+ function usage() {
16
+ return [
17
+ 'byan-handoff export [--from claude|codex] [--to codex|claude] [--task <text>] [--summary <text>] [--next <text>] [--out <file>] [--root <dir>] [--stdout]',
18
+ 'byan-handoff import <file> [--prompt] [--json] [--root <dir>]',
19
+ 'byan-handoff latest [--from claude|codex] [--prompt] [--json] [--root <dir>]',
20
+ ].join('\n');
21
+ }
22
+
23
+ function parseArgs(argv) {
24
+ const args = {
25
+ cmd: argv[2] || 'export',
26
+ root: process.cwd(),
27
+ from: null,
28
+ to: null,
29
+ task: null,
30
+ summary: null,
31
+ out: null,
32
+ stdout: false,
33
+ prompt: false,
34
+ json: false,
35
+ next: [],
36
+ decisions: [],
37
+ blockers: [],
38
+ commands: [],
39
+ notes: [],
40
+ file: null,
41
+ };
42
+
43
+ for (let i = 3; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ if (a === '--root') args.root = argv[++i];
46
+ else if (a === '--from') args.from = argv[++i];
47
+ else if (a === '--to') args.to = argv[++i];
48
+ else if (a === '--task') args.task = argv[++i];
49
+ else if (a === '--summary') args.summary = argv[++i];
50
+ else if (a === '--out') args.out = argv[++i];
51
+ else if (a === '--stdout') args.stdout = true;
52
+ else if (a === '--prompt') args.prompt = true;
53
+ else if (a === '--json') args.json = true;
54
+ else if (a === '--next') args.next.push(argv[++i]);
55
+ else if (a === '--decision') args.decisions.push(argv[++i]);
56
+ else if (a === '--blocker') args.blockers.push(argv[++i]);
57
+ else if (a === '--command') args.commands.push(argv[++i]);
58
+ else if (a === '--note') args.notes.push(argv[++i]);
59
+ else if (a === '-h' || a === '--help') args.help = true;
60
+ else if (!args.file) args.file = a;
61
+ else throw new Error(`Unexpected argument: ${a}`);
62
+ }
63
+ return args;
64
+ }
65
+
66
+ function readHandoffFile(file) {
67
+ return parseMarkdown(fs.readFileSync(file, 'utf8'));
68
+ }
69
+
70
+ function printImported(handoff, args) {
71
+ if (args.json) {
72
+ process.stdout.write(JSON.stringify(handoff, null, 2) + '\n');
73
+ } else if (args.prompt) {
74
+ process.stdout.write(renderResumePrompt(handoff) + '\n');
75
+ } else {
76
+ process.stdout.write(renderMarkdown(handoff) + '\n');
77
+ }
78
+ }
79
+
80
+ function main() {
81
+ try {
82
+ const args = parseArgs(process.argv);
83
+ if (args.help) {
84
+ console.log(usage());
85
+ return;
86
+ }
87
+
88
+ const root = path.resolve(args.root);
89
+ if (args.cmd === 'export') {
90
+ const handoff = buildHandoff({
91
+ root,
92
+ from: args.from,
93
+ to: args.to,
94
+ task: args.task,
95
+ summary: args.summary,
96
+ nextActions: args.next,
97
+ decisions: args.decisions,
98
+ blockers: args.blockers,
99
+ commands: args.commands,
100
+ notes: args.notes,
101
+ });
102
+ if (args.stdout) {
103
+ process.stdout.write(renderMarkdown(handoff) + '\n');
104
+ return;
105
+ }
106
+ const result = writeHandoff(root, handoff, { outPath: args.out });
107
+ process.stdout.write(`${result.path}\n`);
108
+ return;
109
+ }
110
+
111
+ if (args.cmd === 'import') {
112
+ if (!args.file) throw new Error('Missing handoff file');
113
+ printImported(readHandoffFile(path.resolve(root, args.file)), args);
114
+ return;
115
+ }
116
+
117
+ if (args.cmd === 'latest') {
118
+ const file = latestHandoff(root, { from: args.from });
119
+ if (!file) {
120
+ const suffix = args.from ? ` from ${args.from}` : '';
121
+ throw new Error(`No handoff${suffix} found under _byan-output/handoffs`);
122
+ }
123
+ if (!args.prompt && !args.json) {
124
+ process.stdout.write(`${file}\n`);
125
+ return;
126
+ }
127
+ printImported(readHandoffFile(file), args);
128
+ return;
129
+ }
130
+
131
+ throw new Error(`Unknown command: ${args.cmd}`);
132
+ } catch (err) {
133
+ process.stderr.write(`byan-handoff: ${err.message}\n`);
134
+ process.stderr.write(usage() + '\n');
135
+ process.exit(1);
136
+ }
137
+ }
138
+
139
+ main();
@@ -1324,14 +1324,14 @@ async function install(options = {}) {
1324
1324
 
1325
1325
  if (needsCodex) {
1326
1326
  console.log();
1327
- console.log(chalk.cyan('Codex CLI MCP setup (~/.codex/config.toml)'));
1327
+ console.log(chalk.cyan('Codex CLI native setup (~/.codex/config.toml + ~/.codex/skills)'));
1328
1328
  try {
1329
- await setupCodexNative(projectRoot);
1329
+ await setupCodexNative(projectRoot, { templateDir, force: true });
1330
1330
  } catch (error) {
1331
- console.log(chalk.red(` ✘ Codex MCP setup failed: ${error.message}`));
1331
+ console.log(chalk.red(` ✘ Codex native setup failed: ${error.message}`));
1332
1332
  console.log(
1333
1333
  chalk.yellow(
1334
- ` → Edit ~/.codex/config.toml manually to add the byan MCP entry.`
1334
+ ` → Edit ~/.codex/config.toml manually to add the byan MCP entry, then copy BYAN skills into ~/.codex/skills.`
1335
1335
  )
1336
1336
  );
1337
1337
  }
@@ -1426,26 +1426,47 @@ async function install(options = {}) {
1426
1426
  }
1427
1427
  }
1428
1428
 
1429
- if (needsClaude && shouldOfferRtk()) {
1429
+ if ((needsClaude || needsCodex) && shouldOfferRtk()) {
1430
1430
  console.log();
1431
1431
  console.log(chalk.cyan('RTK token optimizer (optional — Rust binary, cuts dev-command tokens 60-90%)'));
1432
1432
  try {
1433
+ const rtkTargets = [
1434
+ ...(needsClaude ? ['claude'] : []),
1435
+ ...(needsCodex ? ['codex'] : []),
1436
+ ];
1437
+ const rtkMessage = needsClaude && needsCodex
1438
+ ? 'Install rtk now? Runs its official installer, adds the GLOBAL Claude Code hook via `rtk init -g --auto-patch`, and makes the native rtk binary available for Codex (no transparent Codex hook).'
1439
+ : needsClaude
1440
+ ? 'Install rtk now? Runs its official installer (brew/cargo, or a pinned curl|sh) and adds a GLOBAL Claude Code hook via `rtk init -g --auto-patch`.'
1441
+ : 'Install rtk now? Runs its official installer (brew/cargo, or a pinned curl|sh) and makes the native rtk binary available for Codex workflows. Codex has no transparent RTK hook here.';
1433
1442
  const { proceed } = await inquirer.prompt([
1434
1443
  {
1435
1444
  type: 'confirm',
1436
1445
  name: 'proceed',
1437
- message: 'Install rtk now? Runs its official installer (brew/cargo, or a pinned curl|sh) and adds a GLOBAL Claude Code hook via `rtk init -g --auto-patch`.',
1446
+ message: rtkMessage,
1438
1447
  default: false,
1439
1448
  },
1440
1449
  ]);
1441
1450
  if (proceed) {
1442
1451
  console.log(chalk.gray(' Installing... progress streams below. The cargo fallback compiles from source (can take minutes); Ctrl+C is safe — BYAN is already installed.'));
1443
- const r = setupRtkIntegration({ log: (m) => console.log(chalk.gray(' ' + m)) });
1452
+ const r = setupRtkIntegration({ targetPlatforms: rtkTargets, log: (m) => console.log(chalk.gray(' ' + m)) });
1444
1453
  if (r.synced) {
1445
- console.log(chalk.green(` ✓ rtk ready (${r.installedVia}, v${r.version || '?'}) — restart Claude Code to activate`));
1454
+ if (r.claudeHook && r.codexReady) {
1455
+ console.log(chalk.green(` ✓ rtk ready (${r.installedVia}, v${r.version || '?'}) — restart Claude Code to activate; Codex can use the native rtk binary`));
1456
+ } else if (r.claudeHook) {
1457
+ console.log(chalk.green(` ✓ rtk ready (${r.installedVia}, v${r.version || '?'}) — restart Claude Code to activate`));
1458
+ } else if (r.codexReady) {
1459
+ console.log(chalk.green(` ✓ rtk ready for Codex (${r.installedVia}, v${r.version || '?'}) — native binary installed; no transparent Codex hook`));
1460
+ } else {
1461
+ console.log(chalk.green(` ✓ rtk ready (${r.installedVia}, v${r.version || '?'})`));
1462
+ }
1446
1463
  if (r.pathHint) console.log(chalk.yellow(` ⚠ rtk is not on your PATH — add it: ${r.pathHint}`));
1447
1464
  } else {
1448
- console.log(chalk.yellow(` ⚠ rtk not wired (${r.reason}) — BYAN unaffected; re-run \`npm run setup-rtk\` anytime`));
1465
+ if (r.codexReady) {
1466
+ console.log(chalk.yellow(` ⚠ rtk Claude hook not wired (${r.reason}) — Codex can still use the native rtk binary; re-run \`npm run setup-rtk\` anytime`));
1467
+ } else {
1468
+ console.log(chalk.yellow(` ⚠ rtk not ready (${r.reason}) — BYAN unaffected; re-run \`npm run setup-rtk\` anytime`));
1469
+ }
1449
1470
  if (r.pathHint) console.log(chalk.yellow(` ⚠ rtk found off-PATH — add it then re-run: ${r.pathHint}`));
1450
1471
  }
1451
1472
  } else {
@@ -25,6 +25,10 @@ function getCodexConfigPath() {
25
25
  return path.join(os.homedir(), '.codex', 'config.toml');
26
26
  }
27
27
 
28
+ function getCodexSkillsDir() {
29
+ return path.join(os.homedir(), '.codex', 'skills');
30
+ }
31
+
28
32
  async function detectCodex() {
29
33
  return fs.pathExists(path.join(os.homedir(), '.codex'));
30
34
  }
@@ -123,6 +127,81 @@ async function patchCodexConfig(projectRoot, options = {}) {
123
127
  return { path: configPath, hadExisting: existing.length > 0, tokenSet: apiToken.length > 0 };
124
128
  }
125
129
 
130
+ function uniqueExistingDirs(dirs) {
131
+ const seen = new Set();
132
+ const out = [];
133
+ for (const dir of dirs.filter(Boolean)) {
134
+ const resolved = path.resolve(dir);
135
+ if (seen.has(resolved)) continue;
136
+ seen.add(resolved);
137
+ out.push(resolved);
138
+ }
139
+ return out;
140
+ }
141
+
142
+ async function findSkillDirs(sourceDirs = []) {
143
+ const skills = [];
144
+ const seenNames = new Set();
145
+
146
+ for (const sourceDir of uniqueExistingDirs(sourceDirs)) {
147
+ if (!(await fs.pathExists(sourceDir))) continue;
148
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
149
+ for (const entry of entries) {
150
+ if (!entry.isDirectory()) continue;
151
+ const skillName = entry.name;
152
+ if (seenNames.has(skillName)) continue;
153
+ const skillDir = path.join(sourceDir, skillName);
154
+ const skillFile = path.join(skillDir, 'SKILL.md');
155
+ if (!(await fs.pathExists(skillFile))) continue;
156
+ seenNames.add(skillName);
157
+ skills.push({ name: skillName, path: skillDir, sourceDir });
158
+ }
159
+ }
160
+
161
+ return skills;
162
+ }
163
+
164
+ function defaultSkillSourceDirs(projectRoot, options = {}) {
165
+ const templateDir = options.templateDir;
166
+ return [
167
+ path.join(projectRoot, '.codex', 'skills'),
168
+ path.join(projectRoot, '.claude', 'skills'),
169
+ templateDir ? path.join(templateDir, '.codex', 'skills') : null,
170
+ templateDir ? path.join(templateDir, '.claude', 'skills') : null,
171
+ ];
172
+ }
173
+
174
+ async function installCodexNativeSkills(projectRoot, options = {}) {
175
+ const destDir = options.destDir || getCodexSkillsDir();
176
+ const sourceDirs = options.sourceDirs || defaultSkillSourceDirs(projectRoot, options);
177
+ const overwrite = options.overwrite !== false;
178
+
179
+ await fs.ensureDir(destDir);
180
+
181
+ const skills = await findSkillDirs(sourceDirs);
182
+ const result = {
183
+ destDir,
184
+ installed: 0,
185
+ skipped: 0,
186
+ skills: [],
187
+ };
188
+
189
+ for (const skill of skills) {
190
+ const dest = path.join(destDir, skill.name);
191
+ const exists = await fs.pathExists(dest);
192
+ if (exists && !overwrite) {
193
+ result.skipped++;
194
+ result.skills.push({ name: skill.name, status: 'skipped-existing', path: dest });
195
+ continue;
196
+ }
197
+ await fs.copy(skill.path, dest, { overwrite: true, errorOnExist: false });
198
+ result.installed++;
199
+ result.skills.push({ name: skill.name, status: exists ? 'updated' : 'installed', path: dest });
200
+ }
201
+
202
+ return result;
203
+ }
204
+
126
205
  async function setupCodexNative(projectRoot, options = {}) {
127
206
  const log = options.quiet ? () => {} : (...a) => console.log(...a);
128
207
 
@@ -142,15 +221,28 @@ async function setupCodexNative(projectRoot, options = {}) {
142
221
  );
143
222
  log(chalk.gray(' (or rerun with BYAN_API_TOKEN=byan_xxx in the env)'));
144
223
  }
145
- log(chalk.gray(' Restart Codex CLI for the new MCP server to load'));
146
- return result;
224
+ const skills = await installCodexNativeSkills(projectRoot, options);
225
+ if (skills.installed > 0) {
226
+ log(chalk.green(` ✓ Codex native skills installed to ${skills.destDir} (${skills.installed})`));
227
+ } else {
228
+ log(chalk.yellow(` ! No Codex native skills found to install into ${skills.destDir}`));
229
+ }
230
+ if (skills.skipped > 0) {
231
+ log(chalk.gray(` ${skills.skipped} existing skill(s) skipped`));
232
+ }
233
+ log(chalk.gray(' Restart Codex CLI for the new MCP server and skills to load'));
234
+ return { ...result, skills };
147
235
  }
148
236
 
149
237
  module.exports = {
150
238
  setupCodexNative,
151
239
  patchCodexConfig,
240
+ installCodexNativeSkills,
241
+ findSkillDirs,
242
+ defaultSkillSourceDirs,
152
243
  stripServerSections,
153
244
  buildByanBlock,
154
245
  getCodexConfigPath,
246
+ getCodexSkillsDir,
155
247
  detectCodex,
156
248
  };
@@ -0,0 +1,300 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execSync } = require('child_process');
6
+
7
+ const FORMAT = 'byan-project-handoff';
8
+ const VERSION = '1.0';
9
+
10
+ function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+
14
+ function slugify(s) {
15
+ return String(s || 'handoff')
16
+ .trim()
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, '-')
19
+ .replace(/^-+|-+$/g, '')
20
+ .slice(0, 80) || 'handoff';
21
+ }
22
+
23
+ function timestampForFile(d = new Date()) {
24
+ return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
25
+ }
26
+
27
+ function handoffDir(root) {
28
+ return path.join(root, '_byan-output', 'handoffs');
29
+ }
30
+
31
+ function defaultHandoffPath(root, handoff, date = new Date()) {
32
+ const from = slugify(handoff.from || 'unknown');
33
+ const to = slugify(handoff.to || 'next');
34
+ const task = slugify(handoff.currentTask || handoff.project || 'project');
35
+ return path.join(handoffDir(root), `${timestampForFile(date)}-${from}-to-${to}-${task}.md`);
36
+ }
37
+
38
+ function arrayify(value) {
39
+ if (Array.isArray(value)) return value.filter((v) => String(v || '').trim()).map(String);
40
+ if (value == null || value === '') return [];
41
+ return [String(value)];
42
+ }
43
+
44
+ function readJsonFile(file) {
45
+ try {
46
+ if (!fs.existsSync(file)) return null;
47
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function safeRun(cmd, { cwd, maxBuffer = 1024 * 1024 } = {}) {
54
+ try {
55
+ return String(execSync(cmd, { cwd, stdio: 'pipe', encoding: 'utf8', maxBuffer })).trim();
56
+ } catch {
57
+ return '';
58
+ }
59
+ }
60
+
61
+ function collectGitSnapshot(root, { run = safeRun } = {}) {
62
+ const status = run('git status --short', { cwd: root });
63
+ const branch = run('git branch --show-current', { cwd: root }) || run('git rev-parse --abbrev-ref HEAD', { cwd: root });
64
+ const head = run('git rev-parse --short HEAD', { cwd: root });
65
+ const changedFiles = status
66
+ .split('\n')
67
+ .map((line) => line.trim())
68
+ .filter(Boolean)
69
+ .map((line) => line.replace(/^.. /, '').replace(/^..\s+/, ''));
70
+ return { branch: branch || null, head: head || null, status, changedFiles };
71
+ }
72
+
73
+ function collectFdState(root) {
74
+ const state = readJsonFile(path.join(root, '_byan-output', 'fd-state.json'));
75
+ if (!state) return null;
76
+ return {
77
+ fd_id: state.fd_id || null,
78
+ feature_name: state.feature_name || null,
79
+ phase: state.phase || null,
80
+ strict_mode: Boolean(state.strict_mode),
81
+ backlog: Array.isArray(state.backlog) ? state.backlog : [],
82
+ validate_verdict: state.validate_verdict || null,
83
+ };
84
+ }
85
+
86
+ function buildHandoff(input = {}) {
87
+ const root = path.resolve(input.root || process.cwd());
88
+ const git = input.git || collectGitSnapshot(root, input);
89
+ const fd = input.fd === undefined ? collectFdState(root) : input.fd;
90
+ const project = input.project || path.basename(root);
91
+ const from = input.from || process.env.BYAN_HANDOFF_FROM || 'unknown';
92
+ const to = input.to || process.env.BYAN_HANDOFF_TO || 'next-assistant';
93
+ const currentTask = input.currentTask || input.task || (fd && fd.feature_name) || 'Resume project work';
94
+ const filesTouched = arrayify(input.filesTouched || input.files || git.changedFiles);
95
+ const decisions = arrayify(input.decisions);
96
+ const blockers = arrayify(input.blockers);
97
+ const nextActions = arrayify(input.nextActions || input.next);
98
+ const commands = arrayify(input.commands);
99
+ const notes = arrayify(input.notes || input.note);
100
+
101
+ return {
102
+ format: FORMAT,
103
+ version: VERSION,
104
+ project,
105
+ root,
106
+ from,
107
+ to,
108
+ createdAt: input.createdAt || nowIso(),
109
+ currentTask,
110
+ summary: input.summary || '',
111
+ decisions,
112
+ filesTouched,
113
+ commands,
114
+ blockers,
115
+ nextActions,
116
+ notes,
117
+ git,
118
+ fd,
119
+ };
120
+ }
121
+
122
+ function renderList(items, empty = '- (none recorded)') {
123
+ const arr = arrayify(items);
124
+ if (!arr.length) return empty;
125
+ return arr.map((item) => `- ${item}`).join('\n');
126
+ }
127
+
128
+ function renderJsonBlock(handoff) {
129
+ return [
130
+ '```json byan-handoff',
131
+ JSON.stringify(handoff, null, 2),
132
+ '```',
133
+ ].join('\n');
134
+ }
135
+
136
+ function renderMarkdown(handoff) {
137
+ const h = buildHandoff(handoff);
138
+ const lines = [];
139
+ lines.push(`# BYAN Project Handoff: ${h.from} -> ${h.to}`);
140
+ lines.push('');
141
+ lines.push('> Portable Markdown handoff for switching between Claude Code and Codex. This file is the source of truth for the next assistant; native assistant memory is optional context only.');
142
+ lines.push('');
143
+ lines.push('## Machine Data');
144
+ lines.push('');
145
+ lines.push(renderJsonBlock(h));
146
+ lines.push('');
147
+ lines.push('## Resume Prompt');
148
+ lines.push('');
149
+ lines.push(renderResumePrompt(h));
150
+ lines.push('');
151
+ lines.push('## Human Summary');
152
+ lines.push('');
153
+ lines.push(h.summary || '_A completer avant le passage si le resume automatique est insuffisant._');
154
+ lines.push('');
155
+ lines.push('## Current Task');
156
+ lines.push('');
157
+ lines.push(h.currentTask);
158
+ lines.push('');
159
+ lines.push('## Decisions');
160
+ lines.push('');
161
+ lines.push(renderList(h.decisions));
162
+ lines.push('');
163
+ lines.push('## Files Touched');
164
+ lines.push('');
165
+ lines.push(renderList(h.filesTouched));
166
+ lines.push('');
167
+ lines.push('## Commands And Tests');
168
+ lines.push('');
169
+ lines.push(renderList(h.commands));
170
+ lines.push('');
171
+ lines.push('## Blockers And Risks');
172
+ lines.push('');
173
+ lines.push(renderList(h.blockers));
174
+ lines.push('');
175
+ lines.push('## Next Actions');
176
+ lines.push('');
177
+ lines.push(renderList(h.nextActions, '- Re-read this handoff, inspect the listed files, then continue the current task.'));
178
+ lines.push('');
179
+ lines.push('## Notes');
180
+ lines.push('');
181
+ lines.push(renderList(h.notes));
182
+ lines.push('');
183
+ lines.push('## Git Snapshot');
184
+ lines.push('');
185
+ lines.push(`- Branch: ${h.git && h.git.branch ? h.git.branch : 'unknown'}`);
186
+ lines.push(`- Head: ${h.git && h.git.head ? h.git.head : 'unknown'}`);
187
+ lines.push('');
188
+ lines.push('```text');
189
+ lines.push((h.git && h.git.status) || '(clean or unavailable)');
190
+ lines.push('```');
191
+ lines.push('');
192
+ if (h.fd) {
193
+ lines.push('## FD Snapshot');
194
+ lines.push('');
195
+ lines.push(`- FD: ${h.fd.fd_id || 'unknown'}`);
196
+ lines.push(`- Feature: ${h.fd.feature_name || 'unknown'}`);
197
+ lines.push(`- Phase: ${h.fd.phase || 'unknown'}`);
198
+ lines.push(`- Strict: ${h.fd.strict_mode ? 'yes' : 'no'}`);
199
+ lines.push('');
200
+ lines.push('### Backlog');
201
+ lines.push('');
202
+ lines.push(renderList((h.fd.backlog || []).map((b) => `${b.id || '-'} [${b.status || '?'}] ${b.title || ''}`)));
203
+ lines.push('');
204
+ }
205
+ return lines.join('\n');
206
+ }
207
+
208
+ function extractJsonBlock(markdown) {
209
+ const text = String(markdown || '');
210
+ const re = /```json\s+byan-handoff\s*\n([\s\S]*?)\n```/m;
211
+ const m = text.match(re);
212
+ if (!m) throw new Error('Missing ```json byan-handoff block');
213
+ try {
214
+ return JSON.parse(m[1]);
215
+ } catch (err) {
216
+ throw new Error(`Invalid byan-handoff JSON: ${err.message}`);
217
+ }
218
+ }
219
+
220
+ function parseMarkdown(markdown) {
221
+ const h = extractJsonBlock(markdown);
222
+ if (h.format !== FORMAT) throw new Error(`Invalid handoff format: ${h.format || 'missing'}`);
223
+ if (!h.version) throw new Error('Missing handoff version');
224
+ return h;
225
+ }
226
+
227
+ function normalizeProvider(value) {
228
+ return slugify(value || '');
229
+ }
230
+
231
+ function providerMatches(actual, expected) {
232
+ const a = normalizeProvider(actual);
233
+ const e = normalizeProvider(expected);
234
+ if (!e) return true;
235
+ return a === e || a.startsWith(`${e}-`);
236
+ }
237
+
238
+ function renderResumePrompt(handoff) {
239
+ const h = buildHandoff(handoff);
240
+ const lines = [];
241
+ lines.push(`Continue this BYAN project from a portable handoff created by ${h.from} for ${h.to}.`);
242
+ lines.push(`Project: ${h.project}`);
243
+ lines.push(`Task: ${h.currentTask}`);
244
+ if (h.summary) lines.push(`Summary: ${h.summary}`);
245
+ if (h.decisions.length) lines.push(`Decisions: ${h.decisions.join(' | ')}`);
246
+ if (h.filesTouched.length) lines.push(`Files to inspect first: ${h.filesTouched.join(', ')}`);
247
+ if (h.commands.length) lines.push(`Known commands/tests: ${h.commands.join(' | ')}`);
248
+ if (h.blockers.length) lines.push(`Blockers/risks: ${h.blockers.join(' | ')}`);
249
+ if (h.nextActions.length) lines.push(`Next actions: ${h.nextActions.join(' | ')}`);
250
+ if (h.fd && h.fd.phase) lines.push(`FD state: ${h.fd.feature_name || h.fd.fd_id || 'unknown'} is in phase ${h.fd.phase}.`);
251
+ lines.push('Use repo files and BYAN portable state as source of truth; do not rely on native assistant memory.');
252
+ return lines.join('\n');
253
+ }
254
+
255
+ function writeHandoff(root, handoff, { outPath, mkdirp = fs.mkdirSync, writeFile = fs.writeFileSync } = {}) {
256
+ const h = buildHandoff({ ...handoff, root });
257
+ const target = path.resolve(outPath || defaultHandoffPath(path.resolve(root), h));
258
+ mkdirp(path.dirname(target), { recursive: true });
259
+ writeFile(target, renderMarkdown(h), 'utf8');
260
+ return { path: target, handoff: h };
261
+ }
262
+
263
+ function latestHandoff(root, { from = null, exists = fs.existsSync, readdir = fs.readdirSync, readFile = fs.readFileSync } = {}) {
264
+ const dir = handoffDir(path.resolve(root));
265
+ if (!exists(dir)) return null;
266
+ const files = readdir(dir)
267
+ .filter((f) => /\.md$/i.test(f))
268
+ .sort()
269
+ .reverse();
270
+ if (!files.length) return null;
271
+ if (!from) return path.join(dir, files[0]);
272
+
273
+ for (const file of files) {
274
+ const full = path.join(dir, file);
275
+ try {
276
+ const handoff = parseMarkdown(readFile(full, 'utf8'));
277
+ if (providerMatches(handoff.from, from)) return full;
278
+ } catch {
279
+ // Ignore non-handoff markdown files in the handoff directory.
280
+ }
281
+ }
282
+ return null;
283
+ }
284
+
285
+ module.exports = {
286
+ FORMAT,
287
+ VERSION,
288
+ buildHandoff,
289
+ collectFdState,
290
+ collectGitSnapshot,
291
+ defaultHandoffPath,
292
+ extractJsonBlock,
293
+ handoffDir,
294
+ latestHandoff,
295
+ parseMarkdown,
296
+ providerMatches,
297
+ renderMarkdown,
298
+ renderResumePrompt,
299
+ writeHandoff,
300
+ };