feat-forge 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +350 -0
  3. package/dist/cli.js +306 -0
  4. package/dist/commands/AbstractCommands.js +16 -0
  5. package/dist/commands/AgentCommands.js +14 -0
  6. package/dist/commands/BranchCommands.js +400 -0
  7. package/dist/commands/CompletionCommands.js +702 -0
  8. package/dist/commands/EnvCommands.js +56 -0
  9. package/dist/commands/FeatureCommands.js +4 -0
  10. package/dist/commands/FixCommands.js +4 -0
  11. package/dist/commands/InitCommands.js +380 -0
  12. package/dist/commands/MaintenanceCommands.js +39 -0
  13. package/dist/commands/ModeCommands.js +15 -0
  14. package/dist/commands/ProxyCommands.js +14 -0
  15. package/dist/commands/ReleaseCommands.js +4 -0
  16. package/dist/commands/ServicesCommands.js +95 -0
  17. package/dist/commands/SubBranchCommands.js +49 -0
  18. package/dist/commands/types/InitOptions.js +1 -0
  19. package/dist/foundation/BranchContext.js +427 -0
  20. package/dist/foundation/ForgeConfig.js +264 -0
  21. package/dist/foundation/ForgeConfigFile.js +391 -0
  22. package/dist/foundation/ForgeContext.js +169 -0
  23. package/dist/foundation/NpmHelper.js +131 -0
  24. package/dist/foundation/PathHelper.js +56 -0
  25. package/dist/foundation/PortAllocator.js +192 -0
  26. package/dist/foundation/Proxy.js +176 -0
  27. package/dist/foundation/Repository.js +431 -0
  28. package/dist/foundation/errors/ForgeError.js +9 -0
  29. package/dist/foundation/errors/_error.config.js +12 -0
  30. package/dist/foundation/errors/generated/ForgeBadStateError.js +11 -0
  31. package/dist/foundation/errors/generated/ForgeConfigError.js +11 -0
  32. package/dist/foundation/errors/generated/ForgeExpectMainRepositoryError.js +11 -0
  33. package/dist/foundation/errors/generated/ForgeModeNotDefinedError.js +11 -0
  34. package/dist/foundation/errors/generated/ForgeNotInActiveBranchError.js +11 -0
  35. package/dist/foundation/errors/generated/ForgePortAllocationsLoadError.js +11 -0
  36. package/dist/foundation/errors/generated/ForgePortNotAssignedError.js +11 -0
  37. package/dist/foundation/errors/generated/ForgePortRangeExhaustedError.js +11 -0
  38. package/dist/foundation/errors/generated/ForgeServicesScanError.js +11 -0
  39. package/dist/foundation/errors/generated/ForgeServicesValidationError.js +11 -0
  40. package/dist/foundation/errors/index.js +13 -0
  41. package/dist/foundation/types/AIAgent.js +1 -0
  42. package/dist/foundation/types/AIAgentName.js +11 -0
  43. package/dist/foundation/types/DeepPartial.js +1 -0
  44. package/dist/foundation/types/IDE.js +1 -0
  45. package/dist/foundation/types/IDEName.js +7 -0
  46. package/dist/foundation/types/ModeConfig.js +1 -0
  47. package/dist/foundation/types/RepositoryInfos.js +1 -0
  48. package/dist/foundation/types/Services.js +156 -0
  49. package/dist/foundation/types/ShellName.js +11 -0
  50. package/dist/lib/agents.js +47 -0
  51. package/dist/lib/bootstrap.js +54 -0
  52. package/dist/lib/branch.js +4 -0
  53. package/dist/lib/config.js +65 -0
  54. package/dist/lib/constants.js +13 -0
  55. package/dist/lib/env.js +20 -0
  56. package/dist/lib/fs.js +156 -0
  57. package/dist/lib/git.js +170 -0
  58. package/dist/lib/hooks.js +98 -0
  59. package/dist/lib/ide.js +75 -0
  60. package/dist/lib/merger.js +103 -0
  61. package/dist/lib/platform.js +13 -0
  62. package/dist/lib/prompt.js +134 -0
  63. package/dist/lib/proxy-dashboard.js +75 -0
  64. package/dist/lib/scanner.js +118 -0
  65. package/dist/lib/services.js +132 -0
  66. package/dist/lib/slug.js +35 -0
  67. package/dist/lib/templates.js +115 -0
  68. package/dist/lib/validator.js +15 -0
  69. package/dist/templates/SPEC.md +21 -0
  70. package/dist/templates/TODO.md +5 -0
  71. package/dist/templates/agent/001.general.Omnibus.agent.md +4 -0
  72. package/dist/templates/agent/002.discovery.Inventorius.agent.md +4 -0
  73. package/dist/templates/agent/003.design.Architecturius.agent.md +8 -0
  74. package/dist/templates/agent/004.plan.Strategos.agent.md +8 -0
  75. package/dist/templates/agent/005.tdd.TestDrivenCodificius.agent.md +8 -0
  76. package/dist/templates/agent/006.code.Codificius.agent.md +8 -0
  77. package/dist/templates/agent/007.simplify.Consolidarius.agent.md +8 -0
  78. package/dist/templates/agent/008.review.Auditorix.agent.md +8 -0
  79. package/dist/templates/agent/009.testwriter.TestScriptor.agent.md +8 -0
  80. package/dist/templates/agent/010.testexecutor.TestExecutor.agent.md +8 -0
  81. package/dist/templates/agent/011.commit.Scribus.agent.md +10 -0
  82. package/dist/templates/agent/CONTEXT.code.md +145 -0
  83. package/dist/templates/agent/CONTEXT.spec.md +98 -0
  84. package/dist/templates/agent/Copilot/Code.agent.md +28 -0
  85. package/dist/templates/agent/Copilot/CodeCommit.agent.md +16 -0
  86. package/dist/templates/agent/Copilot/Feature-Builder.agent.md +49 -0
  87. package/dist/templates/agent/Copilot/Reviewer.agent.md +17 -0
  88. package/dist/templates/agent/Copilot/Simplifier.agent.md +21 -0
  89. package/dist/templates/agent/Copilot/Specs.agent.md +66 -0
  90. package/dist/templates/agent/Copilot/SpecsCommit.agent.md +19 -0
  91. package/dist/templates/agent/Copilot/TODO-Reader.agent.md +18 -0
  92. package/dist/templates/agent/Copilot/Tester.agent.md +12 -0
  93. package/package.json +76 -0
@@ -0,0 +1,427 @@
1
+ import { branchNameAsPath } from '../lib/branch.js';
2
+ import { HookEvent } from '../lib/hooks.js';
3
+ import { replaceTemplateMarkers, resolveAgentFileCustomTemplate, resolveSpecFileCustomTemplate } from '../lib/templates.js';
4
+ import { readdir, rm, symlink } from 'fs/promises';
5
+ import path from 'path';
6
+ import { refreshCopilotAgentContextFiles } from '../lib/agents.js';
7
+ import { TemporaryFolderType } from '../lib/constants.js';
8
+ import { ensureDir, ensureLineInFile, pathExists, readTextFile, writeTextFile } from '../lib/fs.js';
9
+ import { getGitStatusPorcelain, runGit } from '../lib/git.js';
10
+ import { createIDEWorkspaces } from '../lib/ide.js';
11
+ import { promptConfirm, promptForBranch } from '../lib/prompt.js';
12
+ import { WorktreeRepository } from './Repository.js';
13
+ import { AIAgentName } from './types/AIAgentName.js';
14
+ import { slugify } from '../lib/slug.js';
15
+ import { ForgeNotInActiveBranchError } from './errors/index.js';
16
+ export class BranchContext {
17
+ context;
18
+ branchName;
19
+ path;
20
+ repositories;
21
+ active;
22
+ constructor(context, branchName, path, repositories, active) {
23
+ this.context = context;
24
+ this.branchName = branchName;
25
+ this.path = path;
26
+ this.repositories = repositories;
27
+ this.active = active;
28
+ }
29
+ isRootBranch() {
30
+ return !this.isFeatureBranch() && !this.isFixBranch() && !this.isReleaseBranch();
31
+ }
32
+ isFeatureBranch() {
33
+ return this.branchName.startsWith(this.context.options.git.featureBranchPrefix);
34
+ }
35
+ isFixBranch() {
36
+ return this.branchName.startsWith(this.context.options.git.fixBranchPrefix);
37
+ }
38
+ isReleaseBranch() {
39
+ return this.branchName.startsWith(this.context.options.git.releaseBranchPrefix);
40
+ }
41
+ static async loadFromPath(context, branchName, branchRootPath) {
42
+ // search folder in the BranchRootPath that matches a context repository name
43
+ if ((await pathExists(branchRootPath)) === false) {
44
+ throw new Error(`Branch root path does not exist: ${branchRootPath}`);
45
+ }
46
+ const repositories = await BranchContext.loadWorktreeRepositories(context, branchRootPath);
47
+ return new BranchContext(context, branchName, branchRootPath, repositories, true);
48
+ }
49
+ static async loadWorktreeRepositories(context, branchRootPath) {
50
+ const items = await readdir(branchRootPath, { withFileTypes: true });
51
+ const repoDirs = items.filter((item) => item.isDirectory() && context.repositories.some((repo) => repo.name === item.name));
52
+ if (repoDirs.length === 0) {
53
+ throw new Error(`No repository folder found in Branch path: ${branchRootPath}`);
54
+ }
55
+ const repositories = repoDirs.map((dir) => {
56
+ const rootRepo = context.repositories.find((r) => r.name === dir.name);
57
+ return new WorktreeRepository(context, { name: rootRepo.name, path: path.join(branchRootPath, dir.name), main: rootRepo.main }, rootRepo);
58
+ });
59
+ return repositories;
60
+ }
61
+ static async findNearestBranchContext(context, startDir = process.cwd()) {
62
+ const currentDir = path.resolve(startDir);
63
+ const worktreesRoot = path.resolve(context.paths.worktreesRoot);
64
+ const matchingBranch = (await context.mainRepo.getBranches()).find((branchName) => {
65
+ const branchWorktreePath = context.paths.getBranchRootPath(branchName);
66
+ if (currentDir.startsWith(branchWorktreePath)) {
67
+ return true;
68
+ }
69
+ return false;
70
+ });
71
+ if (!matchingBranch) {
72
+ throw new ForgeNotInActiveBranchError(`No active Branch context found for current directory: ${currentDir}`);
73
+ }
74
+ // If we found a matching branch, we can directly load the Branch context from its worktree path
75
+ const branchRootPath = path.join(worktreesRoot, branchNameAsPath(matchingBranch));
76
+ return BranchContext.loadFromPath(context, matchingBranch, branchRootPath);
77
+ }
78
+ mustBeActive() {
79
+ if (!this.active) {
80
+ throw new Error('This operation requires an active Branch context.');
81
+ }
82
+ }
83
+ get folders() {
84
+ return this.context.options.folders;
85
+ }
86
+ get files() {
87
+ return this.context.options.files;
88
+ }
89
+ get branchNameAsPath() {
90
+ return branchNameAsPath(this.branchName);
91
+ }
92
+ get branchNameSlug() {
93
+ return slugify(this.branchName, false, false);
94
+ }
95
+ get mainRepo() {
96
+ return this.repositories.find((repo) => repo.main);
97
+ }
98
+ get secondaryRepos() {
99
+ return this.repositories.filter((repo) => !repo.main);
100
+ }
101
+ get modeFilePath() {
102
+ return path.join(this.path, this.context.options.files.forgeMode);
103
+ }
104
+ get activeSpecPath() {
105
+ return path.join(this.path, this.folders.activeSpec);
106
+ }
107
+ getInPath(...segments) {
108
+ return path.join(this.path, ...segments);
109
+ }
110
+ hasRepo(repoName) {
111
+ return this.repositories.some((repo) => repo.name === repoName);
112
+ }
113
+ getRepo(repoName) {
114
+ if (!this.hasRepo(repoName))
115
+ throw new Error(`Repository ${repoName} is not part of the Branch context`);
116
+ return this.repositories.find((r) => r.name === repoName);
117
+ }
118
+ async getTemporaryRepo(repoName, type) {
119
+ const rootRepo = this.context.getRepo(repoName);
120
+ const tempRepo = await rootRepo.getTemporaryWorktree(this.branchName, type);
121
+ this.repositories.push(tempRepo);
122
+ return tempRepo;
123
+ }
124
+ getTemplatePath(...segments) {
125
+ return this.mainRepo.getTemplatePath(...segments);
126
+ }
127
+ getAgentTemplatePath(...segments) {
128
+ return this.mainRepo.getAgentTemplatePath(...segments);
129
+ }
130
+ async hasModeFile() {
131
+ return pathExists(this.modeFilePath);
132
+ }
133
+ async getMode() {
134
+ const modeFile = this.modeFilePath;
135
+ if (!(await this.hasModeFile())) {
136
+ throw new Error(`Mode file not found for branch ${this.branchName}. Expected at: ${modeFile}`);
137
+ }
138
+ const raw = (await readTextFile(modeFile)).trim().toLowerCase();
139
+ return this.context.getMode(raw);
140
+ }
141
+ async setMode(mode) {
142
+ this.context.modeExistsOrThrow(mode); // will throw if mode does not exist in config
143
+ const modeFile = this.modeFilePath;
144
+ await writeTextFile(modeFile, `${mode}\n`);
145
+ }
146
+ async initMode(mode) {
147
+ if (!(await this.hasModeFile())) {
148
+ mode = mode ?? this.context.getDefaultMode().name;
149
+ this.context.modeExistsOrThrow(mode); // will throw if mode does not exist in config
150
+ await this.setMode(mode);
151
+ }
152
+ }
153
+ async deleteBranch() {
154
+ // do not allow deletion of main,master,etc branches by forge
155
+ if (this.context.options.git.protectedBranches.includes(this.branchName)) {
156
+ throw new Error(`Branch ${this.branchName} is protected and cannot be deleted.`);
157
+ }
158
+ // Must be done on all repositories, not only the one from the Branch
159
+ // It also makes more sense to execute this command on the "root" repositories
160
+ // Note: This will not work if the branch is still used by a worktree
161
+ await Promise.all(this.context.repositories.map((repo) => repo.deleteBranch(this.branchName)));
162
+ }
163
+ async getDirtyRepositories() {
164
+ this.mustBeActive();
165
+ const dirtyRepositories = [];
166
+ for (const repo of this.repositories) {
167
+ const status = await getGitStatusPorcelain(repo.path);
168
+ if (status.length > 0) {
169
+ dirtyRepositories.push(repo);
170
+ }
171
+ }
172
+ return dirtyRepositories;
173
+ }
174
+ async refreshAgentContextFiles(mode) {
175
+ const { agents } = this.context;
176
+ const modeConfig = mode ? this.context.getMode(mode) : await this.getMode();
177
+ // Create the root [AGENTNAME].md files in the Branch's
178
+ await Promise.all(agents.map(async (agent) => {
179
+ const targetFile = this.getInPath(agent.agentFile);
180
+ let content = await resolveAgentFileCustomTemplate(this.context, this.mainRepo, modeConfig.agentFile);
181
+ content = replaceTemplateMarkers(content, this.context, this);
182
+ console.log('Refreshing agent context file for agent', agent.name, 'at', targetFile);
183
+ return writeTextFile(targetFile, content);
184
+ }));
185
+ // Create the agent context files specific to one agent
186
+ await Promise.all(agents.map(async (agent) => {
187
+ switch (agent.name) {
188
+ case AIAgentName.COPILOT:
189
+ // Copilot specific a .github/agents/xxxxxx.agent.md
190
+ await refreshCopilotAgentContextFiles(this.context, this);
191
+ break;
192
+ }
193
+ }));
194
+ // If agentsInEachRepo is true
195
+ // also create the [AGENTNAME].md files in each repository .forge-agents/ folder, so that they can be run locally to the branch
196
+ // Note: The idea of the .forge-agents/ folder is to not mess up with repository root [AGENTNAME].md files that might be used for other purposes
197
+ if (this.context.options.process.agentsInEachRepo) {
198
+ await Promise.all(this.repositories.map(async (repo) => {
199
+ return Promise.all(agents.map(async (agent) => {
200
+ const targetFile = repo.getAgentPath(agent.agentFile);
201
+ let content = await resolveAgentFileCustomTemplate(this.context, this.mainRepo, modeConfig.agentFile);
202
+ content = replaceTemplateMarkers(content, this.context, this, repo);
203
+ console.log('Refreshing agent context file for agent', agent.name, 'at', targetFile);
204
+ return writeTextFile(targetFile, content);
205
+ }));
206
+ }));
207
+ }
208
+ // Execute postRefreshAgents hooks after agent context files are refreshed
209
+ await this.executeHook(HookEvent.POST_REFRESH_AGENTS, { branch: this.branchName });
210
+ }
211
+ /**
212
+ * Ensure :
213
+ * - .active-spec (or the cusomized name) is in .gitignore for one or more repo roots.
214
+ * - .forge-agents/* is in .gitignore for one or more repo roots.
215
+ */
216
+ async ensureGitIgnore(repository) {
217
+ let changes = 0;
218
+ const gitignorePath = path.join(repository.path, '.gitignore');
219
+ changes += await ensureLineInFile(gitignorePath, this.folders.activeSpec);
220
+ changes += await ensureLineInFile(gitignorePath, this.folders.repoAgents);
221
+ if (changes > 0) {
222
+ await repository.commit(`chore: add forge specific files to .gitignore`, [gitignorePath]);
223
+ }
224
+ return changes;
225
+ }
226
+ async initBranch() {
227
+ let hiddenChanges = 0; // Changes were made on temporary worktrees
228
+ for (const rootRepo of this.context.repositories) {
229
+ let worktreeRepo;
230
+ if (this.hasRepo(rootRepo.name)) {
231
+ // worktree already exists, we can use it to check for spec files without affecting the main branch
232
+ worktreeRepo = this.getRepo(rootRepo.name);
233
+ }
234
+ else {
235
+ worktreeRepo = await this.getTemporaryRepo(rootRepo.name, TemporaryFolderType.BRANCH_INIT);
236
+ }
237
+ // Check for spec files in the main repository
238
+ if (worktreeRepo.isMainRepository()) {
239
+ const fileChanges = await this.ensureBranchSpecFiles(worktreeRepo, this.branchName);
240
+ if (fileChanges.length) {
241
+ await worktreeRepo.commit(`docs(${this.branchName}): init branch spec files`, fileChanges);
242
+ if (worktreeRepo.temporary) {
243
+ hiddenChanges += fileChanges.length;
244
+ }
245
+ }
246
+ }
247
+ if (this.context.options.process.manageGitIgnore) {
248
+ const gitIgnoreChanges = await this.ensureGitIgnore(worktreeRepo);
249
+ if (gitIgnoreChanges > 0 && worktreeRepo.temporary) {
250
+ hiddenChanges += gitIgnoreChanges;
251
+ }
252
+ }
253
+ if (worktreeRepo.temporary) {
254
+ await worktreeRepo.remove();
255
+ }
256
+ }
257
+ return hiddenChanges;
258
+ }
259
+ /**
260
+ * Ensure the spec files exist for a branch directory without overwriting existing files.
261
+ * Also creates the agent subdirectory (empty, ready for symlinks).
262
+ */
263
+ async ensureBranchSpecFiles(repo, branchName) {
264
+ repo.mustBeMainRepository();
265
+ let fileChanges = [];
266
+ const branchSpecPath = repo.getSpecPath(branchName);
267
+ await ensureDir(branchSpecPath);
268
+ // Create main spec files
269
+ for (const fileName of this.context.config.specFiles) {
270
+ const filePath = path.join(branchSpecPath, fileName);
271
+ if (await pathExists(filePath)) {
272
+ continue;
273
+ }
274
+ const content = await resolveSpecFileCustomTemplate(this.context, repo, fileName);
275
+ await writeTextFile(filePath, content);
276
+ fileChanges.push(filePath);
277
+ }
278
+ return fileChanges;
279
+ }
280
+ async start() {
281
+ const branchRoot = this.path;
282
+ await ensureDir(branchRoot);
283
+ this.repositories = await this.ensureWorktrees();
284
+ // Set active Branch pointer
285
+ await this.setActiveSpec();
286
+ // Set initial mode to spec if not defined
287
+ await this.initMode(); // default to spec mode on start
288
+ // Refresh agent context files based on mode (will create symlinks to template files or user overrides)
289
+ await this.refreshAgentContextFiles();
290
+ // Create IDE workspaces if configured
291
+ if (this.context.ides.length > 0) {
292
+ await createIDEWorkspaces(this.branchName, this.path, this.mainRepo.name, this.repositories, this.context.ides, this.context.agents);
293
+ }
294
+ // Execute bootstrap scripts in each repository
295
+ for (const repository of this.repositories) {
296
+ try {
297
+ // Copy configured files from root repos to worktrees
298
+ await repository.rootRepository.copyFilesToWorktree(repository);
299
+ // Execute bootstrap script if defined in the repository
300
+ await repository.executeBootstrapScript();
301
+ }
302
+ catch (error) {
303
+ // Soft error here, so that the Branch can still be started even if a bootstrap script fails, which can be useful for debugging or if the bootstrap script is not critical
304
+ console.error(`❌ Failed to execute bootstrap script in ${repository.name}`, error);
305
+ }
306
+ }
307
+ // Execute postBranchStart hooks in each repository
308
+ await this.executeHook(HookEvent.POST_START, { branch: this.branchName });
309
+ this.active = true;
310
+ }
311
+ /**
312
+ * Execute hooks for a specific event across all repositories in this branch.
313
+ * Soft errors are used so that the branch operation can continue even if a hook fails.
314
+ *
315
+ * Hook parameters are passed to scripts as environment variables with FORGE_HOOK_ prefix.
316
+ * Example: params { sourceBranch: 'feature/xyz' } becomes FORGE_HOOK_SOURCEBRANCH
317
+ *
318
+ * @param eventType - Type of hook event to execute
319
+ * @param params - Optional parameters to pass to hooks as environment variables
320
+ */
321
+ async executeHook(eventType, params) {
322
+ for (const repository of this.repositories) {
323
+ try {
324
+ const executedHooks = await repository.executeHook(eventType, params);
325
+ if (executedHooks.length > 0) {
326
+ console.log(`📌 Executed ${executedHooks.length} ${eventType} hook(s) in ${repository.name}: ${executedHooks.join(', ')}`);
327
+ }
328
+ }
329
+ catch (error) {
330
+ // Soft error: allow the branch operation to continue even if a hook fails
331
+ console.error(`❌ Failed to execute ${eventType} hooks in ${repository.name}`, error);
332
+ }
333
+ }
334
+ }
335
+ async ensureWorktrees() {
336
+ return Promise.all(this.context.repositories.map((repo) => {
337
+ return this.ensureWorktreeForRepo(repo);
338
+ }));
339
+ }
340
+ async ensureWorktreeForRepo(repo) {
341
+ if (await repo.hasWorktree(this.branchName)) {
342
+ return repo.getWorktree(this.branchName);
343
+ }
344
+ else {
345
+ return repo.addWorktree(this.branchName);
346
+ }
347
+ }
348
+ async setActiveSpec() {
349
+ // Create .active-spec symlink in the Branch root pointing to the current spec file in the main repository worktree
350
+ const specRealPath = this.mainRepo.getSpecPath(this.branchName);
351
+ const activeSpecPath = this.activeSpecPath;
352
+ await rm(activeSpecPath, { force: true });
353
+ await symlink(path.relative(path.dirname(activeSpecPath), specRealPath), activeSpecPath);
354
+ // If activeSpecInEachRepo is true, also create/update .active-spec symlink in each repository worktree pointing to the same spec file in the main repository worktree
355
+ if (this.context.options.process.activeSpecInEachRepo) {
356
+ await Promise.all(this.repositories.map((repo) => repo.setActiveSpec(this)));
357
+ }
358
+ }
359
+ async collectRepositoriesStatus() {
360
+ const status = {};
361
+ for (const repo of this.repositories) {
362
+ status[repo.name] = await repo.getStatus(this.branchName);
363
+ }
364
+ return status;
365
+ }
366
+ async stop() {
367
+ // Execute preStop hooks before stopping the branch
368
+ await this.executeHook(HookEvent.PRE_STOP, { branch: this.branchName });
369
+ // Check for uncommitted changes in worktrees
370
+ const dirtyRepositories = await this.getDirtyRepositories();
371
+ // Handle dirty worktrees if any exist
372
+ if (dirtyRepositories.length > 0) {
373
+ for (const repo of dirtyRepositories) {
374
+ const repoShouldProceed = await repo.promptDirtyActions();
375
+ if (!repoShouldProceed) {
376
+ return;
377
+ }
378
+ }
379
+ }
380
+ }
381
+ async delete() {
382
+ // Execute preDelete hooks before deleting the branch
383
+ await this.executeHook(HookEvent.PRE_DELETE, { branch: this.branchName });
384
+ // Remove worktrees for all repositories
385
+ await Promise.all(this.repositories.map((repo) => repo.remove()));
386
+ if (await pathExists(this.path)) {
387
+ if ((await readdir(this.path)).length !== 0) {
388
+ // Remove Branch path if empty
389
+ const confirm = await promptConfirm(`Branch path ${this.path} is not empty. Do you want to remove it?`);
390
+ if (!confirm) {
391
+ console.log(`Please manually clean up the Branch path: ${this.path}`);
392
+ return;
393
+ }
394
+ }
395
+ await rm(this.path, { recursive: true, force: true });
396
+ }
397
+ this.active = false;
398
+ }
399
+ async archive() {
400
+ await this.executeHook(HookEvent.PRE_ARCHIVE, { branch: this.branchName });
401
+ let worktreeRepo;
402
+ if (this.hasRepo(this.mainRepo.name)) {
403
+ // worktree already exists, we can use it to create the Branch files without affecting the main branch
404
+ worktreeRepo = this.getRepo(this.mainRepo.name);
405
+ }
406
+ else {
407
+ // create a temporary worktree to move the Branch files without affecting the main branch
408
+ worktreeRepo = await this.getTemporaryRepo(this.mainRepo.name, TemporaryFolderType.BRANCH_ARCHIVE);
409
+ }
410
+ const archivePath = path.join(worktreeRepo.specsArchivePath, branchNameAsPath(this.branchName));
411
+ const branchPath = worktreeRepo.getSpecPath(this.branchName);
412
+ // Create archives directory
413
+ await ensureDir(path.dirname(archivePath));
414
+ // Move Branch to archive using git mv
415
+ await runGit(worktreeRepo.path, ['mv', branchPath, archivePath]);
416
+ // Commit the archive
417
+ await worktreeRepo.commit(`docs(${this.branchName}): archive Branch`, [archivePath]);
418
+ console.log(`✓ Branch \"${this.branchName}\" archived successfully.`);
419
+ console.log(` Moved: ${this.context.options.folders.specs}/${this.branchName}/ → ${this.context.options.folders.specs}/${this.context.options.folders.archive}/${this.branchName}/`);
420
+ // Ask for merge
421
+ const confirmMerge = await promptConfirm('Do you want to merge the archived branch to another branch? (recommended to keep trace in main/dev branch)');
422
+ if (confirmMerge) {
423
+ const targetBranch = await promptForBranch(this.context.mainRepo.path, 'Select target branch to merge archived branch:', this.context.options.git.featureBranchPrefix, false);
424
+ await worktreeRepo.rootRepository.merge(this.branchName, targetBranch);
425
+ }
426
+ }
427
+ }
@@ -0,0 +1,264 @@
1
+ import path from 'path';
2
+ import { FEAT_FORGE_CONFIG_FILE } from '../lib/constants.js';
3
+ import { merge } from '../lib/merger.js';
4
+ import { ForgeConfigError } from './errors/index.js';
5
+ import { AIAgentName } from './types/AIAgentName.js';
6
+ import { IDEName } from './types/IDEName.js';
7
+ import { ForgeOptions, } from './ForgeConfigFile.js';
8
+ export const DEFAULT_SPEC_FILES = ['SPEC.md', 'TODO.md'];
9
+ export const DEFAULT_MODES = [
10
+ {
11
+ name: 'general',
12
+ agentFile: '001.general.Omnibus.agent.md',
13
+ description: 'Default mode with no special behavior',
14
+ },
15
+ {
16
+ name: 'discovery',
17
+ agentFile: '002.discovery.Inventorius.agent.md',
18
+ description: 'Help to define a functional perimeter for a feature',
19
+ default: true,
20
+ },
21
+ {
22
+ name: 'design',
23
+ agentFile: '003.design.Architecturius.agent.md',
24
+ description: 'Help to define an architecture for a feature',
25
+ },
26
+ {
27
+ name: 'plan',
28
+ agentFile: '004.plan.Strategos.agent.md',
29
+ description: 'Generate a plan for a feature, in SPEC.md & TODO.md by default',
30
+ },
31
+ {
32
+ name: 'tdd',
33
+ agentFile: '005.tdd.TestDrivenCodificius.agent.md',
34
+ description: 'Test Driven Development agent to implement the feature',
35
+ },
36
+ {
37
+ name: 'code',
38
+ agentFile: '006.code.Codificius.agent.md',
39
+ description: 'Code agent to implement the feature',
40
+ },
41
+ {
42
+ name: 'simplify',
43
+ agentFile: '007.simplify.Consolidarius.agent.md',
44
+ description: 'Simplify and consolidate the feature',
45
+ },
46
+ {
47
+ name: 'review',
48
+ agentFile: '008.review.Auditorix.agent.md',
49
+ description: 'Review the feature and ensure code guidelines',
50
+ },
51
+ {
52
+ name: 'test-writer',
53
+ agentFile: '009.testwriter.TestScriptor.agent.md',
54
+ description: 'Write unit/functional tests',
55
+ },
56
+ {
57
+ name: 'test-executor',
58
+ agentFile: '010.testexecutor.TestExecutor.agent.md',
59
+ description: 'Execute unit/functional tests',
60
+ },
61
+ {
62
+ name: 'commit',
63
+ agentFile: '011.commit.Scribus.agent.md',
64
+ description: 'Propose commit message based changes related to the feature and then commit',
65
+ },
66
+ ];
67
+ export class ForgeConfig {
68
+ rootDir;
69
+ repositories;
70
+ agents;
71
+ ides;
72
+ options;
73
+ specFiles;
74
+ modes;
75
+ constructor(configPath, configFile) {
76
+ this.rootDir = path.resolve(configFile.rootDir ? path.resolve(configPath, configFile.rootDir) : configPath);
77
+ this.specFiles = configFile.specFiles || DEFAULT_SPEC_FILES;
78
+ // standardize config entries
79
+ this.modes = this.standardizeModes(configFile.modes || DEFAULT_MODES);
80
+ this.repositories = this.standardizeRepositories(configFile.repositories);
81
+ this.ides = this.standardizeIDEs(configFile.ides);
82
+ this.agents = this.standardizeAgents(configFile.agents);
83
+ this.options = this.standardizeOptions(configFile.options);
84
+ }
85
+ getPath(...segments) {
86
+ return path.resolve(this.rootDir, ...segments); // validate path segments
87
+ }
88
+ standardizeModes(rawModes) {
89
+ if (!Array.isArray(rawModes)) {
90
+ throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: "modes" must be an array.`);
91
+ }
92
+ if (rawModes.length === 0) {
93
+ throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: At least one mode must be defined in the "modes" array.`);
94
+ }
95
+ const modes = rawModes.map((entry) => {
96
+ const agentFile = entry.agentFile?.trim();
97
+ if (!agentFile) {
98
+ throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: Each mode entry must have a non-empty "agentFile" property.`);
99
+ }
100
+ const name = entry.name?.trim() || agentFile.replace(/\.[^/.]+$/, ''); // default name from agentFile if not provided
101
+ if (!name) {
102
+ throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: Each mode entry must have a non-empty "name" or valid "agentFile" property.`);
103
+ }
104
+ return {
105
+ name,
106
+ agentFile,
107
+ description: entry.description?.trim(),
108
+ default: !!entry.default,
109
+ };
110
+ });
111
+ ForgeConfig.checkDefaultMode(modes);
112
+ return modes;
113
+ }
114
+ standardizeRepositories(repos) {
115
+ const repositories = repos
116
+ .map((entry) => {
117
+ if (typeof entry === 'string') {
118
+ return {
119
+ path: entry,
120
+ };
121
+ }
122
+ else
123
+ return entry;
124
+ })
125
+ .map((repositoryConfig) => {
126
+ if (typeof repositoryConfig.path !== 'string' || repositoryConfig.path.trim() === '') {
127
+ throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: Repository entries must have a non-empty "path".`);
128
+ }
129
+ const rootPath = repositoryConfig.path.trim();
130
+ return {
131
+ name: repositoryConfig.name ?? path.basename(rootPath),
132
+ path: this.getPath(rootPath),
133
+ main: !!(repositoryConfig.main ?? false),
134
+ copyFiles: repositoryConfig.copyFiles ?? [],
135
+ };
136
+ });
137
+ ForgeConfig.checkMainRepo(repositories);
138
+ return repositories;
139
+ }
140
+ standardizeIDEs(ides) {
141
+ if (!ides) {
142
+ return [];
143
+ }
144
+ if (!Array.isArray(ides)) {
145
+ throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: "ides" must be an array.`);
146
+ }
147
+ return ides
148
+ .map((entry) => {
149
+ if (typeof entry === 'string') {
150
+ return {
151
+ name: entry,
152
+ createWorkspace: true,
153
+ };
154
+ }
155
+ else
156
+ return entry;
157
+ })
158
+ .map((ideConfig) => {
159
+ // Full config object
160
+ const ideName = Object.values(IDEName).find((name) => name === ideConfig.name);
161
+ if (!ideName) {
162
+ throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: IDE config objects must have a valid "name" property.`);
163
+ }
164
+ return {
165
+ name: ideName,
166
+ createWorkspace: ideConfig.createWorkspace ?? true,
167
+ settings: ideConfig.settings ?? {},
168
+ openCommand: ideConfig.openCommand,
169
+ };
170
+ });
171
+ }
172
+ standardizeAgents(agents) {
173
+ if (!agents) {
174
+ return [
175
+ {
176
+ name: null,
177
+ agentFile: 'AGENTS.md',
178
+ requiresIDEConfig: false,
179
+ },
180
+ ];
181
+ }
182
+ if (!Array.isArray(agents)) {
183
+ throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: "agents" must be an array.`);
184
+ }
185
+ return agents
186
+ .map((entry) => {
187
+ if (typeof entry === 'string') {
188
+ return {
189
+ name: entry,
190
+ };
191
+ }
192
+ else
193
+ return entry;
194
+ })
195
+ .map((agentConfig) => {
196
+ const name = agentConfig.name?.trim() ?? null;
197
+ let agentFile = agentConfig.agentFile?.trim() ?? null;
198
+ if (!name && !agentFile) {
199
+ throw new ForgeConfigError(`Invalid ${FEAT_FORGE_CONFIG_FILE}: Agent config objects must have either a valid "name" or an "agentFile" property.`);
200
+ }
201
+ if (!agentFile) {
202
+ agentFile = ForgeConfig.getAgentFile(name);
203
+ }
204
+ return {
205
+ name,
206
+ agentFile,
207
+ requiresIDEConfig: name ? ForgeConfig.agentRequiresIDEConfig(name) : false,
208
+ settings: agentConfig.settings,
209
+ };
210
+ });
211
+ }
212
+ standardizeOptions(options) {
213
+ return merge(new ForgeOptions(), options || {});
214
+ }
215
+ static checkDefaultMode(modes) {
216
+ const defaultModes = modes.filter((mode) => mode.default);
217
+ if (defaultModes.length > 1) {
218
+ throw new ForgeConfigError('Multiple modes cannot be marked as default in the configuration.');
219
+ }
220
+ if (defaultModes.length === 0) {
221
+ modes[0].default = true; // set first mode as default if none marked
222
+ }
223
+ }
224
+ static checkMainRepo(repositories) {
225
+ if (repositories.filter((repo) => repo.main).length > 1) {
226
+ throw new ForgeConfigError('Multiple repositories cannot be marked as main in the configuration.');
227
+ }
228
+ let mainRepo = repositories.find((repo) => repo.main);
229
+ if (!mainRepo) {
230
+ if (repositories.length > 0) {
231
+ repositories[0].main = true;
232
+ }
233
+ else {
234
+ throw new ForgeConfigError('At least one repository must be defined in the configuration.');
235
+ }
236
+ }
237
+ }
238
+ /**
239
+ * Get default agent file name for known agents
240
+ */
241
+ static getAgentFile(agentName) {
242
+ const agentFiles = {
243
+ [AIAgentName.CLAUDE]: 'CLAUDE.md',
244
+ [AIAgentName.COPILOT]: 'AGENTS.md',
245
+ [AIAgentName.GEMINI]: 'GEMINI.md',
246
+ [AIAgentName.CODEX]: 'AGENTS.md',
247
+ [AIAgentName.CURSOR]: 'AGENTS.md',
248
+ };
249
+ return agentFiles[agentName] || 'AGENTS.md';
250
+ }
251
+ /**
252
+ * Check if agent requires IDE configuration
253
+ */
254
+ static agentRequiresIDEConfig(agentName) {
255
+ const agentRequiringIDEConfig = {
256
+ [AIAgentName.COPILOT]: true,
257
+ [AIAgentName.CURSOR]: true,
258
+ [AIAgentName.CLAUDE]: false,
259
+ [AIAgentName.GEMINI]: false,
260
+ [AIAgentName.CODEX]: false,
261
+ };
262
+ return !!agentRequiringIDEConfig[agentName];
263
+ }
264
+ }