forge-workflow 1.3.0 → 1.4.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/bin/forge.js CHANGED
@@ -133,7 +133,7 @@ const AGENTS = {
133
133
  Object.freeze(AGENTS);
134
134
  Object.values(AGENTS).forEach(agent => Object.freeze(agent));
135
135
 
136
- const COMMANDS = ['status', 'research', 'plan', 'dev', 'check', 'ship', 'review', 'merge', 'verify'];
136
+ const COMMANDS = ['status', 'research', 'plan', 'dev', 'check', 'ship', 'review', 'merge', 'verify', 'rollback'];
137
137
 
138
138
  // Code review tool options
139
139
  const CODE_REVIEW_TOOLS = {
@@ -659,12 +659,34 @@ function detectProjectStatus() {
659
659
  const status = {
660
660
  type: 'fresh', // 'fresh', 'upgrade', or 'partial'
661
661
  hasAgentsMd: fs.existsSync(path.join(projectRoot, 'AGENTS.md')),
662
+ hasClaudeMd: fs.existsSync(path.join(projectRoot, 'CLAUDE.md')),
662
663
  hasClaudeCommands: fs.existsSync(path.join(projectRoot, '.claude/commands')),
663
664
  hasEnvLocal: fs.existsSync(path.join(projectRoot, '.env.local')),
664
665
  hasDocsWorkflow: fs.existsSync(path.join(projectRoot, 'docs/WORKFLOW.md')),
665
- existingEnvVars: {}
666
+ existingEnvVars: {},
667
+ agentsMdSize: 0,
668
+ claudeMdSize: 0,
669
+ agentsMdLines: 0,
670
+ claudeMdLines: 0
666
671
  };
667
672
 
673
+ // Get file sizes and line counts for context warnings
674
+ if (status.hasAgentsMd) {
675
+ const agentsPath = path.join(projectRoot, 'AGENTS.md');
676
+ const stats = fs.statSync(agentsPath);
677
+ const content = fs.readFileSync(agentsPath, 'utf8');
678
+ status.agentsMdSize = stats.size;
679
+ status.agentsMdLines = content.split('\n').length;
680
+ }
681
+
682
+ if (status.hasClaudeMd) {
683
+ const claudePath = path.join(projectRoot, 'CLAUDE.md');
684
+ const stats = fs.statSync(claudePath);
685
+ const content = fs.readFileSync(claudePath, 'utf8');
686
+ status.claudeMdSize = stats.size;
687
+ status.claudeMdLines = content.split('\n').length;
688
+ }
689
+
668
690
  // Determine installation type
669
691
  if (status.hasAgentsMd && status.hasClaudeCommands && status.hasDocsWorkflow) {
670
692
  status.type = 'upgrade'; // Full forge installation exists
@@ -681,6 +703,511 @@ function detectProjectStatus() {
681
703
  return status;
682
704
  }
683
705
 
706
+ // Detect project type from package.json
707
+ function detectProjectType() {
708
+ const detection = {
709
+ hasPackageJson: false,
710
+ framework: null,
711
+ frameworkConfidence: 0,
712
+ language: 'javascript',
713
+ languageConfidence: 100,
714
+ projectType: null,
715
+ buildTool: null,
716
+ testFramework: null,
717
+ features: {
718
+ typescript: false,
719
+ monorepo: false,
720
+ docker: false,
721
+ cicd: false
722
+ }
723
+ };
724
+
725
+ const pkg = readPackageJson();
726
+ if (!pkg) return detection;
727
+
728
+ detection.hasPackageJson = true;
729
+
730
+ // Detect TypeScript
731
+ if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript) {
732
+ detection.features.typescript = true;
733
+ detection.language = 'typescript';
734
+ }
735
+
736
+ // Detect monorepo
737
+ if (pkg.workspaces || fs.existsSync(path.join(projectRoot, 'pnpm-workspace.yaml')) || fs.existsSync(path.join(projectRoot, 'lerna.json'))) {
738
+ detection.features.monorepo = true;
739
+ }
740
+
741
+ // Detect Docker
742
+ if (fs.existsSync(path.join(projectRoot, 'Dockerfile')) || fs.existsSync(path.join(projectRoot, 'docker-compose.yml'))) {
743
+ detection.features.docker = true;
744
+ }
745
+
746
+ // Detect CI/CD
747
+ if (fs.existsSync(path.join(projectRoot, '.github/workflows')) ||
748
+ fs.existsSync(path.join(projectRoot, '.gitlab-ci.yml')) ||
749
+ fs.existsSync(path.join(projectRoot, 'azure-pipelines.yml')) ||
750
+ fs.existsSync(path.join(projectRoot, '.circleci/config.yml'))) {
751
+ detection.features.cicd = true;
752
+ }
753
+
754
+ // Framework detection with confidence scoring
755
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
756
+
757
+ // Helper function for test framework detection
758
+ const detectTestFramework = (deps) => {
759
+ if (deps.jest) return 'jest';
760
+ if (deps.vitest) return 'vitest';
761
+ if (deps.mocha) return 'mocha';
762
+ if (deps['@playwright/test']) return 'playwright';
763
+ if (deps.cypress) return 'cypress';
764
+ if (deps.karma) return 'karma';
765
+ return null;
766
+ };
767
+
768
+ // Next.js (highest priority for React projects)
769
+ if (deps.next) {
770
+ detection.framework = 'Next.js';
771
+ detection.frameworkConfidence = 100;
772
+ detection.projectType = 'fullstack';
773
+ detection.buildTool = 'next';
774
+ detection.testFramework = detectTestFramework(deps);
775
+ return detection;
776
+ }
777
+
778
+ // NestJS (backend framework)
779
+ if (deps['@nestjs/core'] || deps['@nestjs/common']) {
780
+ detection.framework = 'NestJS';
781
+ detection.frameworkConfidence = 100;
782
+ detection.projectType = 'backend';
783
+ detection.buildTool = 'nest';
784
+ detection.testFramework = 'jest';
785
+ return detection;
786
+ }
787
+
788
+ // Angular
789
+ if (deps['@angular/core'] || deps['@angular/cli']) {
790
+ detection.framework = 'Angular';
791
+ detection.frameworkConfidence = 100;
792
+ detection.projectType = 'frontend';
793
+ detection.buildTool = 'ng';
794
+ detection.testFramework = 'karma';
795
+ return detection;
796
+ }
797
+
798
+ // Vue.js
799
+ if (deps.vue) {
800
+ if (deps.nuxt) {
801
+ detection.framework = 'Nuxt';
802
+ detection.frameworkConfidence = 100;
803
+ detection.projectType = 'fullstack';
804
+ detection.buildTool = 'nuxt';
805
+ } else {
806
+ detection.framework = 'Vue.js';
807
+ detection.frameworkConfidence = deps['@vue/cli'] ? 100 : 90;
808
+ detection.projectType = 'frontend';
809
+ detection.buildTool = deps.vite ? 'vite' : deps.webpack ? 'webpack' : 'vue-cli';
810
+ }
811
+ detection.testFramework = detectTestFramework(deps);
812
+ return detection;
813
+ }
814
+
815
+ // React (without Next.js)
816
+ if (deps.react) {
817
+ detection.framework = 'React';
818
+ detection.frameworkConfidence = 95;
819
+ detection.projectType = 'frontend';
820
+ detection.buildTool = deps.vite ? 'vite' : deps['react-scripts'] ? 'create-react-app' : 'webpack';
821
+ detection.testFramework = detectTestFramework(deps);
822
+ return detection;
823
+ }
824
+
825
+ // Express (backend)
826
+ if (deps.express) {
827
+ detection.framework = 'Express';
828
+ detection.frameworkConfidence = 90;
829
+ detection.projectType = 'backend';
830
+ detection.buildTool = detection.features.typescript ? 'tsc' : 'node';
831
+ detection.testFramework = detectTestFramework(deps);
832
+ return detection;
833
+ }
834
+
835
+ // Fastify (backend)
836
+ if (deps.fastify) {
837
+ detection.framework = 'Fastify';
838
+ detection.frameworkConfidence = 95;
839
+ detection.projectType = 'backend';
840
+ detection.buildTool = detection.features.typescript ? 'tsc' : 'node';
841
+ detection.testFramework = detectTestFramework(deps);
842
+ return detection;
843
+ }
844
+
845
+ // Svelte
846
+ if (deps.svelte) {
847
+ if (deps['@sveltejs/kit']) {
848
+ detection.framework = 'SvelteKit';
849
+ detection.frameworkConfidence = 100;
850
+ detection.projectType = 'fullstack';
851
+ detection.buildTool = 'vite';
852
+ } else {
853
+ detection.framework = 'Svelte';
854
+ detection.frameworkConfidence = 95;
855
+ detection.projectType = 'frontend';
856
+ detection.buildTool = 'vite';
857
+ }
858
+ detection.testFramework = detectTestFramework(deps);
859
+ return detection;
860
+ }
861
+
862
+ // Remix
863
+ if (deps['@remix-run/react']) {
864
+ detection.framework = 'Remix';
865
+ detection.frameworkConfidence = 100;
866
+ detection.projectType = 'fullstack';
867
+ detection.buildTool = 'remix';
868
+ detection.testFramework = detectTestFramework(deps);
869
+ return detection;
870
+ }
871
+
872
+ // Astro
873
+ if (deps.astro) {
874
+ detection.framework = 'Astro';
875
+ detection.frameworkConfidence = 100;
876
+ detection.projectType = 'frontend';
877
+ detection.buildTool = 'astro';
878
+ detection.testFramework = detectTestFramework(deps);
879
+ return detection;
880
+ }
881
+
882
+ // Generic Node.js project
883
+ if (pkg.main || pkg.scripts?.start) {
884
+ detection.framework = 'Node.js';
885
+ detection.frameworkConfidence = 70;
886
+ detection.projectType = 'backend';
887
+ detection.buildTool = detection.features.typescript ? 'tsc' : 'node';
888
+ detection.testFramework = detectTestFramework(deps);
889
+ return detection;
890
+ }
891
+
892
+ // Fallback: generic JavaScript/TypeScript project
893
+ detection.framework = detection.features.typescript ? 'TypeScript' : 'JavaScript';
894
+ detection.frameworkConfidence = 60;
895
+ detection.projectType = 'library';
896
+ detection.buildTool = deps.vite ? 'vite' : deps.webpack ? 'webpack' : 'npm';
897
+ detection.testFramework = detectTestFramework(deps);
898
+
899
+ return detection;
900
+ }
901
+
902
+ // Display project detection results
903
+ function displayProjectType(detection) {
904
+ if (!detection.hasPackageJson) return;
905
+
906
+ console.log('');
907
+ console.log(chalk.cyan(' 📦 Project Detection:'));
908
+
909
+ if (detection.framework) {
910
+ const confidence = detection.frameworkConfidence >= 90 ? '✓' : '~';
911
+ console.log(` Framework: ${chalk.bold(detection.framework)} ${confidence}`);
912
+ }
913
+
914
+ if (detection.projectType) {
915
+ console.log(` Type: ${detection.projectType}`);
916
+ }
917
+
918
+ if (detection.buildTool) {
919
+ console.log(` Build: ${detection.buildTool}`);
920
+ }
921
+
922
+ if (detection.testFramework) {
923
+ console.log(` Tests: ${detection.testFramework}`);
924
+ }
925
+
926
+ const features = [];
927
+ if (detection.features.typescript) features.push('TypeScript');
928
+ if (detection.features.monorepo) features.push('Monorepo');
929
+ if (detection.features.docker) features.push('Docker');
930
+ if (detection.features.cicd) features.push('CI/CD');
931
+
932
+ if (features.length > 0) {
933
+ console.log(` Features: ${features.join(', ')}`);
934
+ }
935
+ }
936
+
937
+ // Generate framework-specific tips
938
+ function generateFrameworkTips(detection) {
939
+ const tips = {
940
+ 'Next.js': [
941
+ '- Use `npm run dev` for development with hot reload',
942
+ '- Server components are default in App Router',
943
+ '- API routes live in `app/api/` or `pages/api/`'
944
+ ],
945
+ 'React': [
946
+ '- Prefer functional components with hooks',
947
+ '- Use `React.memo()` for expensive components',
948
+ '- State management: Context API or external library'
949
+ ],
950
+ 'Vue.js': [
951
+ '- Use Composition API for better TypeScript support',
952
+ '- `<script setup>` is the recommended syntax',
953
+ '- Pinia is the official state management'
954
+ ],
955
+ 'Angular': [
956
+ '- Use standalone components (Angular 14+)',
957
+ '- Signals for reactive state (Angular 16+)',
958
+ '- RxJS for async operations'
959
+ ],
960
+ 'NestJS': [
961
+ '- Dependency injection via decorators',
962
+ '- Use `@nestjs/config` for environment variables',
963
+ '- Guards for authentication, Interceptors for logging'
964
+ ],
965
+ 'Express': [
966
+ '- Use middleware for cross-cutting concerns',
967
+ '- Error handling with next(err)',
968
+ '- Consider Helmet.js for security headers'
969
+ ],
970
+ 'Fastify': [
971
+ '- Schema-based validation with JSON Schema',
972
+ '- Plugins for reusable functionality',
973
+ '- Async/await by default'
974
+ ],
975
+ 'SvelteKit': [
976
+ '- File-based routing in `src/routes/`',
977
+ '- Server-side rendering by default',
978
+ '- Form actions for mutations'
979
+ ],
980
+ 'Nuxt': [
981
+ '- Auto-imports for components and composables',
982
+ '- `useAsyncData()` for data fetching',
983
+ '- Nitro server engine for deployment'
984
+ ],
985
+ 'Remix': [
986
+ '- Loaders for data fetching',
987
+ '- Actions for mutations',
988
+ '- Progressive enhancement by default'
989
+ ],
990
+ 'Astro': [
991
+ '- Zero JS by default',
992
+ '- Use client:* directives for interactivity',
993
+ '- Content collections for type-safe content'
994
+ ]
995
+ };
996
+
997
+ return tips[detection.framework] || [];
998
+ }
999
+
1000
+ // Update AGENTS.md with project type metadata
1001
+ function updateAgentsMdWithProjectType(detection) {
1002
+ const agentsPath = path.join(projectRoot, 'AGENTS.md');
1003
+ if (!fs.existsSync(agentsPath)) return;
1004
+
1005
+ let content = fs.readFileSync(agentsPath, 'utf-8');
1006
+
1007
+ // Find the project description line (line 3)
1008
+ const lines = content.split('\n');
1009
+ let insertIndex = -1;
1010
+
1011
+ for (let i = 0; i < Math.min(lines.length, 10); i++) {
1012
+ if (lines[i].startsWith('This is a ')) {
1013
+ insertIndex = i + 1;
1014
+ break;
1015
+ }
1016
+ }
1017
+
1018
+ if (insertIndex === -1) return;
1019
+
1020
+ // Build metadata section
1021
+ const metadata = [];
1022
+ metadata.push('');
1023
+ if (detection.framework) {
1024
+ metadata.push(`**Framework**: ${detection.framework}`);
1025
+ }
1026
+ if (detection.language && detection.language !== 'javascript') {
1027
+ metadata.push(`**Language**: ${detection.language}`);
1028
+ }
1029
+ if (detection.projectType) {
1030
+ metadata.push(`**Type**: ${detection.projectType}`);
1031
+ }
1032
+ if (detection.buildTool) {
1033
+ metadata.push(`**Build**: \`${detection.buildTool}\``);
1034
+ }
1035
+ if (detection.testFramework) {
1036
+ metadata.push(`**Tests**: ${detection.testFramework}`);
1037
+ }
1038
+
1039
+ // Add framework-specific tips
1040
+ const tips = generateFrameworkTips(detection);
1041
+ if (tips.length > 0) {
1042
+ metadata.push('');
1043
+ metadata.push('**Framework conventions**:');
1044
+ metadata.push(...tips);
1045
+ }
1046
+
1047
+ // Insert metadata
1048
+ lines.splice(insertIndex, 0, ...metadata);
1049
+
1050
+ fs.writeFileSync(agentsPath, lines.join('\n'), 'utf-8');
1051
+ }
1052
+
1053
+ // Smart file selection with context warnings
1054
+ async function handleInstructionFiles(rl, question, selectedAgents, projectStatus) {
1055
+ const hasClaude = selectedAgents.some(a => a.key === 'claude');
1056
+ const hasOtherAgents = selectedAgents.some(a => a.key !== 'claude');
1057
+
1058
+ // Calculate estimated tokens (rough: ~4 chars per token)
1059
+ const estimateTokens = (bytes) => Math.ceil(bytes / 4);
1060
+
1061
+ const result = {
1062
+ createAgentsMd: false,
1063
+ createClaudeMd: false,
1064
+ skipAgentsMd: false,
1065
+ skipClaudeMd: false
1066
+ };
1067
+
1068
+ // Scenario 1: Both files exist (potential context bloat)
1069
+ if (projectStatus.hasAgentsMd && projectStatus.hasClaudeMd) {
1070
+ const totalLines = projectStatus.agentsMdLines + projectStatus.claudeMdLines;
1071
+ const totalTokens = estimateTokens(projectStatus.agentsMdSize + projectStatus.claudeMdSize);
1072
+
1073
+ console.log('');
1074
+ console.log('⚠️ WARNING: Multiple Instruction Files Detected');
1075
+ console.log('='.repeat(60));
1076
+ console.log(` AGENTS.md: ${projectStatus.agentsMdLines} lines (~${estimateTokens(projectStatus.agentsMdSize)} tokens)`);
1077
+ console.log(` CLAUDE.md: ${projectStatus.claudeMdLines} lines (~${estimateTokens(projectStatus.claudeMdSize)} tokens)`);
1078
+ console.log(` Total: ${totalLines} lines (~${totalTokens} tokens)`);
1079
+ console.log('');
1080
+ console.log(' ⚠️ Claude Code reads BOTH files on every request');
1081
+ console.log(' ⚠️ This increases context usage and costs');
1082
+ console.log('');
1083
+ console.log(' Options:');
1084
+ console.log(' 1) Keep CLAUDE.md only (recommended for Claude Code only)');
1085
+ console.log(' 2) Keep AGENTS.md only (recommended for multi-agent users)');
1086
+ console.log(' 3) Keep both (higher context usage)');
1087
+ console.log('');
1088
+
1089
+ while (true) {
1090
+ const choice = await question('Your choice (1/2/3) [2]: ');
1091
+ const normalized = choice.trim() || '2';
1092
+
1093
+ if (normalized === '1') {
1094
+ result.skipAgentsMd = true;
1095
+ result.createClaudeMd = false; // Keep existing
1096
+ console.log(' ✓ Will keep CLAUDE.md, remove AGENTS.md');
1097
+ break;
1098
+ } else if (normalized === '2') {
1099
+ result.skipClaudeMd = true;
1100
+ result.createAgentsMd = false; // Keep existing
1101
+ console.log(' ✓ Will keep AGENTS.md, remove CLAUDE.md');
1102
+ break;
1103
+ } else if (normalized === '3') {
1104
+ result.createAgentsMd = false; // Keep existing
1105
+ result.createClaudeMd = false; // Keep existing
1106
+ console.log(' ✓ Will keep both files (context: ~' + totalTokens + ' tokens)');
1107
+ break;
1108
+ } else {
1109
+ console.log(' Please enter 1, 2, or 3');
1110
+ }
1111
+ }
1112
+
1113
+ return result;
1114
+ }
1115
+
1116
+ // Scenario 2: Only CLAUDE.md exists
1117
+ if (projectStatus.hasClaudeMd && !projectStatus.hasAgentsMd) {
1118
+ if (hasOtherAgents) {
1119
+ console.log('');
1120
+ console.log('📋 Found existing CLAUDE.md (' + projectStatus.claudeMdLines + ' lines)');
1121
+ console.log(' You selected multiple agents. Recommendation:');
1122
+ console.log(' → Migrate to AGENTS.md (works with all agents)');
1123
+ console.log('');
1124
+
1125
+ const migrate = await askYesNo(question, 'Migrate CLAUDE.md to AGENTS.md?', false);
1126
+ if (migrate) {
1127
+ result.createAgentsMd = true;
1128
+ result.skipClaudeMd = true;
1129
+ console.log(' ✓ Will migrate content to AGENTS.md');
1130
+ } else {
1131
+ result.createAgentsMd = true;
1132
+ result.createClaudeMd = false; // Keep existing
1133
+ console.log(' ✓ Will keep CLAUDE.md and create AGENTS.md');
1134
+ }
1135
+ } else {
1136
+ // Claude Code only - keep CLAUDE.md
1137
+ result.createClaudeMd = false; // Keep existing
1138
+ console.log(' ✓ Keeping existing CLAUDE.md');
1139
+ }
1140
+
1141
+ return result;
1142
+ }
1143
+
1144
+ // Scenario 3: Only AGENTS.md exists
1145
+ if (projectStatus.hasAgentsMd && !projectStatus.hasClaudeMd) {
1146
+ if (hasClaude && !hasOtherAgents) {
1147
+ console.log('');
1148
+ console.log('📋 Found existing AGENTS.md (' + projectStatus.agentsMdLines + ' lines)');
1149
+ console.log(' You selected Claude Code only. Options:');
1150
+ console.log(' 1) Keep AGENTS.md (works fine)');
1151
+ console.log(' 2) Rename to CLAUDE.md (Claude-specific naming)');
1152
+ console.log('');
1153
+
1154
+ const rename = await askYesNo(question, 'Rename to CLAUDE.md?', true);
1155
+ if (rename) {
1156
+ result.createClaudeMd = true;
1157
+ result.skipAgentsMd = true;
1158
+ console.log(' ✓ Will rename to CLAUDE.md');
1159
+ } else {
1160
+ result.createAgentsMd = false; // Keep existing
1161
+ console.log(' ✓ Keeping AGENTS.md');
1162
+ }
1163
+ } else {
1164
+ // Multi-agent or other agents - keep AGENTS.md
1165
+ result.createAgentsMd = false; // Keep existing
1166
+ console.log(' ✓ Keeping existing AGENTS.md');
1167
+ }
1168
+
1169
+ return result;
1170
+ }
1171
+
1172
+ // Scenario 4: Neither file exists (fresh install)
1173
+ if (hasClaude && !hasOtherAgents) {
1174
+ // Claude Code only → create CLAUDE.md
1175
+ result.createClaudeMd = true;
1176
+ console.log(' ✓ Will create CLAUDE.md (Claude Code specific)');
1177
+ } else if (!hasClaude && hasOtherAgents) {
1178
+ // Other agents only → create AGENTS.md
1179
+ result.createAgentsMd = true;
1180
+ console.log(' ✓ Will create AGENTS.md (universal)');
1181
+ } else {
1182
+ // Multiple agents including Claude → create AGENTS.md + reference CLAUDE.md
1183
+ result.createAgentsMd = true;
1184
+ result.createClaudeMd = true; // Will be minimal reference
1185
+ console.log(' ✓ Will create AGENTS.md (main) + CLAUDE.md (reference)');
1186
+ }
1187
+
1188
+ return result;
1189
+ }
1190
+
1191
+ // Create minimal CLAUDE.md that references AGENTS.md
1192
+ function createClaudeReference(destPath) {
1193
+ const content = `# Claude Code Instructions
1194
+
1195
+ See [AGENTS.md](AGENTS.md) for all project instructions.
1196
+
1197
+ This file exists to avoid Claude Code reading both CLAUDE.md and AGENTS.md (which doubles context usage). Keep project-level instructions in AGENTS.md.
1198
+
1199
+ ---
1200
+
1201
+ <!-- Add Claude Code-specific instructions below (if needed) -->
1202
+ <!-- Examples: MCP server setup, custom commands, Claude-only workflows -->
1203
+
1204
+ 💡 **Keep this minimal** - Main instructions are in AGENTS.md
1205
+ `;
1206
+
1207
+ fs.writeFileSync(destPath, content, 'utf8');
1208
+ return true;
1209
+ }
1210
+
684
1211
  // Configure external services interactively
685
1212
  async function configureExternalServices(rl, question, selectedAgents = [], projectStatus = null) {
686
1213
  console.log('');
@@ -972,6 +1499,13 @@ function minimalInstall() {
972
1499
  const agentsSrc = path.join(packageDir, 'AGENTS.md');
973
1500
  if (copyFile(agentsSrc, 'AGENTS.md')) {
974
1501
  console.log(' Created: AGENTS.md (universal standard)');
1502
+
1503
+ // Detect project type and update AGENTS.md
1504
+ const detection = detectProjectType();
1505
+ if (detection.hasPackageJson) {
1506
+ updateAgentsMdWithProjectType(detection);
1507
+ displayProjectType(detection);
1508
+ }
975
1509
  }
976
1510
  }
977
1511
 
@@ -1353,6 +1887,13 @@ async function interactiveSetup() {
1353
1887
  // New file
1354
1888
  if (copyFile(agentsSrc, 'AGENTS.md')) {
1355
1889
  console.log(' Created: AGENTS.md (universal standard)');
1890
+
1891
+ // Detect project type and update AGENTS.md
1892
+ const detection = detectProjectType();
1893
+ if (detection.hasPackageJson) {
1894
+ updateAgentsMdWithProjectType(detection);
1895
+ displayProjectType(detection);
1896
+ }
1356
1897
  }
1357
1898
  }
1358
1899
  }
@@ -1834,6 +2375,13 @@ async function interactiveSetupWithFlags(flags) {
1834
2375
  // New file
1835
2376
  if (copyFile(agentsSrc, 'AGENTS.md')) {
1836
2377
  console.log(' Created: AGENTS.md (universal standard)');
2378
+
2379
+ // Detect project type and update AGENTS.md
2380
+ const detection = detectProjectType();
2381
+ if (detection.hasPackageJson) {
2382
+ updateAgentsMdWithProjectType(detection);
2383
+ displayProjectType(detection);
2384
+ }
1837
2385
  }
1838
2386
  }
1839
2387
  }
@@ -2089,10 +2637,345 @@ async function main() {
2089
2637
 
2090
2638
  // Interactive setup (skip-external still applies)
2091
2639
  await interactiveSetupWithFlags(flags);
2640
+ } else if (command === 'rollback') {
2641
+ // Execute rollback menu
2642
+ await showRollbackMenu();
2092
2643
  } else {
2093
2644
  // Default: minimal install (postinstall behavior)
2094
2645
  minimalInstall();
2095
2646
  }
2096
2647
  }
2097
2648
 
2649
+ // ============================================================================
2650
+ // ROLLBACK SYSTEM - TDD Validated
2651
+ // ============================================================================
2652
+ // Security: All inputs validated before use in git commands
2653
+ // See test/rollback-validation.test.js for validation test coverage
2654
+
2655
+ // Validate rollback inputs (security-critical)
2656
+ function validateRollbackInput(method, target) {
2657
+ const validMethods = ['commit', 'pr', 'partial', 'branch'];
2658
+ if (!validMethods.includes(method)) {
2659
+ return { valid: false, error: 'Invalid method' };
2660
+ }
2661
+
2662
+ // Validate commit hash (git allows 4-40 char abbreviations)
2663
+ if (method === 'commit' || method === 'pr') {
2664
+ if (target !== 'HEAD' && !/^[0-9a-f]{4,40}$/i.test(target)) {
2665
+ return { valid: false, error: 'Invalid commit hash format' };
2666
+ }
2667
+ }
2668
+
2669
+ // Validate file paths
2670
+ if (method === 'partial') {
2671
+ const files = target.split(',').map(f => f.trim());
2672
+ for (const file of files) {
2673
+ // Reject shell metacharacters
2674
+ if (/[;|&$`()<>\r\n]/.test(file)) {
2675
+ return { valid: false, error: `Invalid characters in path: ${file}` };
2676
+ }
2677
+ // Reject URL-encoded path traversal attempts
2678
+ if (/%2[eE]|%2[fF]|%5[cC]/.test(file)) {
2679
+ return { valid: false, error: `URL-encoded characters not allowed: ${file}` };
2680
+ }
2681
+ // Reject non-ASCII/unicode characters
2682
+ if (!/^[\x20-\x7E]+$/.test(file)) {
2683
+ return { valid: false, error: `Only ASCII characters allowed in path: ${file}` };
2684
+ }
2685
+ // Prevent path traversal
2686
+ const resolved = path.resolve(projectRoot, file);
2687
+ if (!resolved.startsWith(projectRoot)) {
2688
+ return { valid: false, error: `Path outside project: ${file}` };
2689
+ }
2690
+ }
2691
+ }
2692
+
2693
+ // Validate branch range
2694
+ if (method === 'branch') {
2695
+ if (!target.includes('..')) {
2696
+ return { valid: false, error: 'Branch range must use format: start..end' };
2697
+ }
2698
+ const [start, end] = target.split('..');
2699
+ if (!/^[0-9a-f]{4,40}$/i.test(start) || !/^[0-9a-f]{4,40}$/i.test(end)) {
2700
+ return { valid: false, error: 'Invalid commit hashes in range' };
2701
+ }
2702
+ }
2703
+
2704
+ return { valid: true };
2705
+ }
2706
+
2707
+ // Extract USER sections before rollback
2708
+ function extractUserSections(filePath) {
2709
+ if (!fs.existsSync(filePath)) return {};
2710
+
2711
+ const content = fs.readFileSync(filePath, 'utf-8');
2712
+ const sections = {};
2713
+
2714
+ // Extract USER sections
2715
+ const userRegex = /<!-- USER:START -->([\s\S]*?)<!-- USER:END -->/g;
2716
+ let match;
2717
+ let index = 0;
2718
+
2719
+ while ((match = userRegex.exec(content)) !== null) {
2720
+ sections[`user_${index}`] = match[1];
2721
+ index++;
2722
+ }
2723
+
2724
+ // Extract custom commands
2725
+ const customCommandsDir = path.join(path.dirname(filePath), '.claude', 'commands', 'custom');
2726
+ if (fs.existsSync(customCommandsDir)) {
2727
+ sections.customCommands = fs.readdirSync(customCommandsDir)
2728
+ .filter(f => f.endsWith('.md'))
2729
+ .map(f => ({
2730
+ name: f,
2731
+ content: fs.readFileSync(path.join(customCommandsDir, f), 'utf-8')
2732
+ }));
2733
+ }
2734
+
2735
+ return sections;
2736
+ }
2737
+
2738
+ // Restore USER sections after rollback
2739
+ function preserveUserSections(filePath, savedSections) {
2740
+ if (!fs.existsSync(filePath) || Object.keys(savedSections).length === 0) {
2741
+ return;
2742
+ }
2743
+
2744
+ let content = fs.readFileSync(filePath, 'utf-8');
2745
+
2746
+ // Restore USER sections
2747
+ let index = 0;
2748
+ content = content.replace(
2749
+ /<!-- USER:START -->[\s\S]*?<!-- USER:END -->/g,
2750
+ () => {
2751
+ const section = savedSections[`user_${index}`];
2752
+ index++;
2753
+ return section ? `<!-- USER:START -->${section}<!-- USER:END -->` : '';
2754
+ }
2755
+ );
2756
+
2757
+ fs.writeFileSync(filePath, content, 'utf-8');
2758
+
2759
+ // Restore custom commands
2760
+ if (savedSections.customCommands) {
2761
+ const customCommandsDir = path.join(path.dirname(filePath), '.claude', 'commands', 'custom');
2762
+ if (!fs.existsSync(customCommandsDir)) {
2763
+ fs.mkdirSync(customCommandsDir, { recursive: true });
2764
+ }
2765
+
2766
+ savedSections.customCommands.forEach(cmd => {
2767
+ fs.writeFileSync(
2768
+ path.join(customCommandsDir, cmd.name),
2769
+ cmd.content,
2770
+ 'utf-8'
2771
+ );
2772
+ });
2773
+ }
2774
+ }
2775
+
2776
+ // Perform rollback operation
2777
+ async function performRollback(method, target, dryRun = false) {
2778
+ console.log('');
2779
+ console.log(chalk.cyan(` 🔄 Rollback: ${method}`));
2780
+ console.log(` Target: ${target}`);
2781
+ if (dryRun) {
2782
+ console.log(chalk.yellow(' Mode: DRY RUN (preview only)'));
2783
+ }
2784
+ console.log('');
2785
+
2786
+ // Validate inputs BEFORE any git operations
2787
+ const validation = validateRollbackInput(method, target);
2788
+ if (!validation.valid) {
2789
+ console.log(chalk.red(` ❌ ${validation.error}`));
2790
+ return false;
2791
+ }
2792
+
2793
+ // Check for clean working directory
2794
+ try {
2795
+ const { execSync } = require('child_process');
2796
+ const status = execSync('git status --porcelain', { encoding: 'utf-8' });
2797
+ if (status.trim() !== '') {
2798
+ console.log(chalk.red(' ❌ Working directory has uncommitted changes'));
2799
+ console.log(' Commit or stash changes before rollback');
2800
+ return false;
2801
+ }
2802
+ } catch (err) {
2803
+ console.log(chalk.red(' ❌ Git error:'), err.message);
2804
+ return false;
2805
+ }
2806
+
2807
+ // Extract USER sections before rollback
2808
+ const agentsPath = path.join(projectRoot, 'AGENTS.md');
2809
+ const savedSections = extractUserSections(agentsPath);
2810
+
2811
+ if (!dryRun) {
2812
+ console.log(' 📦 Backing up user content...');
2813
+ }
2814
+
2815
+ try {
2816
+ const { execSync } = require('child_process');
2817
+
2818
+ if (method === 'commit') {
2819
+ if (dryRun) {
2820
+ console.log(` Would revert: ${target}`);
2821
+ const files = execSync(`git diff-tree --no-commit-id --name-only -r ${target}`, { encoding: 'utf-8' });
2822
+ console.log(' Affected files:');
2823
+ files.trim().split('\n').forEach(f => console.log(` - ${f}`));
2824
+ } else {
2825
+ execSync(`git revert --no-edit ${target}`, { stdio: 'inherit' });
2826
+ }
2827
+ } else if (method === 'pr') {
2828
+ if (dryRun) {
2829
+ console.log(` Would revert merge: ${target}`);
2830
+ const files = execSync(`git diff-tree --no-commit-id --name-only -r ${target}`, { encoding: 'utf-8' });
2831
+ console.log(' Affected files:');
2832
+ files.trim().split('\n').forEach(f => console.log(` - ${f}`));
2833
+ } else {
2834
+ execSync(`git revert -m 1 --no-edit ${target}`, { stdio: 'inherit' });
2835
+
2836
+ // Update Beads issue if linked
2837
+ const commitMsg = execSync(`git log -1 --format=%B ${target}`, { encoding: 'utf-8' });
2838
+ const issueMatch = commitMsg.match(/#(\d+)/);
2839
+ if (issueMatch) {
2840
+ try {
2841
+ execSync(`bd update ${issueMatch[1]} --status reverted --comment "PR reverted"`, { stdio: 'inherit' });
2842
+ console.log(` Updated Beads issue #${issueMatch[1]} to 'reverted'`);
2843
+ } catch {
2844
+ // Beads not installed - silently continue
2845
+ }
2846
+ }
2847
+ }
2848
+ } else if (method === 'partial') {
2849
+ const files = target.split(',').map(f => f.trim());
2850
+ if (dryRun) {
2851
+ console.log(' Would restore files:');
2852
+ files.forEach(f => console.log(` - ${f}`));
2853
+ } else {
2854
+ files.forEach(f => {
2855
+ execSync(`git checkout HEAD~1 -- "${f}"`, { stdio: 'inherit' });
2856
+ });
2857
+ execSync(`git commit -m "chore: rollback ${files.join(', ')}"`, { stdio: 'inherit' });
2858
+ }
2859
+ } else if (method === 'branch') {
2860
+ const [startCommit, endCommit] = target.split('..');
2861
+ if (dryRun) {
2862
+ console.log(` Would revert range: ${startCommit}..${endCommit}`);
2863
+ const commits = execSync(`git log --oneline ${startCommit}..${endCommit}`, { encoding: 'utf-8' });
2864
+ console.log(' Commits to revert:');
2865
+ commits.trim().split('\n').forEach(c => console.log(` ${c}`));
2866
+ } else {
2867
+ execSync(`git revert --no-edit ${startCommit}..${endCommit}`, { stdio: 'inherit' });
2868
+ }
2869
+ }
2870
+
2871
+ if (!dryRun) {
2872
+ console.log(' 📦 Restoring user content...');
2873
+ preserveUserSections(agentsPath, savedSections);
2874
+
2875
+ // Amend commit to include restored USER sections
2876
+ if (fs.existsSync(agentsPath)) {
2877
+ execSync('git add AGENTS.md', { stdio: 'inherit' });
2878
+ execSync('git commit --amend --no-edit', { stdio: 'inherit' });
2879
+ }
2880
+
2881
+ console.log('');
2882
+ console.log(chalk.green(' ✅ Rollback complete'));
2883
+ console.log(' User content preserved');
2884
+ }
2885
+
2886
+ return true;
2887
+ } catch (err) {
2888
+ console.log('');
2889
+ console.log(chalk.red(' ❌ Rollback failed:'), err.message);
2890
+ console.log(' Try manual rollback with: git revert <commit>');
2891
+ return false;
2892
+ }
2893
+ }
2894
+
2895
+ // Interactive rollback menu
2896
+ async function showRollbackMenu() {
2897
+ console.log('');
2898
+ console.log(chalk.cyan.bold(' 🔄 Forge Rollback'));
2899
+ console.log('');
2900
+ console.log(' Choose rollback method:');
2901
+ console.log('');
2902
+ console.log(' 1. Rollback last commit');
2903
+ console.log(' 2. Rollback specific commit');
2904
+ console.log(' 3. Rollback merged PR');
2905
+ console.log(' 4. Rollback specific files only');
2906
+ console.log(' 5. Rollback entire branch');
2907
+ console.log(' 6. Preview rollback (dry run)');
2908
+ console.log('');
2909
+
2910
+ const rl = readline.createInterface({
2911
+ input: process.stdin,
2912
+ output: process.stdout
2913
+ });
2914
+
2915
+ const choice = await new Promise(resolve => {
2916
+ rl.question(' Enter choice (1-6): ', resolve);
2917
+ });
2918
+
2919
+ let method, target, dryRun = false;
2920
+
2921
+ switch (choice.trim()) {
2922
+ case '1':
2923
+ method = 'commit';
2924
+ target = 'HEAD';
2925
+ break;
2926
+
2927
+ case '2':
2928
+ target = await new Promise(resolve => {
2929
+ rl.question(' Enter commit hash: ', resolve);
2930
+ });
2931
+ method = 'commit';
2932
+ break;
2933
+
2934
+ case '3':
2935
+ target = await new Promise(resolve => {
2936
+ rl.question(' Enter merge commit hash: ', resolve);
2937
+ });
2938
+ method = 'pr';
2939
+ break;
2940
+
2941
+ case '4':
2942
+ target = await new Promise(resolve => {
2943
+ rl.question(' Enter file paths (comma-separated): ', resolve);
2944
+ });
2945
+ method = 'partial';
2946
+ break;
2947
+
2948
+ case '5':
2949
+ const start = await new Promise(resolve => {
2950
+ rl.question(' Enter start commit: ', resolve);
2951
+ });
2952
+ const end = await new Promise(resolve => {
2953
+ rl.question(' Enter end commit: ', resolve);
2954
+ });
2955
+ target = `${start.trim()}..${end.trim()}`;
2956
+ method = 'branch';
2957
+ break;
2958
+
2959
+ case '6':
2960
+ dryRun = true;
2961
+ const dryMethod = await new Promise(resolve => {
2962
+ rl.question(' Preview method (commit/pr/partial/branch): ', resolve);
2963
+ });
2964
+ method = dryMethod.trim();
2965
+ target = await new Promise(resolve => {
2966
+ rl.question(' Enter target (commit/files/range): ', resolve);
2967
+ });
2968
+ break;
2969
+
2970
+ default:
2971
+ console.log(chalk.red(' Invalid choice'));
2972
+ rl.close();
2973
+ return;
2974
+ }
2975
+
2976
+ rl.close();
2977
+
2978
+ await performRollback(method, target, dryRun);
2979
+ }
2980
+
2098
2981
  main().catch(console.error);