@entelligentsia/forgecli 0.8.4 → 0.9.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 (170) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +165 -2
  3. package/dist/bin/argv.d.ts +2 -2
  4. package/dist/bin/argv.js +17 -0
  5. package/dist/bin/argv.js.map +1 -1
  6. package/dist/bin/config.d.ts +69 -0
  7. package/dist/bin/config.js +315 -0
  8. package/dist/bin/config.js.map +1 -0
  9. package/dist/bin/doctor.d.ts +1 -0
  10. package/dist/bin/doctor.js +12 -0
  11. package/dist/bin/doctor.js.map +1 -1
  12. package/dist/bin/forge.js +7 -0
  13. package/dist/bin/forge.js.map +1 -1
  14. package/dist/extensions/forgecli/config-command.d.ts +8 -0
  15. package/dist/extensions/forgecli/config-command.js +66 -0
  16. package/dist/extensions/forgecli/config-command.js.map +1 -0
  17. package/dist/extensions/forgecli/config-layer.d.ts +38 -0
  18. package/dist/extensions/forgecli/config-layer.js +68 -0
  19. package/dist/extensions/forgecli/config-layer.js.map +1 -0
  20. package/dist/extensions/forgecli/config-tui/component.d.ts +35 -0
  21. package/dist/extensions/forgecli/config-tui/component.js +236 -0
  22. package/dist/extensions/forgecli/config-tui/component.js.map +1 -0
  23. package/dist/extensions/forgecli/config-tui/handler.d.ts +40 -0
  24. package/dist/extensions/forgecli/config-tui/handler.js +240 -0
  25. package/dist/extensions/forgecli/config-tui/handler.js.map +1 -0
  26. package/dist/extensions/forgecli/config-tui/index.d.ts +5 -0
  27. package/dist/extensions/forgecli/config-tui/index.js +5 -0
  28. package/dist/extensions/forgecli/config-tui/index.js.map +1 -0
  29. package/dist/extensions/forgecli/config-tui/keys.d.ts +26 -0
  30. package/dist/extensions/forgecli/config-tui/keys.js +33 -0
  31. package/dist/extensions/forgecli/config-tui/keys.js.map +1 -0
  32. package/dist/extensions/forgecli/config-tui/plugin-config-reader.d.ts +23 -0
  33. package/dist/extensions/forgecli/config-tui/plugin-config-reader.js +58 -0
  34. package/dist/extensions/forgecli/config-tui/plugin-config-reader.js.map +1 -0
  35. package/dist/extensions/forgecli/config-tui/screens/advanced-menu.d.ts +7 -0
  36. package/dist/extensions/forgecli/config-tui/screens/advanced-menu.js +83 -0
  37. package/dist/extensions/forgecli/config-tui/screens/advanced-menu.js.map +1 -0
  38. package/dist/extensions/forgecli/config-tui/screens/confirm-quit.d.ts +11 -0
  39. package/dist/extensions/forgecli/config-tui/screens/confirm-quit.js +54 -0
  40. package/dist/extensions/forgecli/config-tui/screens/confirm-quit.js.map +1 -0
  41. package/dist/extensions/forgecli/config-tui/screens/override-editor.d.ts +11 -0
  42. package/dist/extensions/forgecli/config-tui/screens/override-editor.js +233 -0
  43. package/dist/extensions/forgecli/config-tui/screens/override-editor.js.map +1 -0
  44. package/dist/extensions/forgecli/config-tui/screens/overrides-list-phases.d.ts +7 -0
  45. package/dist/extensions/forgecli/config-tui/screens/overrides-list-phases.js +91 -0
  46. package/dist/extensions/forgecli/config-tui/screens/overrides-list-phases.js.map +1 -0
  47. package/dist/extensions/forgecli/config-tui/screens/overrides-list.d.ts +7 -0
  48. package/dist/extensions/forgecli/config-tui/screens/overrides-list.js +71 -0
  49. package/dist/extensions/forgecli/config-tui/screens/overrides-list.js.map +1 -0
  50. package/dist/extensions/forgecli/config-tui/screens/persona-editor.d.ts +10 -0
  51. package/dist/extensions/forgecli/config-tui/screens/persona-editor.js +182 -0
  52. package/dist/extensions/forgecli/config-tui/screens/persona-editor.js.map +1 -0
  53. package/dist/extensions/forgecli/config-tui/screens/persona-picker.d.ts +7 -0
  54. package/dist/extensions/forgecli/config-tui/screens/persona-picker.js +76 -0
  55. package/dist/extensions/forgecli/config-tui/screens/persona-picker.js.map +1 -0
  56. package/dist/extensions/forgecli/config-tui/screens/personas-list.d.ts +7 -0
  57. package/dist/extensions/forgecli/config-tui/screens/personas-list.js +98 -0
  58. package/dist/extensions/forgecli/config-tui/screens/personas-list.js.map +1 -0
  59. package/dist/extensions/forgecli/config-tui/screens/shared.d.ts +29 -0
  60. package/dist/extensions/forgecli/config-tui/screens/shared.js +100 -0
  61. package/dist/extensions/forgecli/config-tui/screens/shared.js.map +1 -0
  62. package/dist/extensions/forgecli/config-tui/screens/show-resolved.d.ts +23 -0
  63. package/dist/extensions/forgecli/config-tui/screens/show-resolved.js +128 -0
  64. package/dist/extensions/forgecli/config-tui/screens/show-resolved.js.map +1 -0
  65. package/dist/extensions/forgecli/config-tui/screens/tier-menu.d.ts +7 -0
  66. package/dist/extensions/forgecli/config-tui/screens/tier-menu.js +135 -0
  67. package/dist/extensions/forgecli/config-tui/screens/tier-menu.js.map +1 -0
  68. package/dist/extensions/forgecli/config-tui/screens/tier-picker.d.ts +9 -0
  69. package/dist/extensions/forgecli/config-tui/screens/tier-picker.js +122 -0
  70. package/dist/extensions/forgecli/config-tui/screens/tier-picker.js.map +1 -0
  71. package/dist/extensions/forgecli/config-tui/screens/types.d.ts +24 -0
  72. package/dist/extensions/forgecli/config-tui/screens/types.js +5 -0
  73. package/dist/extensions/forgecli/config-tui/screens/types.js.map +1 -0
  74. package/dist/extensions/forgecli/config-tui/screens.d.ts +24 -0
  75. package/dist/extensions/forgecli/config-tui/screens.js +78 -0
  76. package/dist/extensions/forgecli/config-tui/screens.js.map +1 -0
  77. package/dist/extensions/forgecli/config-tui/state/buffer.d.ts +11 -0
  78. package/dist/extensions/forgecli/config-tui/state/buffer.js +91 -0
  79. package/dist/extensions/forgecli/config-tui/state/buffer.js.map +1 -0
  80. package/dist/extensions/forgecli/config-tui/state/constants.d.ts +4 -0
  81. package/dist/extensions/forgecli/config-tui/state/constants.js +14 -0
  82. package/dist/extensions/forgecli/config-tui/state/constants.js.map +1 -0
  83. package/dist/extensions/forgecli/config-tui/state/index.d.ts +6 -0
  84. package/dist/extensions/forgecli/config-tui/state/index.js +9 -0
  85. package/dist/extensions/forgecli/config-tui/state/index.js.map +1 -0
  86. package/dist/extensions/forgecli/config-tui/state/init.d.ts +2 -0
  87. package/dist/extensions/forgecli/config-tui/state/init.js +30 -0
  88. package/dist/extensions/forgecli/config-tui/state/init.js.map +1 -0
  89. package/dist/extensions/forgecli/config-tui/state/model.d.ts +192 -0
  90. package/dist/extensions/forgecli/config-tui/state/model.js +4 -0
  91. package/dist/extensions/forgecli/config-tui/state/model.js.map +1 -0
  92. package/dist/extensions/forgecli/config-tui/state/reducer.d.ts +2 -0
  93. package/dist/extensions/forgecli/config-tui/state/reducer.js +212 -0
  94. package/dist/extensions/forgecli/config-tui/state/reducer.js.map +1 -0
  95. package/dist/extensions/forgecli/config-tui/state/selectors.d.ts +91 -0
  96. package/dist/extensions/forgecli/config-tui/state/selectors.js +231 -0
  97. package/dist/extensions/forgecli/config-tui/state/selectors.js.map +1 -0
  98. package/dist/extensions/forgecli/config-tui/state.d.ts +6 -0
  99. package/dist/extensions/forgecli/config-tui/state.js +11 -0
  100. package/dist/extensions/forgecli/config-tui/state.js.map +1 -0
  101. package/dist/extensions/forgecli/config-tui/theme.d.ts +37 -0
  102. package/dist/extensions/forgecli/config-tui/theme.js +88 -0
  103. package/dist/extensions/forgecli/config-tui/theme.js.map +1 -0
  104. package/dist/extensions/forgecli/config-tui/tier-meta.d.ts +28 -0
  105. package/dist/extensions/forgecli/config-tui/tier-meta.js +69 -0
  106. package/dist/extensions/forgecli/config-tui/tier-meta.js.map +1 -0
  107. package/dist/extensions/forgecli/config-writer.d.ts +16 -0
  108. package/dist/extensions/forgecli/config-writer.js +63 -0
  109. package/dist/extensions/forgecli/config-writer.js.map +1 -0
  110. package/dist/extensions/forgecli/fix-bug.js +85 -1
  111. package/dist/extensions/forgecli/fix-bug.js.map +1 -1
  112. package/dist/extensions/forgecli/forge-cli-schema.json +54 -0
  113. package/dist/extensions/forgecli/forge-commands.js +3 -8
  114. package/dist/extensions/forgecli/forge-commands.js.map +1 -1
  115. package/dist/extensions/forgecli/forge-subagent.d.ts +13 -0
  116. package/dist/extensions/forgecli/forge-subagent.js +19 -0
  117. package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
  118. package/dist/extensions/forgecli/index.js +16 -0
  119. package/dist/extensions/forgecli/index.js.map +1 -1
  120. package/dist/extensions/forgecli/input-router.d.ts +33 -0
  121. package/dist/extensions/forgecli/input-router.js +133 -0
  122. package/dist/extensions/forgecli/input-router.js.map +1 -0
  123. package/dist/extensions/forgecli/model-resolver.d.ts +32 -0
  124. package/dist/extensions/forgecli/model-resolver.js +65 -0
  125. package/dist/extensions/forgecli/model-resolver.js.map +1 -0
  126. package/dist/extensions/forgecli/model-validator.d.ts +29 -0
  127. package/dist/extensions/forgecli/model-validator.js +107 -0
  128. package/dist/extensions/forgecli/model-validator.js.map +1 -0
  129. package/dist/extensions/forgecli/run-sprint.js +59 -0
  130. package/dist/extensions/forgecli/run-sprint.js.map +1 -1
  131. package/dist/extensions/forgecli/run-task.js +93 -1
  132. package/dist/extensions/forgecli/run-task.js.map +1 -1
  133. package/dist/extensions/forgecli/thread-switcher.js +5 -2
  134. package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
  135. package/dist/extensions/forgecli/whats-new-widget.js +5 -2
  136. package/dist/extensions/forgecli/whats-new-widget.js.map +1 -1
  137. package/package.json +11 -3
  138. package/dist/extensions/forgecli/review-command.d.ts +0 -2
  139. package/dist/extensions/forgecli/review-command.js +0 -184
  140. package/dist/extensions/forgecli/review-command.js.map +0 -1
  141. package/dist/forge-payload/.tools/banners.cjs +0 -435
  142. package/dist/forge-payload/.tools/build-context-pack.cjs +0 -290
  143. package/dist/forge-payload/.tools/build-init-context.cjs +0 -322
  144. package/dist/forge-payload/.tools/build-overlay.cjs +0 -326
  145. package/dist/forge-payload/.tools/build-persona-pack.cjs +0 -226
  146. package/dist/forge-payload/.tools/collate.cjs +0 -1041
  147. package/dist/forge-payload/.tools/generation-manifest.cjs +0 -311
  148. package/dist/forge-payload/.tools/lib/forge-root.cjs +0 -59
  149. package/dist/forge-payload/.tools/lib/paths.cjs +0 -29
  150. package/dist/forge-payload/.tools/lib/pricing.cjs +0 -165
  151. package/dist/forge-payload/.tools/lib/project-root.cjs +0 -32
  152. package/dist/forge-payload/.tools/lib/result.js +0 -40
  153. package/dist/forge-payload/.tools/lib/store-facade.cjs +0 -162
  154. package/dist/forge-payload/.tools/lib/store-nlp.cjs +0 -250
  155. package/dist/forge-payload/.tools/lib/store-query-exec.cjs +0 -272
  156. package/dist/forge-payload/.tools/lib/validate.js +0 -141
  157. package/dist/forge-payload/.tools/manage-config.cjs +0 -340
  158. package/dist/forge-payload/.tools/manage-versions.cjs +0 -365
  159. package/dist/forge-payload/.tools/package.json +0 -3
  160. package/dist/forge-payload/.tools/parse-gates.cjs +0 -151
  161. package/dist/forge-payload/.tools/parse-verdict.cjs +0 -67
  162. package/dist/forge-payload/.tools/preflight-gate.cjs +0 -350
  163. package/dist/forge-payload/.tools/prompts/sprint-plan-prompt.md +0 -70
  164. package/dist/forge-payload/.tools/schemas/task-list.schema.json +0 -53
  165. package/dist/forge-payload/.tools/seed-store.cjs +0 -237
  166. package/dist/forge-payload/.tools/store-cli.cjs +0 -1226
  167. package/dist/forge-payload/.tools/store-query.cjs +0 -319
  168. package/dist/forge-payload/.tools/store.cjs +0 -315
  169. package/dist/forge-payload/.tools/substitute-placeholders.cjs +0 -625
  170. package/dist/forge-payload/.tools/validate-store.cjs +0 -593
@@ -1,625 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * substitute-placeholders.cjs — Phase 3 (Materialize) engine for FR-002.
6
- *
7
- * Reads config.json and project-context.json, walks every file under
8
- * a base-pack directory, replaces {{KEY}} placeholders, and writes materialised
9
- * output to the appropriate output directories.
10
- *
11
- * Output path mapping (--target claude-code, default):
12
- * base-pack/commands/ → <outRoot>/.claude/commands/forge/
13
- * base-pack/personas/ → <outRoot>/.forge/personas/
14
- * base-pack/skills/ → <outRoot>/.forge/skills/
15
- * base-pack/workflows/ → <outRoot>/.forge/workflows/
16
- * base-pack/templates/ → <outRoot>/.forge/templates/
17
- *
18
- * Output path mapping (--target pi):
19
- * base-pack/personas/ → <outRoot>/personas/
20
- * base-pack/skills/ → <outRoot>/skills/
21
- * base-pack/workflows/ → <outRoot>/workflows/ (including _fragments/)
22
- * base-pack/templates/ → <outRoot>/templates/
23
- * base-pack/commands/ → SKIPPED (pi commands are registered programmatically)
24
- *
25
- * CLI:
26
- * node substitute-placeholders.cjs
27
- * [--target <claude-code|pi>] (default: claude-code)
28
- * [--src <path>] (base-pack source dir for --target pi)
29
- * [--forge-root <path>]
30
- * [--base-pack <path>]
31
- * [--config <path>]
32
- * [--context <path>]
33
- * [--rules <path>]
34
- * [--out <projectRoot>]
35
- * [--dry-run]
36
- *
37
- * Exported API (for unit testing):
38
- * buildSubstitutionMap(config, context, rules?)
39
- * applySubstitutions(text, map)
40
- * extractFrontmatter(content)
41
- * substituteFile(content, map)
42
- * walkBasePackPi(src, outRoot, dryRun, io)
43
- * PI_TARGET_SUBDIRS
44
- * REQUIRED_KEYS
45
- * RUNTIME_PASSTHROUGH_KEYS
46
- */
47
-
48
- const fs = require('node:fs');
49
- const path = require('node:path');
50
- const { getCommandsSubdir } = require('./lib/paths.cjs');
51
-
52
- // ── Constants ────────────────────────────────────────────────────────────────
53
-
54
- /**
55
- * Keys that must be present in the substitution map. Their absence causes
56
- * process.exit(1) (or a throw in library mode).
57
- */
58
- const REQUIRED_KEYS = new Set(['PROJECT_NAME', 'PREFIX']);
59
-
60
- /**
61
- * Runtime passthrough keys: placeholders filled at runtime by tools such as
62
- * collate.cjs. These MUST NOT be replaced by this tool. Any {{KEY}} whose key
63
- * is in this set is left untouched in the output.
64
- */
65
- const RUNTIME_PASSTHROUGH_KEYS = new Set([
66
- 'DATE',
67
- 'SPRINT_ID',
68
- 'TASK_ID',
69
- 'ROLE',
70
- 'MODEL',
71
- 'PHASES',
72
- 'INPUT',
73
- 'OUTPUT',
74
- 'COST',
75
- 'INPUT_TOKENS',
76
- 'OUTPUT_TOKENS',
77
- 'CACHE_READ',
78
- 'CACHE_WRITE',
79
- 'TOTAL_INPUT_TOKENS',
80
- 'TOTAL_OUTPUT_TOKENS',
81
- 'TOTAL_CACHE_READ_TOKENS',
82
- 'TOTAL_CACHE_WRITE_TOKENS',
83
- 'TOTAL_COST_USD',
84
- 'placeholder',
85
- ]);
86
-
87
- // ── Output path mapping ──────────────────────────────────────────────────────
88
-
89
- /**
90
- * Maps a base-pack subdirectory name to an output directory path relative to
91
- * the project root. The 'commands' entry is computed dynamically from the
92
- * project prefix via getCommandsSubdir() — see walkBasePack.
93
- */
94
- const SUBDIR_OUTPUT_MAP = {
95
- personas: path.join('.forge', 'personas'),
96
- skills: path.join('.forge', 'skills'),
97
- workflows: path.join('.forge', 'workflows'),
98
- templates: path.join('.forge', 'templates'),
99
- };
100
-
101
- /**
102
- * Subdirectories included when --target pi is used.
103
- * 'commands' is explicitly excluded — pi commands are registered
104
- * programmatically in TypeScript, not via .md files.
105
- *
106
- * Exported for unit tests (Test Group 15).
107
- */
108
- const PI_TARGET_SUBDIRS = new Set(['workflows', 'personas', 'skills', 'templates']);
109
-
110
- // ── Frontmatter extraction ───────────────────────────────────────────────────
111
-
112
- /**
113
- * Extract YAML frontmatter from a file's content.
114
- *
115
- * The opening `---` must be at line 1, column 0 (no leading whitespace) to
116
- * avoid false positives from `---` horizontal rules in Markdown body content.
117
- *
118
- * @param {string} content
119
- * @returns {{ frontmatter: string|null, body: string }}
120
- */
121
- function extractFrontmatter(content) {
122
- // Opening --- must be at the very start of the file, at column 0.
123
- if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
124
- return { frontmatter: null, body: content };
125
- }
126
-
127
- // Find the closing ---
128
- const lines = content.split('\n');
129
- for (let i = 1; i < lines.length; i++) {
130
- if (lines[i] === '---' || lines[i] === '---\r') {
131
- // Closing delimiter found at line i (0-indexed)
132
- // Reconstruct frontmatter (lines 0..i inclusive) and body (lines i+1..)
133
- const frontmatterLines = lines.slice(0, i + 1);
134
- const bodyLines = lines.slice(i + 1);
135
-
136
- // Re-attach trailing newline to closing --- so frontmatter ends with \n
137
- const frontmatter = frontmatterLines.join('\n') + '\n';
138
- const body = bodyLines.join('\n');
139
-
140
- return { frontmatter, body };
141
- }
142
- }
143
-
144
- // No closing --- found — treat entire content as body (malformed frontmatter)
145
- return { frontmatter: null, body: content };
146
- }
147
-
148
- // ── Substitution ─────────────────────────────────────────────────────────────
149
-
150
- /**
151
- * Apply substitution map to a string. Keys in RUNTIME_PASSTHROUGH_KEYS are
152
- * left intact. Unknown keys are also left intact (missing optional keys).
153
- *
154
- * @param {string} text
155
- * @param {Map<string, string>} map
156
- * @returns {string}
157
- */
158
- function applySubstitutions(text, map) {
159
- // Only match placeholders whose keys are ALL_CAPS_WITH_UNDERSCORES or
160
- // lowercase-with-hyphens (for skill names). Dot-notation is intentionally
161
- // NOT matched — T03 uses flat keys only.
162
- return text.replace(/\{\{([A-Za-z][A-Za-z0-9_-]*)\}\}/g, (full, key) => {
163
- if (RUNTIME_PASSTHROUGH_KEYS.has(key)) return full;
164
- if (map.has(key)) return map.get(key);
165
- // Unknown key — leave intact (missing optional)
166
- return full;
167
- });
168
- }
169
-
170
- /**
171
- * Apply substitutions to a file's content, preserving frontmatter byte-for-byte.
172
- *
173
- * @param {string} content
174
- * @param {Map<string, string>} map
175
- * @returns {string}
176
- */
177
- function substituteFile(content, map) {
178
- const { frontmatter, body } = extractFrontmatter(content);
179
- if (frontmatter === null) {
180
- return applySubstitutions(content, map);
181
- }
182
- // Frontmatter is preserved byte-for-byte; only the body gets substitution
183
- return frontmatter + applySubstitutions(body, map);
184
- }
185
-
186
- // ── Substitution map builder ──────────────────────────────────────────────────
187
-
188
- /**
189
- * Build the flat substitution map from config.json and project-context.json.
190
- *
191
- * @param {object} config — parsed .forge/config.json
192
- * @param {object|null} context — parsed project-context.json (may be null)
193
- * @param {object|null} rules — parsed build-base-pack-rules.json (optional;
194
- * loaded from forge root if omitted in CLI mode)
195
- * @returns {Map<string, string>}
196
- * @throws {Error} if PROJECT_NAME or PREFIX is missing
197
- */
198
- function buildSubstitutionMap(config, context, rules) {
199
- const project = (config && config.project) || {};
200
- const commands = (config && config.commands) || {};
201
- const paths = (config && config.paths) || {};
202
- const engRoot = paths.engineering || 'engineering';
203
-
204
- // ── Validate required keys ─────────────────────────────────────────────────
205
- // PROJECT_NAME is sourced from config.project.name OR context.project.name
206
- const projectName = (project.name) || (context && context.project && context.project.name) || '';
207
- const prefix = (project.prefix) || (context && context.project && context.project.prefix) || '';
208
-
209
- if (!projectName) {
210
- throw new Error('substitute-placeholders: missing required key PROJECT_NAME (config.project.name)');
211
- }
212
- if (!prefix) {
213
- throw new Error('substitute-placeholders: missing required key PREFIX (config.project.prefix)');
214
- }
215
-
216
- const map = new Map();
217
-
218
- // ── Config-sourced keys ────────────────────────────────────────────────────
219
- map.set('PROJECT_NAME', projectName);
220
- map.set('PREFIX', prefix);
221
- map.set('TEST_COMMAND', commands.test || '');
222
- map.set('LINT_COMMAND', commands.lint || '');
223
- map.set('KB_PATH', engRoot + '/architecture');
224
-
225
- // ── project-context.json sourced keys ─────────────────────────────────────
226
- if (context) {
227
- const arch = context.architecture || {};
228
- const frameworks = arch.frameworks || {};
229
- const deployment = context.deployment || {};
230
- const conventions = context.conventions || {};
231
- const verification = context.verification || {};
232
-
233
- // ENTITY_MODEL — entities array joined with ', '
234
- const entities = Array.isArray(context.entities) ? context.entities : [];
235
- map.set('ENTITY_MODEL', entities.join(', '));
236
-
237
- // DATA_ACCESS — direct string
238
- map.set('DATA_ACCESS', arch.dataAccess || '');
239
-
240
- // KEY_DIRECTORIES — array joined with ', '
241
- const keyDirs = Array.isArray(arch.keyDirectories) ? arch.keyDirectories : [];
242
- map.set('KEY_DIRECTORIES', keyDirs.join(', '));
243
-
244
- // TECHNICAL_DEBT — array joined with ', '
245
- const techDebt = Array.isArray(context.technicalDebt) ? context.technicalDebt : [];
246
- map.set('TECHNICAL_DEBT', techDebt.join(', '));
247
-
248
- // IMPACT_CATEGORIES — array joined with ', '
249
- const impact = Array.isArray(context.impactCategories) ? context.impactCategories : [];
250
- map.set('IMPACT_CATEGORIES', impact.join(', '));
251
-
252
- // DEPLOYMENT_ENVIRONMENTS — markdown table
253
- const envs = Array.isArray(deployment.environments) ? deployment.environments : [];
254
- map.set('DEPLOYMENT_ENVIRONMENTS', renderDeploymentTable(envs));
255
-
256
- // BRANCHING_CONVENTION — direct string
257
- map.set('BRANCHING_CONVENTION', conventions.branching || '');
258
-
259
- // STACK_SUMMARY — backend + frontend + database, empty parts omitted
260
- const stackParts = [frameworks.backend, frameworks.frontend, frameworks.database]
261
- .filter(Boolean);
262
- map.set('STACK_SUMMARY', stackParts.join(' + '));
263
-
264
- // VERIFICATION_COMMANDS — comma-separated non-empty command values
265
- const verificationValues = Object.values(verification).filter(Boolean);
266
- map.set('VERIFICATION_COMMANDS', verificationValues.join(', '));
267
-
268
- // SKILL_DIRECTIVES — 'skill → persona1, persona2' per entry
269
- const skillWiring = Array.isArray(context.skillWiring) ? context.skillWiring : [];
270
- const skillLines = skillWiring.map(entry => {
271
- const personas = Array.isArray(entry.personas) ? entry.personas : [];
272
- return `${entry.skill} → ${personas.join(', ')}`;
273
- });
274
- map.set('SKILL_DIRECTIVES', skillLines.join('\n'));
275
- } else {
276
- // No context — set defaults for all context-sourced keys
277
- for (const key of [
278
- 'ENTITY_MODEL', 'DATA_ACCESS', 'KEY_DIRECTORIES', 'TECHNICAL_DEBT',
279
- 'IMPACT_CATEGORIES', 'DEPLOYMENT_ENVIRONMENTS', 'BRANCHING_CONVENTION',
280
- 'STACK_SUMMARY', 'VERIFICATION_COMMANDS', 'SKILL_DIRECTIVES',
281
- ]) {
282
- map.set(key, '');
283
- }
284
- }
285
-
286
- // ── Skill-context blocks ───────────────────────────────────────────────────
287
- // These are rendered by joining the pre-substituted lines with \n and then
288
- // performing normal placeholder substitution on the joined block.
289
- //
290
- // Advisory Note 1: GENERIC_SKILL_PROJECT_CONTEXT uses empty array if the
291
- // 'generic' key is absent from personaProjectContext (treat as empty, not throw).
292
-
293
- const personaContextMap = (rules && rules.personaProjectContext) || {};
294
-
295
- const PERSONA_CONTEXT_KEYS = {
296
- ARCHITECT_SKILL_PROJECT_CONTEXT: 'architect',
297
- ENGINEER_SKILL_PROJECT_CONTEXT: 'engineer',
298
- SUPERVISOR_SKILL_PROJECT_CONTEXT: 'supervisor',
299
- COLLATOR_SKILL_PROJECT_CONTEXT: 'collator',
300
- BUG_FIXER_SKILL_PROJECT_CONTEXT: 'bug-fixer',
301
- QA_ENGINEER_SKILL_PROJECT_CONTEXT: 'qa-engineer',
302
- GENERIC_SKILL_PROJECT_CONTEXT: 'generic',
303
- };
304
-
305
- for (const [placeholder, personaKey] of Object.entries(PERSONA_CONTEXT_KEYS)) {
306
- const lines = Array.isArray(personaContextMap[personaKey]) ? personaContextMap[personaKey] : [];
307
- if (lines.length === 0) {
308
- map.set(placeholder, '');
309
- continue;
310
- }
311
- // Join lines, then apply substitutions from the map built so far
312
- const raw = lines.join('\n');
313
- const substituted = applySubstitutions(raw, map);
314
- map.set(placeholder, substituted);
315
- }
316
-
317
- return map;
318
- }
319
-
320
- // ── Rendering helpers ─────────────────────────────────────────────────────────
321
-
322
- /**
323
- * Render a deployment environments array as a Markdown table.
324
- *
325
- * @param {Array<{name:string, frontend:string, backend:string, region:string}>} envs
326
- * @returns {string}
327
- */
328
- function renderDeploymentTable(envs) {
329
- if (!Array.isArray(envs) || envs.length === 0) return '';
330
- const header = '| Environment | Frontend | Backend | Region |';
331
- const sep = '|---|---|---|---|';
332
- const rows = envs.map(e =>
333
- `| ${e.name || ''} | ${e.frontend || ''} | ${e.backend || ''} | ${e.region || ''} |`
334
- );
335
- return [header, sep, ...rows].join('\n');
336
- }
337
-
338
- // ── Walker ────────────────────────────────────────────────────────────────────
339
-
340
- /**
341
- * Walk the base-pack directory and write substituted files to outRoot.
342
- *
343
- * Advisory Note 3: every output path is resolved and checked to be under outRoot
344
- * to prevent path traversal via symlinks or '..' segments.
345
- *
346
- * @param {string} basePack — absolute path to the base-pack directory
347
- * @param {Map<string, string>} map
348
- * @param {string} outRoot — absolute project root (e.g. '/home/user/myproject')
349
- * @param {boolean} dryRun — if true, perform no writes
350
- * @param {{ warn: function }} io — pluggable stderr for warnings
351
- */
352
- function walkBasePack(basePack, map, outRoot, dryRun, io) {
353
- const warn = (io && io.warn) || ((msg) => process.stderr.write(msg + '\n'));
354
-
355
- // Extract prefix from substitution map for commands path computation
356
- const prefix = map.get('PREFIX') || '';
357
- const commandsSubdir = prefix ? getCommandsSubdir(prefix) : 'forge';
358
-
359
- // Sorted readdir for deterministic idempotent output (Advisory Note 7)
360
- const topEntries = fs.readdirSync(basePack).sort();
361
- for (const subdir of topEntries) {
362
- const subdirPath = path.join(basePack, subdir);
363
- const stat = fs.statSync(subdirPath);
364
- if (!stat.isDirectory()) continue;
365
-
366
- let relOutputDir;
367
- if (subdir === 'commands') {
368
- relOutputDir = path.join('.claude', 'commands', commandsSubdir);
369
- } else {
370
- relOutputDir = SUBDIR_OUTPUT_MAP[subdir];
371
- }
372
- if (!relOutputDir) {
373
- warn(`substitute-placeholders: unknown base-pack subdir "${subdir}" — skipping`);
374
- continue;
375
- }
376
-
377
- walkDir(subdirPath, relOutputDir, outRoot, map, dryRun, warn);
378
- }
379
- }
380
-
381
- /**
382
- * Recursively walk a directory, substituting and writing each file.
383
- *
384
- * FR-004 fix: uses `entry` (bare filename from readdirSync) instead of
385
- * `path.relative(baseDir, srcPath)` which caused double-nesting when
386
- * recursing into subdirectories like _fragments/. The output directory
387
- * is already tracked via `relOutputDir` (updated on each recursive
388
- * descent), so using just the filename is sufficient.
389
- */
390
- function walkDir(currentDir, relOutputDir, outRoot, map, dryRun, warn) {
391
- const entries = fs.readdirSync(currentDir).sort();
392
- for (const entry of entries) {
393
- const srcPath = path.join(currentDir, entry);
394
- const stat = fs.statSync(srcPath);
395
-
396
- if (stat.isDirectory()) {
397
- const childRelOutputDir = path.join(relOutputDir, entry);
398
- walkDir(srcPath, childRelOutputDir, outRoot, map, dryRun, warn);
399
- continue;
400
- }
401
-
402
- if (!stat.isFile()) continue;
403
-
404
- const relFile = entry;
405
- const outPath = path.resolve(outRoot, relOutputDir, relFile);
406
-
407
- // Path traversal defence: outPath must be inside outRoot
408
- const safeOutRoot = path.resolve(outRoot);
409
- if (!outPath.startsWith(safeOutRoot + path.sep) && outPath !== safeOutRoot) {
410
- warn(`substitute-placeholders: skipping file outside outRoot: ${outPath}`);
411
- continue;
412
- }
413
-
414
- const content = fs.readFileSync(srcPath, 'utf8');
415
- const substituted = substituteFile(content, map);
416
-
417
- if (!dryRun) {
418
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
419
- fs.writeFileSync(outPath, substituted, 'utf8');
420
- }
421
- }
422
- }
423
-
424
- // ── Pi target walker ─────────────────────────────────────────────────────────
425
-
426
- /**
427
- * Walk the base-pack directory and write files to outRoot for --target pi.
428
- *
429
- * Shares ~80% of walkBasePack logic; differs only in output-path mapping:
430
- * - Output is flat (e.g., `workflows/` not `.forge/workflows/`)
431
- * - Only PI_TARGET_SUBDIRS are included; all others are silently skipped
432
- * - Substitution map is always new Map() — all {{KEY}} tokens preserved
433
- * - Path-traversal defence (same outPath.startsWith(safeOutRoot) check as walkBasePack)
434
- *
435
- * @param {string} src — absolute path to base-pack source directory
436
- * @param {string} outRoot — absolute output root
437
- * @param {boolean} dryRun — if true, perform no writes
438
- * @param {{ warn: function }} io — pluggable stderr for warnings
439
- */
440
- function walkBasePackPi(src, outRoot, dryRun, io) {
441
- const warn = (io && io.warn) || ((msg) => process.stderr.write(msg + '\n'));
442
-
443
- // Empty substitution map — all {{KEY}} tokens preserved (pass-through)
444
- const emptyMap = new Map();
445
-
446
- const topEntries = fs.readdirSync(src).sort();
447
- for (const subdir of topEntries) {
448
- const subdirPath = path.join(src, subdir);
449
- const stat = fs.statSync(subdirPath);
450
- if (!stat.isDirectory()) continue;
451
-
452
- if (!PI_TARGET_SUBDIRS.has(subdir)) {
453
- // Expected skips (e.g., commands/) are debug-level; no user-facing warning
454
- continue;
455
- }
456
-
457
- // Flat layout: output directly to outRoot/<subdir>/... (no .forge/ wrapper)
458
- walkDir(subdirPath, subdir, outRoot, emptyMap, dryRun, warn);
459
- }
460
- }
461
-
462
- // ── CLI entry point ───────────────────────────────────────────────────────────
463
-
464
- if (require.main === module) {
465
- try {
466
- const argv = process.argv.slice(2);
467
- const args = parseCliArgs(argv);
468
-
469
- const dryRun = args.dryRun || false;
470
- const target = args.target || 'claude-code';
471
-
472
- // Validate target
473
- const VALID_TARGETS = new Set(['claude-code', 'pi']);
474
- if (!VALID_TARGETS.has(target)) {
475
- process.stderr.write(
476
- `substitute-placeholders: unknown --target "${target}". Valid targets: claude-code, pi\n`
477
- );
478
- process.exit(1);
479
- }
480
-
481
- // Resolve output root
482
- const outRoot = args.out || process.cwd();
483
-
484
- if (target === 'pi') {
485
- // ── --target pi dispatch ──────────────────────────────────────────────
486
-
487
- // Warn if --config, --context, or --rules were passed (they are ignored)
488
- if (args.config || args.context || args.rules) {
489
- process.stderr.write(
490
- 'Warning: --config and --context are ignored when --target pi\n'
491
- );
492
- }
493
-
494
- // Resolve --src (default: <forgeRoot>/init/base-pack)
495
- const forgeRoot = args.forgeRoot || resolveForgeRoot();
496
- const src = args.src || path.join(forgeRoot, 'init', 'base-pack');
497
- if (!fs.existsSync(src)) {
498
- process.stderr.write(`substitute-placeholders: --src path not found at ${src}\n`);
499
- process.exit(1);
500
- }
501
-
502
- // Walk pi base-pack (pass-through, no substitution)
503
- walkBasePackPi(src, outRoot, dryRun, null);
504
-
505
- if (dryRun) {
506
- process.stdout.write('substitute-placeholders: dry run complete (no files written)\n');
507
- } else {
508
- process.stdout.write('substitute-placeholders: pi layout complete\n');
509
- }
510
- process.exit(0);
511
- }
512
-
513
- // ── --target claude-code dispatch (default) ───────────────────────────────
514
-
515
- // Resolve forge root
516
- const forgeRoot = args.forgeRoot || resolveForgeRoot();
517
-
518
- // Resolve base-pack
519
- const basePack = args.basePack || path.join(forgeRoot, 'init', 'base-pack');
520
- if (!fs.existsSync(basePack)) {
521
- process.stderr.write(`substitute-placeholders: base-pack not found at ${basePack}\n`);
522
- process.exit(1);
523
- }
524
-
525
- // Resolve config
526
- const configPath = args.config || path.resolve(process.cwd(), '.forge', 'config.json');
527
- if (!fs.existsSync(configPath)) {
528
- process.stderr.write(`substitute-placeholders: config not found at ${configPath}\n`);
529
- process.exit(1);
530
- }
531
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
532
-
533
- // Resolve project-context (optional)
534
- let context = null;
535
- const contextPath = args.context;
536
- if (contextPath) {
537
- if (!fs.existsSync(contextPath)) {
538
- process.stderr.write(`substitute-placeholders: context not found at ${contextPath}\n`);
539
- process.exit(1);
540
- }
541
- context = JSON.parse(fs.readFileSync(contextPath, 'utf8'));
542
- } else {
543
- // Try default location
544
- const defaultContext = path.resolve(process.cwd(), '.forge', 'project-context.json');
545
- if (fs.existsSync(defaultContext)) {
546
- context = JSON.parse(fs.readFileSync(defaultContext, 'utf8'));
547
- }
548
- }
549
-
550
- // Resolve build-base-pack-rules.json (optional)
551
- let rules = null;
552
- const rulesPath = args.rules || path.join(forgeRoot, 'tools', 'build-base-pack-rules.json');
553
- if (fs.existsSync(rulesPath)) {
554
- rules = JSON.parse(fs.readFileSync(rulesPath, 'utf8'));
555
- }
556
-
557
- // Build substitution map — exits 1 if required keys are missing
558
- let map;
559
- try {
560
- map = buildSubstitutionMap(config, context, rules);
561
- } catch (err) {
562
- process.stderr.write(err.message + '\n');
563
- process.exit(1);
564
- }
565
-
566
- // Walk and materialise
567
- walkBasePack(basePack, map, outRoot, dryRun, null);
568
-
569
- if (dryRun) {
570
- process.stdout.write('substitute-placeholders: dry run complete (no files written)\n');
571
- } else {
572
- process.stdout.write('substitute-placeholders: materialisation complete\n');
573
- }
574
- process.exit(0);
575
- } catch (err) {
576
- process.stderr.write(`substitute-placeholders: fatal error: ${err.message}\n`);
577
- process.exit(1);
578
- }
579
- }
580
-
581
- // ── CLI argument parser ───────────────────────────────────────────────────────
582
-
583
- function parseCliArgs(argv) {
584
- const args = {};
585
- for (let i = 0; i < argv.length; i++) {
586
- const a = argv[i];
587
- if (a === '--dry-run') { args.dryRun = true; continue; }
588
- if (a === '--target' && argv[i + 1]) { args.target = argv[++i]; continue; }
589
- if (a === '--src' && argv[i + 1]) { args.src = argv[++i]; continue; }
590
- if (a === '--forge-root' && argv[i + 1]) { args.forgeRoot = argv[++i]; continue; }
591
- if (a === '--base-pack' && argv[i + 1]) { args.basePack = argv[++i]; continue; }
592
- if (a === '--config' && argv[i + 1]) { args.config = argv[++i]; continue; }
593
- if (a === '--context' && argv[i + 1]) { args.context = argv[++i]; continue; }
594
- if (a === '--rules' && argv[i + 1]) { args.rules = argv[++i]; continue; }
595
- if (a === '--out' && argv[i + 1]) { args.out = argv[++i]; continue; }
596
- }
597
- return args;
598
- }
599
-
600
- // ── Forge root resolver ───────────────────────────────────────────────────────
601
-
602
- function resolveForgeRoot() {
603
- const configPath = path.resolve(process.cwd(), '.forge', 'config.json');
604
- if (fs.existsSync(configPath)) {
605
- try {
606
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
607
- if (cfg.paths && cfg.paths.forgeRoot) return cfg.paths.forgeRoot;
608
- } catch (_) { /* fall through */ }
609
- }
610
- // Default: assume we're running from within forge/forge/tools/
611
- return path.resolve(__dirname, '..');
612
- }
613
-
614
- // ── Exports (for unit testing) ────────────────────────────────────────────────
615
-
616
- module.exports = {
617
- buildSubstitutionMap,
618
- applySubstitutions,
619
- extractFrontmatter,
620
- substituteFile,
621
- walkBasePackPi, // NEW — layout-reshape walker for --target pi
622
- PI_TARGET_SUBDIRS, // NEW — exported constant for tests
623
- REQUIRED_KEYS,
624
- RUNTIME_PASSTHROUGH_KEYS,
625
- };