wogiflow 1.1.1 → 1.1.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.
@@ -400,13 +400,112 @@ class BaseBridge {
400
400
 
401
401
  // ==================== Template Utility Methods ====================
402
402
 
403
+ /**
404
+ * Get the package root directory (where wogiflow is installed)
405
+ * @returns {string|null} Package root path or null if not found
406
+ */
407
+ getPackageRoot() {
408
+ // Try to find the package by looking up from scripts directory
409
+ const possibleRoots = [
410
+ path.resolve(__dirname, '..', '..'), // From .workflow/bridges/ -> package root
411
+ path.resolve(__dirname, '..', '..', '..', 'node_modules', 'wogiflow'), // From project
412
+ ];
413
+
414
+ for (const root of possibleRoots) {
415
+ const pkgPath = path.join(root, 'package.json');
416
+ if (fs.existsSync(pkgPath)) {
417
+ try {
418
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
419
+ if (pkg.name === 'wogiflow') {
420
+ return root;
421
+ }
422
+ } catch {
423
+ // Continue to next option
424
+ }
425
+ }
426
+ }
427
+ return null;
428
+ }
429
+
430
+ /**
431
+ * Check if a template file is outdated/stub
432
+ * @param {string} templatePath - Path to template file
433
+ * @returns {boolean} True if template appears to be outdated
434
+ */
435
+ isTemplateOutdated(templatePath) {
436
+ if (!fs.existsSync(templatePath)) {
437
+ return true;
438
+ }
439
+
440
+ try {
441
+ const content = fs.readFileSync(templatePath, 'utf-8');
442
+
443
+ // Check for stub/placeholder markers
444
+ const stubMarkers = [
445
+ 'Full implementation pending',
446
+ 'stub template',
447
+ 'not yet implemented',
448
+ 'TODO: implement',
449
+ 'placeholder template'
450
+ ];
451
+
452
+ for (const marker of stubMarkers) {
453
+ if (content.toLowerCase().includes(marker.toLowerCase())) {
454
+ return true;
455
+ }
456
+ }
457
+
458
+ // Check if template is too short (likely incomplete)
459
+ if (content.length < 500) {
460
+ return true;
461
+ }
462
+
463
+ return false;
464
+ } catch {
465
+ return true;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Get template path, preferring package template if project template is outdated
471
+ * @param {string} templateName - Template filename (e.g., 'gemini-md.hbs')
472
+ * @returns {string|null} Path to best available template
473
+ */
474
+ getBestTemplatePath(templateName) {
475
+ const projectTemplate = path.join(this.projectDir, this.workflowDir, 'templates', templateName);
476
+
477
+ // If project template exists and is not outdated, use it
478
+ if (!this.isTemplateOutdated(projectTemplate)) {
479
+ return projectTemplate;
480
+ }
481
+
482
+ // Try to use package template instead
483
+ const packageRoot = this.getPackageRoot();
484
+ if (packageRoot) {
485
+ const packageTemplate = path.join(packageRoot, '.workflow', 'templates', templateName);
486
+ if (fs.existsSync(packageTemplate)) {
487
+ this.log(`Using package template for ${templateName} (project template outdated)`);
488
+ return packageTemplate;
489
+ }
490
+ }
491
+
492
+ // Fall back to project template even if outdated
493
+ if (fs.existsSync(projectTemplate)) {
494
+ return projectTemplate;
495
+ }
496
+
497
+ return null;
498
+ }
499
+
403
500
  /**
404
501
  * Load a partial template from .workflow/templates/partials/
502
+ * Checks both project and package directories
405
503
  * @param {string} partialName - Name of the partial (without .hbs extension)
406
504
  * @returns {string} Partial content or empty string if not found
407
505
  */
408
506
  loadPartial(partialName) {
409
- const partialPath = path.join(
507
+ // Try project partials first
508
+ const projectPartialPath = path.join(
410
509
  this.projectDir,
411
510
  this.workflowDir,
412
511
  'templates',
@@ -415,12 +514,33 @@ class BaseBridge {
415
514
  );
416
515
 
417
516
  try {
418
- if (fs.existsSync(partialPath)) {
419
- return fs.readFileSync(partialPath, 'utf-8');
517
+ if (fs.existsSync(projectPartialPath)) {
518
+ return fs.readFileSync(projectPartialPath, 'utf-8');
420
519
  }
421
520
  } catch (err) {
422
- this.log(`Warning: Could not load partial ${partialName}: ${err.message}`);
521
+ this.log(`Warning: Could not load project partial ${partialName}: ${err.message}`);
522
+ }
523
+
524
+ // Try package partials as fallback
525
+ const packageRoot = this.getPackageRoot();
526
+ if (packageRoot) {
527
+ const packagePartialPath = path.join(
528
+ packageRoot,
529
+ '.workflow',
530
+ 'templates',
531
+ 'partials',
532
+ `${partialName}.hbs`
533
+ );
534
+
535
+ try {
536
+ if (fs.existsSync(packagePartialPath)) {
537
+ return fs.readFileSync(packagePartialPath, 'utf-8');
538
+ }
539
+ } catch (err) {
540
+ this.log(`Warning: Could not load package partial ${partialName}: ${err.message}`);
541
+ }
423
542
  }
543
+
424
544
  return '';
425
545
  }
426
546
 
@@ -45,11 +45,9 @@ class ClaudeBridge extends BaseBridge {
45
45
  * @returns {string} Generated CLAUDE.md content
46
46
  */
47
47
  generateRulesContent(config) {
48
- const projectName = config.projectName || 'Project';
49
-
50
- // Check if custom template exists
51
- const templatePath = path.join(this.projectDir, this.workflowDir, 'templates', 'claude-md.hbs');
52
- if (fs.existsSync(templatePath)) {
48
+ // Use getBestTemplatePath to find the best template (prefers package over outdated project)
49
+ const templatePath = this.getBestTemplatePath('claude-md.hbs');
50
+ if (templatePath) {
53
51
  return this.generateFromTemplate(templatePath, config);
54
52
  }
55
53
 
@@ -81,8 +81,8 @@ class CodexBridge extends BaseBridge {
81
81
  this.registerPartials();
82
82
 
83
83
  try {
84
- const templatePath = path.join(this.projectDir, this.workflowDir, 'templates', 'agents-md.hbs');
85
- if (fs.existsSync(templatePath)) {
84
+ const templatePath = this.getBestTemplatePath('agents-md.hbs');
85
+ if (templatePath) {
86
86
  const templateSource = fs.readFileSync(templatePath, 'utf-8');
87
87
  const template = Handlebars.compile(templateSource);
88
88
  return template(context);
@@ -88,8 +88,8 @@ class CursorBridge extends BaseBridge {
88
88
  // Register partials before compiling
89
89
  this.registerPartials();
90
90
 
91
- const templatePath = path.join(this.projectDir, this.workflowDir, 'templates', 'cursor-rules.mdc.hbs');
92
- if (fs.existsSync(templatePath)) {
91
+ const templatePath = this.getBestTemplatePath('cursor-rules.mdc.hbs');
92
+ if (templatePath) {
93
93
  try {
94
94
  const templateSource = fs.readFileSync(templatePath, 'utf-8');
95
95
  const template = Handlebars.compile(templateSource);
@@ -58,9 +58,9 @@ class GeminiBridge extends BaseBridge {
58
58
  * @returns {string} Generated GEMINI.md content
59
59
  */
60
60
  generateRulesContent(config) {
61
- // Check if custom template exists
62
- const templatePath = path.join(this.projectDir, this.workflowDir, 'templates', 'gemini-md.hbs');
63
- if (fs.existsSync(templatePath)) {
61
+ // Use getBestTemplatePath to find the best template (prefers package over outdated project)
62
+ const templatePath = this.getBestTemplatePath('gemini-md.hbs');
63
+ if (templatePath) {
64
64
  return this.generateFromTemplate(templatePath, config);
65
65
  }
66
66
 
@@ -92,23 +92,53 @@ function getCliType(projectDir = process.cwd()) {
92
92
  }
93
93
  }
94
94
 
95
+ /**
96
+ * Detect which CLI is currently running based on environment
97
+ * @param {string} projectDir - Project root directory
98
+ * @returns {string} Detected CLI type
99
+ */
100
+ function detectRunningCli(projectDir = process.cwd()) {
101
+ // Priority 1: Environment variables set by CLI tools
102
+ if (process.env.CLAUDE_CODE_ENTRY_POINT) return 'claude-code';
103
+ if (process.env.CURSOR_SESSION_ID) return 'cursor';
104
+ if (process.env.OPENCODE_SESSION) return 'opencode';
105
+
106
+ // Priority 2: Check caller stack for hook path hints
107
+ try {
108
+ const stack = new Error().stack || '';
109
+ if (stack.includes('/claude-code/')) return 'claude-code';
110
+ if (stack.includes('/gemini-cli/')) return 'gemini-cli';
111
+ if (stack.includes('/cursor/')) return 'cursor';
112
+ if (stack.includes('/opencode/')) return 'opencode';
113
+ } catch {
114
+ // Ignore stack parsing errors
115
+ }
116
+
117
+ // Priority 3: Config file setting
118
+ return getCliType(projectDir);
119
+ }
120
+
95
121
  /**
96
122
  * Get the bridge instance for the current CLI type
97
123
  * @param {Object} options - Options to pass to bridge constructor
98
124
  * @param {string} options.projectDir - Project root directory
125
+ * @param {string} options.cliType - Override CLI type (optional)
99
126
  * @param {boolean} options.verbose - Enable verbose logging
100
127
  * @returns {BaseBridge} Bridge instance
101
128
  */
102
129
  function getBridge(options = {}) {
103
130
  const projectDir = options.projectDir || process.cwd();
104
- const cliType = getCliType(projectDir);
131
+ // Allow explicit CLI type override, otherwise detect from config
132
+ const cliType = options.cliType || getCliType(projectDir);
105
133
 
106
134
  loadBridges();
107
135
 
108
136
  const BridgeLoader = bridges[cliType];
109
137
  if (!BridgeLoader) {
110
138
  // If no specific bridge exists, return null (manual mode)
111
- console.warn(`No bridge available for CLI type: ${cliType}`);
139
+ if (options.verbose) {
140
+ console.warn(`No bridge available for CLI type: ${cliType}`);
141
+ }
112
142
  return null;
113
143
  }
114
144
 
@@ -122,16 +152,18 @@ function getBridge(options = {}) {
122
152
  /**
123
153
  * Sync the current CLI bridge
124
154
  * @param {Object} options - Options
155
+ * @param {string} options.cliType - Override CLI type (optional)
125
156
  * @returns {Object} Sync result
126
157
  */
127
158
  async function syncBridge(options = {}) {
128
159
  const bridge = getBridge(options);
160
+ const cliType = options.cliType || getCliType(options.projectDir);
129
161
 
130
162
  if (!bridge) {
131
163
  return {
132
164
  success: false,
133
165
  error: 'No bridge available for current CLI type',
134
- cliType: getCliType(options.projectDir)
166
+ cliType
135
167
  };
136
168
  }
137
169
 
@@ -161,6 +193,7 @@ module.exports = {
161
193
  getBridge,
162
194
  syncBridge,
163
195
  getCliType,
196
+ detectRunningCli,
164
197
  listAvailableBridges,
165
198
  isBridgeAvailable,
166
199
  BaseBridge: require('./base-bridge')
@@ -88,11 +88,11 @@ class KimiBridge extends BaseBridge {
88
88
 
89
89
  try {
90
90
  // Try kimi-specific template first, fall back to generic agents-md.hbs
91
- let templatePath = path.join(this.projectDir, this.workflowDir, 'templates', 'kimi-agents-md.hbs');
92
- if (!fs.existsSync(templatePath)) {
93
- templatePath = path.join(this.projectDir, this.workflowDir, 'templates', 'agents-md.hbs');
91
+ let templatePath = this.getBestTemplatePath('kimi-agents-md.hbs');
92
+ if (!templatePath) {
93
+ templatePath = this.getBestTemplatePath('agents-md.hbs');
94
94
  }
95
- if (fs.existsSync(templatePath)) {
95
+ if (templatePath) {
96
96
  let templateSource;
97
97
  try {
98
98
  templateSource = fs.readFileSync(templatePath, 'utf-8');
@@ -84,8 +84,8 @@ class OpenCodeBridge extends BaseBridge {
84
84
  this.registerPartials();
85
85
 
86
86
  try {
87
- const templatePath = path.join(this.projectDir, this.workflowDir, 'templates', 'opencode-agents-md.hbs');
88
- if (fs.existsSync(templatePath)) {
87
+ const templatePath = this.getBestTemplatePath('opencode-agents-md.hbs');
88
+ if (templatePath) {
89
89
  const templateSource = fs.readFileSync(templatePath, 'utf-8');
90
90
  const template = Handlebars.compile(templateSource);
91
91
  return template(context);
package/lib/installer.js CHANGED
@@ -322,6 +322,31 @@ function createWorkflowStructure(projectRoot, config) {
322
322
  console.log(' Created .workflow/ directory structure');
323
323
  }
324
324
 
325
+ /**
326
+ * CLI-specific resource mappings
327
+ * Maps CLI keys to their package source directories and output file names
328
+ */
329
+ const CLI_RESOURCES = {
330
+ claude: {
331
+ packageDir: '.claude',
332
+ rulesFile: 'CLAUDE.md',
333
+ templateName: 'claude-md.hbs',
334
+ subdirs: ['commands', 'docs', 'rules', 'skills']
335
+ },
336
+ gemini: {
337
+ packageDir: '.claude', // Share Claude's resources as base, bridge customizes output
338
+ rulesFile: 'GEMINI.md',
339
+ templateName: 'gemini-md.hbs',
340
+ subdirs: ['commands', 'docs', 'rules', 'skills']
341
+ },
342
+ opencode: {
343
+ packageDir: '.claude',
344
+ rulesFile: '.opencode/agents.md',
345
+ templateName: 'opencode-agents-md.hbs',
346
+ subdirs: ['docs', 'skills']
347
+ }
348
+ };
349
+
325
350
  /**
326
351
  * Create CLI-specific configuration
327
352
  * @param {string} projectRoot - Project root directory
@@ -338,45 +363,48 @@ function createCLIConfig(projectRoot, cliKey, config) {
338
363
  const cliDir = path.join(projectRoot, cli.dir);
339
364
  fs.mkdirSync(cliDir, { recursive: true });
340
365
 
341
- // Create CLI-specific config file and copy resources
342
- if (cliKey === 'claude') {
343
- // Copy commands from package
344
- const packageCommands = path.join(PACKAGE_ROOT, '.claude', 'commands');
345
- const projectCommands = path.join(cliDir, 'commands');
346
- if (fs.existsSync(packageCommands)) {
347
- copyDir(packageCommands, projectCommands);
348
- console.log(' Copied .claude/commands/ (slash commands)');
349
- }
366
+ // Get CLI-specific resource configuration
367
+ const resources = CLI_RESOURCES[cliKey];
368
+ if (!resources) {
369
+ console.log(` ${cli.name} will be configured via bridge sync`);
370
+ console.log(` Configured ${cli.name}`);
371
+ return;
372
+ }
350
373
 
351
- // Copy docs from package
352
- const packageDocs = path.join(PACKAGE_ROOT, '.claude', 'docs');
353
- const projectDocs = path.join(cliDir, 'docs');
354
- if (fs.existsSync(packageDocs)) {
355
- copyDir(packageDocs, projectDocs);
356
- console.log(' Copied .claude/docs/ (documentation)');
374
+ // Copy common subdirectories (commands, docs, rules, skills)
375
+ const packageCliDir = path.join(PACKAGE_ROOT, resources.packageDir);
376
+ for (const subdir of resources.subdirs) {
377
+ const packageSubdir = path.join(packageCliDir, subdir);
378
+ const projectSubdir = path.join(cliDir, subdir);
379
+ if (fs.existsSync(packageSubdir)) {
380
+ copyDir(packageSubdir, projectSubdir);
381
+ console.log(` Copied ${cli.dir}/${subdir}/`);
357
382
  }
383
+ }
358
384
 
359
- // Copy rules from package
360
- const packageRules = path.join(PACKAGE_ROOT, '.claude', 'rules');
361
- const projectRules = path.join(cliDir, 'rules');
362
- if (fs.existsSync(packageRules)) {
363
- copyDir(packageRules, projectRules);
364
- console.log(' Copied .claude/rules/ (coding rules)');
385
+ // Generate the rules/instructions file (CLAUDE.md, GEMINI.md, etc.)
386
+ // First try to use the bridge for proper template rendering
387
+ try {
388
+ const bridgesPath = path.join(projectRoot, '.workflow', 'bridges');
389
+ if (fs.existsSync(bridgesPath)) {
390
+ const bridges = require(bridgesPath);
391
+ const bridge = bridges.getBridge({ projectDir: projectRoot, cliType: cliKey === 'claude' ? 'claude-code' : cliKey === 'gemini' ? 'gemini-cli' : cliKey, verbose: false });
392
+ if (bridge) {
393
+ bridge.generateRulesFile();
394
+ console.log(` Created ${resources.rulesFile} (via bridge)`);
395
+ console.log(` Configured ${cli.name}`);
396
+ return;
397
+ }
365
398
  }
366
-
367
- // Copy skills from package (base skills only)
368
- const packageSkills = path.join(PACKAGE_ROOT, '.claude', 'skills');
369
- const projectSkills = path.join(cliDir, 'skills');
370
- if (fs.existsSync(packageSkills)) {
371
- copyDir(packageSkills, projectSkills);
372
- console.log(' Copied .claude/skills/ (base skills)');
399
+ } catch (err) {
400
+ // Bridge not available yet - fall through to simple generation
401
+ if (process.env.DEBUG) {
402
+ console.log(` Bridge not available: ${err.message}`);
373
403
  }
404
+ }
374
405
 
375
- // Generate CLAUDE.md from template
376
- const claudeMdTemplate = path.join(PACKAGE_ROOT, '.workflow', 'templates', 'claude-md.hbs');
377
- if (fs.existsSync(claudeMdTemplate)) {
378
- // For now, create a simple CLAUDE.md - the bridge will regenerate with full template
379
- const claudeMd = `# Project Instructions
406
+ // Fallback: Create a simple rules file - the bridge will regenerate with full template
407
+ const simpleContent = `# Project Instructions
380
408
 
381
409
  You are an AI development assistant using the WogiFlow methodology v1.0.
382
410
 
@@ -394,13 +422,25 @@ cat .workflow/state/ready.json # Check tasks
394
422
  - \`/wogi-status\` - Project overview
395
423
  - \`/wogi-health\` - Check workflow health
396
424
 
425
+ Run \`flow bridge sync\` to regenerate this file with full template.
426
+
397
427
  Generated by Wogi Flow v${config.version}
398
428
  `;
399
- fs.writeFileSync(path.join(projectRoot, 'CLAUDE.md'), claudeMd);
400
- console.log(' Created CLAUDE.md');
401
- }
429
+
430
+ // Determine output path (handle nested paths like .opencode/agents.md)
431
+ const rulesFilePath = resources.rulesFile.includes('/')
432
+ ? path.join(projectRoot, resources.rulesFile)
433
+ : path.join(projectRoot, resources.rulesFile);
434
+
435
+ // Ensure parent directory exists for nested paths
436
+ const rulesFileDir = path.dirname(rulesFilePath);
437
+ if (!fs.existsSync(rulesFileDir)) {
438
+ fs.mkdirSync(rulesFileDir, { recursive: true });
402
439
  }
403
440
 
441
+ fs.writeFileSync(rulesFilePath, simpleContent);
442
+ console.log(` Created ${resources.rulesFile}`);
443
+
404
444
  console.log(` Configured ${cli.name}`);
405
445
  }
406
446
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Bridge State Tracker
5
+ *
6
+ * Tracks CLI bridge sync state and provides auto-sync functionality.
7
+ * Enables seamless generation of CLI instruction files on session start.
8
+ *
9
+ * Usage:
10
+ * const { autoSyncBridge, needsSync } = require('./flow-bridge-state');
11
+ *
12
+ * // Auto-sync on session start (non-blocking)
13
+ * await autoSyncBridge('claude-code', { silent: true });
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+
20
+ // Project paths
21
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
22
+ const WORKFLOW_DIR = path.join(PROJECT_ROOT, '.workflow');
23
+ const STATE_DIR = path.join(WORKFLOW_DIR, 'state');
24
+ const CONFIG_PATH = path.join(WORKFLOW_DIR, 'config.json');
25
+ const SYNC_STATE_PATH = path.join(STATE_DIR, 'bridge-sync.json');
26
+
27
+ // CLI type to output file mapping
28
+ const CLI_OUTPUT_FILES = {
29
+ 'claude-code': 'CLAUDE.md',
30
+ 'gemini-cli': 'GEMINI.md',
31
+ 'cursor': '.cursor/rules/wogi-flow.mdc',
32
+ 'opencode': '.opencode/agents.md',
33
+ 'codex': 'AGENTS.md',
34
+ 'kimi': 'KIMI.md'
35
+ };
36
+
37
+ /**
38
+ * Safe JSON parse with prototype pollution protection
39
+ * @param {string} filePath - Path to JSON file
40
+ * @param {*} defaultValue - Default value if parsing fails
41
+ * @returns {*} Parsed object or default value
42
+ */
43
+ function safeJsonParse(filePath, defaultValue = {}) {
44
+ try {
45
+ const content = fs.readFileSync(filePath, 'utf-8');
46
+ const parsed = JSON.parse(content);
47
+
48
+ // Check for prototype pollution keys
49
+ const checkDangerous = (obj, depth = 0) => {
50
+ if (depth > 10 || !obj || typeof obj !== 'object') return false;
51
+ const dangerous = ['__proto__', 'constructor', 'prototype'];
52
+ for (const key of Object.keys(obj)) {
53
+ if (dangerous.includes(key)) return true;
54
+ if (obj[key] && typeof obj[key] === 'object') {
55
+ if (checkDangerous(obj[key], depth + 1)) return true;
56
+ }
57
+ }
58
+ return false;
59
+ };
60
+
61
+ if (checkDangerous(parsed)) {
62
+ return defaultValue;
63
+ }
64
+ return parsed;
65
+ } catch {
66
+ return defaultValue;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Calculate MD5 hash of config.json for staleness detection
72
+ * @returns {string} Hash of config content
73
+ */
74
+ function getConfigChecksum() {
75
+ try {
76
+ const content = fs.readFileSync(CONFIG_PATH, 'utf-8');
77
+ return crypto.createHash('md5').update(content).digest('hex');
78
+ } catch {
79
+ return '';
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Get the output file path for a CLI type
85
+ * @param {string} cliType - CLI type
86
+ * @returns {string} Full path to output file
87
+ */
88
+ function getOutputFilePath(cliType) {
89
+ const filename = CLI_OUTPUT_FILES[cliType];
90
+ if (!filename) return null;
91
+ return path.join(PROJECT_ROOT, filename);
92
+ }
93
+
94
+ /**
95
+ * Read current sync state
96
+ * @returns {Object} Sync state
97
+ */
98
+ function readSyncState() {
99
+ return safeJsonParse(SYNC_STATE_PATH, { syncs: {}, version: 1 });
100
+ }
101
+
102
+ /**
103
+ * Write sync state
104
+ * @param {Object} state - State to write
105
+ */
106
+ function writeSyncState(state) {
107
+ try {
108
+ // Ensure state directory exists
109
+ if (!fs.existsSync(STATE_DIR)) {
110
+ fs.mkdirSync(STATE_DIR, { recursive: true });
111
+ }
112
+ fs.writeFileSync(SYNC_STATE_PATH, JSON.stringify(state, null, 2));
113
+ } catch (err) {
114
+ if (process.env.DEBUG) {
115
+ console.error(`[bridge-state] Failed to write sync state: ${err.message}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Get last sync time for a CLI type
122
+ * @param {string} cliType - CLI type
123
+ * @returns {string|null} ISO timestamp or null
124
+ */
125
+ function getLastSyncTime(cliType) {
126
+ const state = readSyncState();
127
+ return state.syncs?.[cliType]?.lastSync || null;
128
+ }
129
+
130
+ /**
131
+ * Update sync time for a CLI type
132
+ * @param {string} cliType - CLI type
133
+ * @param {string} configHash - Current config hash
134
+ */
135
+ function setLastSyncTime(cliType, configHash) {
136
+ const state = readSyncState();
137
+ if (!state.syncs) state.syncs = {};
138
+ state.syncs[cliType] = {
139
+ lastSync: new Date().toISOString(),
140
+ configHash
141
+ };
142
+ writeSyncState(state);
143
+ }
144
+
145
+ /**
146
+ * Check if a CLI bridge needs to be synced
147
+ * @param {string} cliType - CLI type to check
148
+ * @returns {Object} { needsSync: boolean, reason: string }
149
+ */
150
+ function needsSync(cliType) {
151
+ // Check if output file exists
152
+ const outputPath = getOutputFilePath(cliType);
153
+ if (!outputPath) {
154
+ return { needsSync: false, reason: 'unknown-cli' };
155
+ }
156
+
157
+ if (!fs.existsSync(outputPath)) {
158
+ return { needsSync: true, reason: 'file-missing' };
159
+ }
160
+
161
+ // Check if config has changed since last sync
162
+ const state = readSyncState();
163
+ const cliState = state.syncs?.[cliType];
164
+
165
+ if (!cliState) {
166
+ return { needsSync: true, reason: 'never-synced' };
167
+ }
168
+
169
+ const currentHash = getConfigChecksum();
170
+ if (cliState.configHash !== currentHash) {
171
+ return { needsSync: true, reason: 'config-changed' };
172
+ }
173
+
174
+ return { needsSync: false, reason: 'up-to-date' };
175
+ }
176
+
177
+ /**
178
+ * Auto-sync a CLI bridge if needed
179
+ * @param {string} cliType - CLI type to sync
180
+ * @param {Object} options - Options
181
+ * @param {boolean} options.silent - Suppress output
182
+ * @param {boolean} options.force - Force sync even if up-to-date
183
+ * @returns {Object} { synced: boolean, reason: string }
184
+ */
185
+ async function autoSyncBridge(cliType, options = {}) {
186
+ const { silent = false, force = false } = options;
187
+
188
+ // Check if sync is needed
189
+ if (!force) {
190
+ const check = needsSync(cliType);
191
+ if (!check.needsSync) {
192
+ if (!silent && process.env.DEBUG) {
193
+ console.error(`[bridge-state] ${cliType}: ${check.reason}, skipping sync`);
194
+ }
195
+ return { synced: false, reason: check.reason };
196
+ }
197
+ }
198
+
199
+ // Load bridges module
200
+ let bridges;
201
+ try {
202
+ bridges = require(path.join(PROJECT_ROOT, '.workflow', 'bridges'));
203
+ } catch (err) {
204
+ if (process.env.DEBUG) {
205
+ console.error(`[bridge-state] Failed to load bridges: ${err.message}`);
206
+ }
207
+ return { synced: false, reason: 'bridges-unavailable', error: err.message };
208
+ }
209
+
210
+ // Get bridge for the specified CLI type
211
+ let bridge;
212
+ try {
213
+ // Pass explicit cliType to override config default
214
+ bridge = bridges.getBridge({
215
+ projectDir: PROJECT_ROOT,
216
+ cliType: cliType,
217
+ verbose: !silent
218
+ });
219
+
220
+ // Fallback: Try loading the specific bridge directly
221
+ if (!bridge) {
222
+ const BridgeClass = require(path.join(PROJECT_ROOT, '.workflow', 'bridges', `${cliType}-bridge`));
223
+ bridge = new BridgeClass({
224
+ projectDir: PROJECT_ROOT,
225
+ verbose: !silent
226
+ });
227
+ }
228
+ } catch (err) {
229
+ if (process.env.DEBUG) {
230
+ console.error(`[bridge-state] Failed to get bridge for ${cliType}: ${err.message}`);
231
+ }
232
+ return { synced: false, reason: 'bridge-load-failed', error: err.message };
233
+ }
234
+
235
+ // Run sync
236
+ try {
237
+ await bridge.sync();
238
+
239
+ // Update state
240
+ const configHash = getConfigChecksum();
241
+ setLastSyncTime(cliType, configHash);
242
+
243
+ if (!silent) {
244
+ console.error(`[bridge-state] Synced ${cliType} bridge`);
245
+ }
246
+
247
+ return { synced: true, reason: 'success' };
248
+ } catch (err) {
249
+ if (process.env.DEBUG) {
250
+ console.error(`[bridge-state] Sync failed for ${cliType}: ${err.message}`);
251
+ }
252
+ return { synced: false, reason: 'sync-failed', error: err.message };
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Sync all enabled CLIs
258
+ * @param {Object} options - Options
259
+ * @returns {Object} Results for each CLI
260
+ */
261
+ async function syncAllEnabledClis(options = {}) {
262
+ const config = safeJsonParse(CONFIG_PATH, {});
263
+ const primaryCli = config.cli?.type || 'claude-code';
264
+ const enabledClis = config.cli?.enabled || [primaryCli];
265
+
266
+ const results = {};
267
+ for (const cliType of enabledClis) {
268
+ results[cliType] = await autoSyncBridge(cliType, options);
269
+ }
270
+
271
+ return results;
272
+ }
273
+
274
+ /**
275
+ * Detect which CLI is currently running
276
+ * Based on environment variables and caller context
277
+ * @returns {string} CLI type
278
+ */
279
+ function detectRunningCli() {
280
+ // Priority 1: Environment variables
281
+ if (process.env.CLAUDE_CODE_ENTRY_POINT) return 'claude-code';
282
+ if (process.env.GEMINI_API_KEY && !process.env.CLAUDE_CODE_ENTRY_POINT) return 'gemini-cli';
283
+ if (process.env.CURSOR_SESSION_ID) return 'cursor';
284
+ if (process.env.OPENCODE_SESSION) return 'opencode';
285
+
286
+ // Priority 2: Check caller stack for hook path hints
287
+ try {
288
+ const stack = new Error().stack || '';
289
+ if (stack.includes('/claude-code/')) return 'claude-code';
290
+ if (stack.includes('/gemini-cli/')) return 'gemini-cli';
291
+ if (stack.includes('/cursor/')) return 'cursor';
292
+ if (stack.includes('/opencode/')) return 'opencode';
293
+ } catch {
294
+ // Ignore stack parsing errors
295
+ }
296
+
297
+ // Priority 3: Config file setting
298
+ const config = safeJsonParse(CONFIG_PATH, {});
299
+ if (config.cli?.type) return config.cli.type;
300
+
301
+ // Default
302
+ return 'claude-code';
303
+ }
304
+
305
+ // CLI interface
306
+ if (require.main === module) {
307
+ const args = process.argv.slice(2);
308
+ const command = args[0];
309
+
310
+ const run = async () => {
311
+ switch (command) {
312
+ case 'check': {
313
+ const cliType = args[1] || detectRunningCli();
314
+ const result = needsSync(cliType);
315
+ console.log(JSON.stringify({ cliType, ...result }, null, 2));
316
+ break;
317
+ }
318
+
319
+ case 'sync': {
320
+ const cliType = args[1] || detectRunningCli();
321
+ const result = await autoSyncBridge(cliType, { silent: false, force: args.includes('--force') });
322
+ console.log(JSON.stringify({ cliType, ...result }, null, 2));
323
+ break;
324
+ }
325
+
326
+ case 'sync-all': {
327
+ const results = await syncAllEnabledClis({ silent: false, force: args.includes('--force') });
328
+ console.log(JSON.stringify(results, null, 2));
329
+ break;
330
+ }
331
+
332
+ case 'detect': {
333
+ const cliType = detectRunningCli();
334
+ console.log(cliType);
335
+ break;
336
+ }
337
+
338
+ default:
339
+ console.log('Usage: flow-bridge-state <command> [options]');
340
+ console.log('');
341
+ console.log('Commands:');
342
+ console.log(' check [cli-type] Check if sync is needed');
343
+ console.log(' sync [cli-type] Sync a CLI bridge');
344
+ console.log(' sync-all Sync all enabled CLIs');
345
+ console.log(' detect Detect running CLI type');
346
+ console.log('');
347
+ console.log('Options:');
348
+ console.log(' --force Force sync even if up-to-date');
349
+ }
350
+ };
351
+
352
+ run().catch(err => {
353
+ console.error(`Error: ${err.message}`);
354
+ process.exit(1);
355
+ });
356
+ }
357
+
358
+ module.exports = {
359
+ needsSync,
360
+ autoSyncBridge,
361
+ syncAllEnabledClis,
362
+ detectRunningCli,
363
+ getConfigChecksum,
364
+ getLastSyncTime,
365
+ setLastSyncTime,
366
+ CLI_OUTPUT_FILES
367
+ };
@@ -62,30 +62,44 @@ function listBridges() {
62
62
  {
63
63
  id: 'claude-code',
64
64
  name: 'Claude Code',
65
- status: 'implemented',
65
+ status: 'full',
66
66
  folder: '.claude',
67
67
  rulesFile: 'CLAUDE.md'
68
68
  },
69
69
  {
70
70
  id: 'gemini-cli',
71
71
  name: 'Gemini CLI',
72
- status: 'planned',
72
+ status: 'full',
73
73
  folder: '.gemini',
74
74
  rulesFile: 'GEMINI.md'
75
75
  },
76
+ {
77
+ id: 'cursor',
78
+ name: 'Cursor',
79
+ status: 'full',
80
+ folder: '.cursor',
81
+ rulesFile: '.cursor/rules/wogi-flow.mdc'
82
+ },
76
83
  {
77
84
  id: 'opencode',
78
85
  name: 'OpenCode',
79
- status: 'planned',
86
+ status: 'full',
80
87
  folder: '.opencode',
81
- rulesFile: 'OPENCODE.md'
88
+ rulesFile: '.opencode/agents.md'
89
+ },
90
+ {
91
+ id: 'codex',
92
+ name: 'Codex CLI',
93
+ status: 'soft',
94
+ folder: '.codex',
95
+ rulesFile: 'AGENTS.md'
82
96
  },
83
97
  {
84
- id: 'other',
85
- name: 'Other / Manual',
86
- status: 'manual',
87
- folder: 'N/A',
88
- rulesFile: 'N/A'
98
+ id: 'kimi',
99
+ name: 'Kimi CLI',
100
+ status: 'soft',
101
+ folder: '.kimi',
102
+ rulesFile: 'KIMI.md'
89
103
  }
90
104
  ];
91
105
 
@@ -93,12 +107,14 @@ function listBridges() {
93
107
 
94
108
  for (const bridge of availableBridges) {
95
109
  const isCurrent = bridge.id === currentCli;
96
- const statusColor = bridge.status === 'implemented' ? colors.green :
97
- bridge.status === 'planned' ? colors.yellow : colors.cyan;
110
+ const statusColor = bridge.status === 'full' ? colors.green :
111
+ bridge.status === 'soft' ? colors.yellow : colors.cyan;
112
+ const statusLabel = bridge.status === 'full' ? 'full parity (hooks)' :
113
+ bridge.status === 'soft' ? 'soft parity (rules only)' : bridge.status;
98
114
  const indicator = isCurrent ? `${colors.green}→${colors.reset}` : ' ';
99
115
 
100
116
  console.log(` ${indicator} ${colors.bold}${bridge.name}${colors.reset} (${bridge.id})`);
101
- console.log(` Status: ${statusColor}${bridge.status}${colors.reset}`);
117
+ console.log(` Status: ${statusColor}${statusLabel}${colors.reset}`);
102
118
  console.log(` Folder: ${bridge.folder}`);
103
119
  console.log(` Rules: ${bridge.rulesFile}`);
104
120
  console.log('');
@@ -150,12 +166,43 @@ function showStatus() {
150
166
  console.log('');
151
167
  }
152
168
 
169
+ /**
170
+ * Normalize CLI type argument to standard format
171
+ */
172
+ function normalizeCliType(input) {
173
+ if (!input) return null;
174
+ const normalized = input.toLowerCase().trim();
175
+ const aliases = {
176
+ 'gemini': 'gemini-cli',
177
+ 'gemini-cli': 'gemini-cli',
178
+ 'claude': 'claude-code',
179
+ 'claude-code': 'claude-code',
180
+ 'opencode': 'opencode',
181
+ 'cursor': 'cursor',
182
+ 'codex': 'codex',
183
+ 'kimi': 'kimi'
184
+ };
185
+ return aliases[normalized] || null;
186
+ }
187
+
153
188
  /**
154
189
  * Sync bridge
155
190
  */
156
191
  async function syncBridge(options = {}) {
157
192
  const verbose = options.verbose || process.argv.includes('--verbose') || process.argv.includes('-v');
158
193
 
194
+ // Check for CLI type argument (e.g., "flow bridge sync gemini")
195
+ const cliTypeArg = process.argv[3];
196
+ const requestedCliType = normalizeCliType(cliTypeArg);
197
+
198
+ if (cliTypeArg && !requestedCliType) {
199
+ console.error(`${colors.red}Error:${colors.reset} Unknown CLI type: ${cliTypeArg}`);
200
+ console.error('Available types: claude-code, gemini-cli, cursor, opencode, codex, kimi');
201
+ process.exit(1);
202
+ }
203
+
204
+ const targetCliType = requestedCliType || getCliType();
205
+
159
206
  console.log(`${colors.cyan}Syncing CLI bridge...${colors.reset}`);
160
207
  console.log('');
161
208
 
@@ -170,7 +217,11 @@ async function syncBridge(options = {}) {
170
217
  process.exit(1);
171
218
  }
172
219
 
173
- const result = await bridges.syncBridge({ verbose, projectDir: PROJECT_ROOT });
220
+ const result = await bridges.syncBridge({
221
+ verbose,
222
+ projectDir: PROJECT_ROOT,
223
+ cliType: targetCliType
224
+ });
174
225
 
175
226
  if (result.success) {
176
227
  console.log(`${colors.green}✓ Bridge sync complete${colors.reset}`);
@@ -213,11 +264,18 @@ switch (command) {
213
264
  listBridges();
214
265
  break;
215
266
  default:
216
- console.log('Usage: flow bridge [sync|status|list]');
267
+ console.log('Usage: flow bridge [sync|status|list] [cli-type]');
217
268
  console.log('');
218
269
  console.log('Commands:');
219
- console.log(' sync Sync .workflow/ config to CLI-specific folder');
220
- console.log(' status Show current bridge configuration');
221
- console.log(' list List available CLI bridges');
270
+ console.log(' sync [cli-type] Sync .workflow/ config to CLI-specific folder');
271
+ console.log(' status Show current bridge configuration');
272
+ console.log(' list List available CLI bridges');
273
+ console.log('');
274
+ console.log('CLI Types:');
275
+ console.log(' claude-code, gemini-cli (or gemini), cursor, opencode, codex, kimi');
276
+ console.log('');
277
+ console.log('Examples:');
278
+ console.log(' flow bridge sync # Sync default CLI from config');
279
+ console.log(' flow bridge sync gemini # Sync Gemini CLI specifically');
222
280
  process.exit(1);
223
281
  }
@@ -11,8 +11,30 @@ const { gatherSessionContext } = require('../../core/session-context');
11
11
  const { claudeCodeAdapter } = require('../../adapters/claude-code');
12
12
  const { setCliSessionId, clearStaleCurrentTaskAsync } = require('../../../flow-session-state');
13
13
 
14
+ // Lazy-load bridge state to avoid circular dependencies
15
+ let autoSyncBridge = null;
16
+ function getAutoSyncBridge() {
17
+ if (!autoSyncBridge) {
18
+ try {
19
+ autoSyncBridge = require('../../../flow-bridge-state').autoSyncBridge;
20
+ } catch {
21
+ autoSyncBridge = async () => ({ synced: false, reason: 'unavailable' });
22
+ }
23
+ }
24
+ return autoSyncBridge;
25
+ }
26
+
14
27
  async function main() {
15
28
  try {
29
+ // Auto-sync bridge if needed (non-blocking, silent)
30
+ try {
31
+ const syncFn = getAutoSyncBridge();
32
+ await syncFn('claude-code', { silent: true });
33
+ } catch (err) {
34
+ if (process.env.DEBUG) {
35
+ console.error(`[session-start] Bridge auto-sync failed: ${err.message}`);
36
+ }
37
+ }
16
38
  // Read input from stdin
17
39
  let inputData = '';
18
40
  for await (const chunk of process.stdin) {
@@ -31,6 +31,19 @@ function getSessionState() {
31
31
  return sessionState;
32
32
  }
33
33
 
34
+ // Lazy-load bridge state for auto-sync
35
+ let autoSyncBridge = null;
36
+ function getAutoSyncBridge() {
37
+ if (!autoSyncBridge) {
38
+ try {
39
+ autoSyncBridge = require('../../../flow-bridge-state').autoSyncBridge;
40
+ } catch {
41
+ autoSyncBridge = async () => ({ synced: false, reason: 'unavailable' });
42
+ }
43
+ }
44
+ return autoSyncBridge;
45
+ }
46
+
34
47
  /**
35
48
  * Read stdin with size limit protection
36
49
  * @returns {string} Input data, truncated if over limit
@@ -59,6 +72,16 @@ async function readStdinWithLimit() {
59
72
  * Handle session start event
60
73
  */
61
74
  async function handleSessionStart(input) {
75
+ // Auto-sync bridge if needed (non-blocking, silent)
76
+ try {
77
+ const syncFn = getAutoSyncBridge();
78
+ await syncFn('cursor', { silent: true });
79
+ } catch (err) {
80
+ if (process.env.DEBUG) {
81
+ console.error(`[cursor/session-start] Bridge auto-sync failed: ${err.message}`);
82
+ }
83
+ }
84
+
62
85
  try {
63
86
  const parsedInput = cursorAdapter.parseInput(input);
64
87
 
@@ -22,8 +22,30 @@ try {
22
22
  clearStaleCurrentTaskAsync = async () => {};
23
23
  }
24
24
 
25
+ // Lazy-load bridge state for auto-sync
26
+ let autoSyncBridge = null;
27
+ function getAutoSyncBridge() {
28
+ if (!autoSyncBridge) {
29
+ try {
30
+ autoSyncBridge = require('../../../flow-bridge-state').autoSyncBridge;
31
+ } catch {
32
+ autoSyncBridge = async () => ({ synced: false, reason: 'unavailable' });
33
+ }
34
+ }
35
+ return autoSyncBridge;
36
+ }
37
+
25
38
  async function main() {
26
39
  try {
40
+ // Auto-sync bridge if needed (non-blocking, silent)
41
+ try {
42
+ const syncFn = getAutoSyncBridge();
43
+ await syncFn('gemini-cli', { silent: true });
44
+ } catch (err) {
45
+ if (process.env.DEBUG) {
46
+ console.error(`[session-start] Bridge auto-sync failed: ${err.message}`);
47
+ }
48
+ }
27
49
  // Read input from stdin
28
50
  let inputData = '';
29
51
  for await (const chunk of process.stdin) {
@@ -26,12 +26,35 @@ function getSessionState() {
26
26
  return sessionState;
27
27
  }
28
28
 
29
+ // Lazy-load bridge state for auto-sync
30
+ let autoSyncBridge = null;
31
+ function getAutoSyncBridge() {
32
+ if (!autoSyncBridge) {
33
+ try {
34
+ autoSyncBridge = require('../../../flow-bridge-state').autoSyncBridge;
35
+ } catch {
36
+ autoSyncBridge = async () => ({ synced: false, reason: 'unavailable' });
37
+ }
38
+ }
39
+ return autoSyncBridge;
40
+ }
41
+
29
42
  /**
30
43
  * Handle session start event
31
44
  * @param {Object} ctx - OpenCode plugin context
32
45
  * @returns {Object} Plugin result with additionalContext
33
46
  */
34
47
  async function handleSessionStart(ctx) {
48
+ // Auto-sync bridge if needed (non-blocking, silent)
49
+ try {
50
+ const syncFn = getAutoSyncBridge();
51
+ await syncFn('opencode', { silent: true });
52
+ } catch (err) {
53
+ if (process.env.DEBUG) {
54
+ console.error(`[opencode/session-start] Bridge auto-sync failed: ${err.message}`);
55
+ }
56
+ }
57
+
35
58
  try {
36
59
  const input = ctx || {};
37
60
  const parsedInput = opencodeAdapter.parseInput(input);