@entelligentsia/forgecli 1.0.10 → 1.0.20

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 (183) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/dist/CHANGELOG-forge-plugin.md +211 -0
  3. package/dist/bin/forge.js +0 -0
  4. package/dist/extensions/forgecli/config-layer.js.map +1 -1
  5. package/dist/extensions/forgecli/context-governor-compaction.d.ts +83 -0
  6. package/dist/extensions/forgecli/context-governor-compaction.js +302 -0
  7. package/dist/extensions/forgecli/context-governor-compaction.js.map +1 -0
  8. package/dist/extensions/forgecli/context-governor.d.ts +173 -0
  9. package/dist/extensions/forgecli/context-governor.js +618 -0
  10. package/dist/extensions/forgecli/context-governor.js.map +1 -0
  11. package/dist/extensions/forgecli/dashboard/component.d.ts +105 -0
  12. package/dist/extensions/forgecli/dashboard/component.js +861 -0
  13. package/dist/extensions/forgecli/dashboard/component.js.map +1 -0
  14. package/dist/extensions/forgecli/dashboard/register.d.ts +2 -0
  15. package/dist/extensions/forgecli/dashboard/register.js +31 -0
  16. package/dist/extensions/forgecli/dashboard/register.js.map +1 -0
  17. package/dist/extensions/forgecli/dashboard/theme.d.ts +27 -0
  18. package/dist/extensions/forgecli/dashboard/theme.js +91 -0
  19. package/dist/extensions/forgecli/dashboard/theme.js.map +1 -0
  20. package/dist/extensions/forgecli/dashboard/view-model.d.ts +35 -0
  21. package/dist/extensions/forgecli/dashboard/view-model.js +54 -0
  22. package/dist/extensions/forgecli/dashboard/view-model.js.map +1 -0
  23. package/dist/extensions/forgecli/fix-bug.js +126 -7
  24. package/dist/extensions/forgecli/fix-bug.js.map +1 -1
  25. package/dist/extensions/forgecli/forge-artifact-tool.js +2 -1
  26. package/dist/extensions/forgecli/forge-artifact-tool.js.map +1 -1
  27. package/dist/extensions/forgecli/forge-commands.js +1 -0
  28. package/dist/extensions/forgecli/forge-commands.js.map +1 -1
  29. package/dist/extensions/forgecli/forge-init/phase4-register.js +53 -0
  30. package/dist/extensions/forgecli/forge-init/phase4-register.js.map +1 -1
  31. package/dist/extensions/forgecli/forge-subagent.d.ts +20 -1
  32. package/dist/extensions/forgecli/forge-subagent.js +23 -7
  33. package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
  34. package/dist/extensions/forgecli/forge-tools.js +3 -1
  35. package/dist/extensions/forgecli/forge-tools.js.map +1 -1
  36. package/dist/extensions/forgecli/hook-dispatcher.d.ts +3 -1
  37. package/dist/extensions/forgecli/hook-dispatcher.js +37 -3
  38. package/dist/extensions/forgecli/hook-dispatcher.js.map +1 -1
  39. package/dist/extensions/forgecli/index.js +38 -1
  40. package/dist/extensions/forgecli/index.js.map +1 -1
  41. package/dist/extensions/forgecli/lib/halt-advisor.d.ts +59 -0
  42. package/dist/extensions/forgecli/lib/halt-advisor.js +113 -0
  43. package/dist/extensions/forgecli/lib/halt-advisor.js.map +1 -0
  44. package/dist/extensions/forgecli/migration-engine.js +25 -12
  45. package/dist/extensions/forgecli/migration-engine.js.map +1 -1
  46. package/dist/extensions/forgecli/orchestrator-status-bar.d.ts +26 -0
  47. package/dist/extensions/forgecli/orchestrator-status-bar.js +213 -0
  48. package/dist/extensions/forgecli/orchestrator-status-bar.js.map +1 -0
  49. package/dist/extensions/forgecli/orchestrator-tree.d.ts +96 -0
  50. package/dist/extensions/forgecli/orchestrator-tree.js +390 -0
  51. package/dist/extensions/forgecli/orchestrator-tree.js.map +1 -0
  52. package/dist/extensions/forgecli/project-orientation.js +12 -8
  53. package/dist/extensions/forgecli/project-orientation.js.map +1 -1
  54. package/dist/extensions/forgecli/regenerate.d.ts +16 -0
  55. package/dist/extensions/forgecli/regenerate.js +110 -0
  56. package/dist/extensions/forgecli/regenerate.js.map +1 -1
  57. package/dist/extensions/forgecli/run-sprint.d.ts +3 -1
  58. package/dist/extensions/forgecli/run-sprint.js +34 -3
  59. package/dist/extensions/forgecli/run-sprint.js.map +1 -1
  60. package/dist/extensions/forgecli/run-task.d.ts +66 -1
  61. package/dist/extensions/forgecli/run-task.js +323 -12
  62. package/dist/extensions/forgecli/run-task.js.map +1 -1
  63. package/dist/extensions/forgecli/thread-switcher.d.ts +4 -1
  64. package/dist/extensions/forgecli/thread-switcher.js +118 -762
  65. package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
  66. package/dist/extensions/forgecli/viewport-events.js +32 -0
  67. package/dist/extensions/forgecli/viewport-events.js.map +1 -1
  68. package/dist/forge-payload/.base-pack/commands/fix-bug.md +1 -1
  69. package/dist/forge-payload/.base-pack/commands/run-sprint.md +1 -1
  70. package/dist/forge-payload/.base-pack/commands/run-task.md +1 -1
  71. package/dist/forge-payload/.base-pack/personas/architect.md +1 -1
  72. package/dist/forge-payload/.base-pack/personas/bug-fixer.md +1 -1
  73. package/dist/forge-payload/.base-pack/personas/collator.md +3 -3
  74. package/dist/forge-payload/.base-pack/personas/engineer.md +1 -1
  75. package/dist/forge-payload/.base-pack/personas/librarian.md +1 -1
  76. package/dist/forge-payload/.base-pack/personas/orchestrator.md +1 -1
  77. package/dist/forge-payload/.base-pack/personas/product-manager.md +1 -1
  78. package/dist/forge-payload/.base-pack/personas/qa-engineer.md +1 -1
  79. package/dist/forge-payload/.base-pack/personas/supervisor.md +1 -1
  80. package/dist/forge-payload/.base-pack/workflows/_fragments/event-emission-schema.md +1 -1
  81. package/dist/forge-payload/.base-pack/workflows/_fragments/friction-emit.md +1 -1
  82. package/dist/forge-payload/.base-pack/workflows/_fragments/iron-laws.md +1 -1
  83. package/dist/forge-payload/.base-pack/workflows/_fragments/progress-reporting.md +2 -2
  84. package/dist/forge-payload/.base-pack/workflows/_fragments/store-cli-verbs.md +11 -2
  85. package/dist/forge-payload/.base-pack/workflows/architect_approve.md +6 -7
  86. package/dist/forge-payload/.base-pack/workflows/architect_review_sprint_completion.md +2 -2
  87. package/dist/forge-payload/.base-pack/workflows/architect_sprint_intake.md +2 -2
  88. package/dist/forge-payload/.base-pack/workflows/architect_sprint_plan.md +5 -5
  89. package/dist/forge-payload/.base-pack/workflows/collator_agent.md +4 -6
  90. package/dist/forge-payload/.base-pack/workflows/commit_task.md +5 -6
  91. package/dist/forge-payload/.base-pack/workflows/enhance.md +5 -5
  92. package/dist/forge-payload/.base-pack/workflows/implement_plan.md +15 -7
  93. package/dist/forge-payload/.base-pack/workflows/migrate_structural.md +12 -13
  94. package/dist/forge-payload/.base-pack/workflows/plan_task.md +12 -6
  95. package/dist/forge-payload/.base-pack/workflows/review_code.md +12 -11
  96. package/dist/forge-payload/.base-pack/workflows/review_plan.md +12 -11
  97. package/dist/forge-payload/.base-pack/workflows/sprint_retrospective.md +3 -3
  98. package/dist/forge-payload/.base-pack/workflows/triage.md +12 -9
  99. package/dist/forge-payload/.base-pack/workflows/update_implementation.md +2 -2
  100. package/dist/forge-payload/.base-pack/workflows/update_plan.md +2 -2
  101. package/dist/forge-payload/.base-pack/workflows/validate_task.md +9 -9
  102. package/dist/forge-payload/.base-pack/workflows-js/wfl-fix-bug.js +490 -0
  103. package/dist/forge-payload/.base-pack/workflows-js/wfl-run-sprint.js +416 -0
  104. package/dist/forge-payload/.base-pack/workflows-js/wfl-run-task.js +608 -0
  105. package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
  106. package/dist/forge-payload/.schemas/config.schema.json +2 -3
  107. package/dist/forge-payload/.schemas/enum-catalog.json +2 -2
  108. package/dist/forge-payload/.schemas/event.schema.json +16 -0
  109. package/dist/forge-payload/.schemas/migrations.json +359 -18
  110. package/dist/forge-payload/commands/health.md +29 -0
  111. package/dist/forge-payload/commands/rebuild.md +143 -15
  112. package/dist/forge-payload/commands/update.md +28 -27
  113. package/dist/forge-payload/hooks/preflight-session.cjs +99 -0
  114. package/dist/forge-payload/init/phases/phase-3-materialize.md +18 -5
  115. package/dist/forge-payload/integrity.json +7 -6
  116. package/dist/forge-payload/meta/fragments/tool-discipline.md +1 -1
  117. package/dist/forge-payload/meta/personas/meta-architect.md +1 -1
  118. package/dist/forge-payload/meta/personas/meta-bug-fixer.md +1 -1
  119. package/dist/forge-payload/meta/personas/meta-collator.md +7 -7
  120. package/dist/forge-payload/meta/personas/meta-engineer.md +1 -1
  121. package/dist/forge-payload/meta/personas/meta-orchestrator.md +1 -1
  122. package/dist/forge-payload/meta/personas/meta-supervisor.md +1 -1
  123. package/dist/forge-payload/meta/tool-specs/store-cli.spec.md +1 -1
  124. package/dist/forge-payload/meta/workflows/_fragments/event-emission-schema.md +1 -1
  125. package/dist/forge-payload/meta/workflows/_fragments/friction-emit.md +1 -1
  126. package/dist/forge-payload/meta/workflows/_fragments/iron-laws.md +1 -1
  127. package/dist/forge-payload/meta/workflows/_fragments/progress-reporting.md +2 -2
  128. package/dist/forge-payload/meta/workflows/_fragments/store-cli-verbs.md +11 -2
  129. package/dist/forge-payload/meta/workflows/meta-approve.md +6 -7
  130. package/dist/forge-payload/meta/workflows/meta-bug-triage.md +12 -9
  131. package/dist/forge-payload/meta/workflows/meta-collate.md +5 -7
  132. package/dist/forge-payload/meta/workflows/meta-commit.md +5 -6
  133. package/dist/forge-payload/meta/workflows/meta-enhance.md +5 -5
  134. package/dist/forge-payload/meta/workflows/meta-fix-bug.md +35 -11
  135. package/dist/forge-payload/meta/workflows/meta-implement.md +15 -7
  136. package/dist/forge-payload/meta/workflows/meta-migrate.md +13 -14
  137. package/dist/forge-payload/meta/workflows/meta-new-sprint.md +3 -3
  138. package/dist/forge-payload/meta/workflows/meta-orchestrate.md +138 -39
  139. package/dist/forge-payload/meta/workflows/meta-plan-sprint.md +6 -6
  140. package/dist/forge-payload/meta/workflows/meta-plan-task.md +12 -6
  141. package/dist/forge-payload/meta/workflows/meta-retro.md +4 -4
  142. package/dist/forge-payload/meta/workflows/meta-retrospective.md +4 -4
  143. package/dist/forge-payload/meta/workflows/meta-review-implementation.md +12 -11
  144. package/dist/forge-payload/meta/workflows/meta-review-plan.md +12 -11
  145. package/dist/forge-payload/meta/workflows/meta-review-sprint-completion.md +3 -3
  146. package/dist/forge-payload/meta/workflows/meta-sprint-intake.md +3 -3
  147. package/dist/forge-payload/meta/workflows/meta-sprint-plan.md +6 -6
  148. package/dist/forge-payload/meta/workflows/meta-update-implementation.md +2 -2
  149. package/dist/forge-payload/meta/workflows/meta-update-plan.md +2 -2
  150. package/dist/forge-payload/meta/workflows/meta-validate.md +9 -9
  151. package/dist/forge-payload/schemas/config.schema.json +2 -3
  152. package/dist/forge-payload/schemas/enum-catalog.json +2 -2
  153. package/dist/forge-payload/schemas/event.schema.json +16 -0
  154. package/dist/forge-payload/schemas/structure-manifest.json +75 -73
  155. package/dist/forge-payload/skills/refresh-kb-links/SKILL.md +14 -7
  156. package/dist/forge-payload/tools/banners.cjs +29 -10
  157. package/dist/forge-payload/tools/check-structure.cjs +88 -7
  158. package/dist/forge-payload/tools/collate.cjs +48 -2
  159. package/dist/forge-payload/tools/manage-config.cjs +5 -7
  160. package/dist/forge-payload/tools/parse-gates.cjs +73 -1
  161. package/dist/forge-payload/tools/postflight-gate.cjs +298 -0
  162. package/dist/forge-payload/tools/preflight-gate.cjs +47 -0
  163. package/dist/forge-payload/tools/substitute-placeholders.cjs +5 -4
  164. package/dist/forge-payload/tools/verify-phase.cjs +17 -0
  165. package/package.json +2 -2
  166. package/dist/bin/forgecli.d.ts +0 -2
  167. package/dist/bin/forgecli.js +0 -6
  168. package/dist/bin/forgecli.js.map +0 -1
  169. package/dist/extensions/forgecli/config-tui/index.d.ts +0 -5
  170. package/dist/extensions/forgecli/config-tui/index.js +0 -5
  171. package/dist/extensions/forgecli/config-tui/index.js.map +0 -1
  172. package/dist/extensions/forgecli/loaders/persona-skill-loader.d.ts +0 -45
  173. package/dist/extensions/forgecli/loaders/persona-skill-loader.js +0 -227
  174. package/dist/extensions/forgecli/loaders/persona-skill-loader.js.map +0 -1
  175. package/dist/extensions/forgecli/loaders/template-render.d.ts +0 -20
  176. package/dist/extensions/forgecli/loaders/template-render.js +0 -85
  177. package/dist/extensions/forgecli/loaders/template-render.js.map +0 -1
  178. package/dist/extensions/forgecli/loaders/workflow-loader.d.ts +0 -41
  179. package/dist/extensions/forgecli/loaders/workflow-loader.js +0 -164
  180. package/dist/extensions/forgecli/loaders/workflow-loader.js.map +0 -1
  181. package/dist/forge-payload/.base-pack/workflows/fix_bug.md +0 -446
  182. package/dist/forge-payload/.base-pack/workflows/orchestrate_task.md +0 -928
  183. package/dist/forge-payload/.base-pack/workflows/run_sprint.md +0 -225
@@ -32,6 +32,7 @@
32
32
  // node banners.cjs --subtitle "Forging your SDLC"
33
33
  // node banners.cjs --progress 5 12 "Templates"
34
34
  // node banners.cjs --phase 7 12 "Workflows" ember
35
+ // node banners.cjs --badge forge --quiet (automated/orchestrator use: zero stdout)
35
36
 
36
37
  // ─── Plain-mode detection ─────────────────────────────────────────────────────
37
38
  // Resolved at call-time so tests can flip env vars dynamically.
@@ -51,6 +52,12 @@ function stripAnsi(s) {
51
52
  // Soft override — set by `--plain` CLI flag to force plain mode for one run.
52
53
  let FORCE_PLAIN = false;
53
54
 
55
+ // Quiet mode — set by `--quiet` CLI flag to suppress all stdout output.
56
+ // Used by the orchestrator loop so banner output does not enter the LLM context
57
+ // window (output remains visible on a real TTY via the human's terminal but is
58
+ // not fed back as tool-call response text).
59
+ let QUIET_MODE = false;
60
+
54
61
  // ─── ANSI helpers ─────────────────────────────────────────────────────────────
55
62
  const R = '\x1b[0m';
56
63
  const B = '\x1b[1m';
@@ -376,6 +383,7 @@ function _maybePlain(s) {
376
383
 
377
384
  // ─── CLI ───────────────────────────────────────────────────────────────────────
378
385
  // node banners.cjs [<name>] [--gallery] [--badge <name>] [--mark <name>] [--list]
386
+ // node banners.cjs --badge <name> --quiet (suppress all stdout; orchestrator use)
379
387
 
380
388
  if (require.main === module) {
381
389
  let args = process.argv.slice(2);
@@ -387,38 +395,49 @@ if (require.main === module) {
387
395
  args = args.slice(0, plainIdx).concat(args.slice(plainIdx + 1));
388
396
  }
389
397
 
398
+ // --quiet may appear anywhere; consume it before dispatch.
399
+ // When set, all stdout is suppressed (exit 0 still guaranteed on success).
400
+ const quietIdx = args.indexOf('--quiet');
401
+ if (quietIdx !== -1) {
402
+ QUIET_MODE = true;
403
+ args = args.slice(0, quietIdx).concat(args.slice(quietIdx + 1));
404
+ }
405
+
406
+ // Helper: write to stdout only when not in quiet mode.
407
+ const emit = (s) => { if (!QUIET_MODE) process.stdout.write(s); };
408
+
390
409
  try {
391
410
  if (!args.length || args[0] === '--gallery') {
392
- process.stdout.write(gallery() + '\n');
411
+ emit(gallery() + '\n');
393
412
  } else if (args[0] === '--list') {
394
- console.log(list().map(n => {
413
+ emit(list().map(n => {
395
414
  const b = BANNERS[n];
396
415
  return `${b.emoji} ${n.padEnd(8)} — ${b.tagline}`;
397
- }).join('\n'));
416
+ }).join('\n') + '\n');
398
417
  } else if (args[0] === '--badge') {
399
- console.log(badge(args[1] || ''));
418
+ emit(badge(args[1] || '') + '\n');
400
419
  } else if (args[0] === '--mark') {
401
- console.log(mark(args[1] || ''));
420
+ emit(mark(args[1] || '') + '\n');
402
421
  } else if (args[0] === '--subtitle') {
403
- console.log(subtitle(args.slice(1).join(' ')));
422
+ emit(subtitle(args.slice(1).join(' ')) + '\n');
404
423
  } else if (args[0] === '--progress') {
405
424
  const n = Number(args[1]);
406
425
  const total = Number(args[2]);
407
426
  const label = args.slice(3).join(' ') || undefined;
408
- console.log(progressBar(n, total, { label }));
427
+ emit(progressBar(n, total, { label }) + '\n');
409
428
  } else if (args[0] === '--phase') {
410
429
  const n = Number(args[1]);
411
430
  const total = Number(args[2]);
412
431
  const name = args[3] || '';
413
432
  const bannerKey = args[4] || 'forge';
414
433
  const mode = args[5]; // optional 'fast' | 'full'
415
- console.log(phaseHeader(n, total, name, bannerKey, mode ? { mode } : undefined));
434
+ emit(phaseHeader(n, total, name, bannerKey, mode ? { mode } : undefined) + '\n');
416
435
  } else if (args[0] === '--rule') {
417
436
  // --rule [text] Zen-blue em-dash horizontal rule (with optional label)
418
437
  const text = args.slice(1).join(' ') || undefined;
419
- console.log(ruleLine(text));
438
+ emit(ruleLine(text) + '\n');
420
439
  } else {
421
- process.stdout.write(render(args[0]) + '\n');
440
+ emit(render(args[0]) + '\n');
422
441
  }
423
442
  } catch (e) {
424
443
  console.error(e.message);
@@ -132,9 +132,17 @@ function validateManifest(manifest, forgeRoot) {
132
132
  fragments: path.join(basePackDir, 'workflows', '_fragments'),
133
133
  templates: path.join(basePackDir, 'templates'),
134
134
  commands: path.join(basePackDir, 'commands'),
135
+ 'workflows-js': path.join(basePackDir, 'workflows-js'),
136
+ // tools: not base-pack-sourced — source is forgeRoot/tools/ (verbatim vendor).
137
+ // Uses recursive enumeration to include lib/*.cjs with the lib/ prefix.
138
+ tools: path.join(forgeRoot, 'tools'),
135
139
  // schemas: not base-pack-sourced — source is forgeRoot/schemas/
136
140
  };
137
141
 
142
+ // Namespaces that require recursive enumeration (files may include path prefixes
143
+ // like lib/*.cjs). The walker returns relative paths with forward slashes.
144
+ const recursiveNs = new Set(['tools']);
145
+
138
146
  const manifestOnly = []; // Files in manifest but not in base-pack
139
147
  const basePackOnly = []; // Files in base-pack but not in manifest
140
148
 
@@ -147,14 +155,31 @@ function validateManifest(manifest, forgeRoot) {
147
155
 
148
156
  const manifestFiles = new Set(ns.files || []);
149
157
 
150
- // Read base-pack source directory
158
+ // Read base-pack source directory (flat or recursive depending on namespace)
151
159
  let basePackFiles = [];
152
160
  try {
153
- basePackFiles = fs.readdirSync(sourceDir).filter(f => {
154
- // Only include regular files, skip subdirectories (like _fragments under workflows)
155
- const stat = fs.statSync(path.join(sourceDir, f));
156
- return stat.isFile();
157
- });
161
+ if (recursiveNs.has(nsKey)) {
162
+ // Recursive walk: enumerate all files, returning paths relative to sourceDir
163
+ // (with forward-slash separators). Excludes *.test.cjs files.
164
+ const walkDir = (dir, prefix) => {
165
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
166
+ for (const entry of entries) {
167
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
168
+ if (entry.isDirectory()) {
169
+ walkDir(path.join(dir, entry.name), relPath);
170
+ } else if (entry.isFile() && !entry.name.endsWith('.test.cjs')) {
171
+ basePackFiles.push(relPath);
172
+ }
173
+ }
174
+ };
175
+ walkDir(sourceDir, '');
176
+ } else {
177
+ basePackFiles = fs.readdirSync(sourceDir).filter(f => {
178
+ // Only include regular files, skip subdirectories (like _fragments under workflows)
179
+ const stat = fs.statSync(path.join(sourceDir, f));
180
+ return stat.isFile();
181
+ });
182
+ }
158
183
  } catch {
159
184
  // Directory doesn't exist — all manifest files are manifestOnly
160
185
  for (const f of manifestFiles) {
@@ -183,9 +208,65 @@ function validateManifest(manifest, forgeRoot) {
183
208
  return { manifestOnly, basePackOnly };
184
209
  }
185
210
 
211
+ // Checks whether the vendored .forge/tools/ directory is present, and whether
212
+ // the version marker (.forge/tools/.forge-tools-version) matches the active
213
+ // plugin version from .forge/config.json (paths.forgeRef).
214
+ //
215
+ // Returns:
216
+ // { present, vendoredVersion, activeVersion, stale, reason }
217
+ //
218
+ // present — true if .forge/tools/ directory exists
219
+ // vendoredVersion — version string from .forge-tools-version (or null)
220
+ // activeVersion — version string from paths.forgeRef in config (or null)
221
+ // stale — true when: dir absent=false; marker absent=true; versions differ=true
222
+ // reason — 'ok' | 'missing' | 'marker-absent' | 'version-mismatch'
223
+ function checkToolsVersion(projectRoot) {
224
+ const toolsDir = path.join(projectRoot, '.forge', 'tools');
225
+ const markerFile = path.join(toolsDir, '.forge-tools-version');
226
+ const configFile = path.join(projectRoot, '.forge', 'config.json');
227
+
228
+ // Dir absent → not stale (just missing)
229
+ if (!fs.existsSync(toolsDir)) {
230
+ return { present: false, vendoredVersion: null, activeVersion: null, stale: false, reason: 'missing' };
231
+ }
232
+
233
+ // Read active version from config
234
+ let activeVersion = null;
235
+ if (fs.existsSync(configFile)) {
236
+ try {
237
+ const cfg = JSON.parse(fs.readFileSync(configFile, 'utf8'));
238
+ activeVersion = (cfg && cfg.paths && cfg.paths.forgeRef) ? cfg.paths.forgeRef : null;
239
+ } catch {
240
+ // unparseable — leave activeVersion null
241
+ }
242
+ }
243
+
244
+ // Marker absent → stale
245
+ if (!fs.existsSync(markerFile)) {
246
+ return { present: true, vendoredVersion: null, activeVersion, stale: true, reason: 'marker-absent' };
247
+ }
248
+
249
+ // Read vendored version from marker
250
+ let vendoredVersion = null;
251
+ try {
252
+ const marker = JSON.parse(fs.readFileSync(markerFile, 'utf8'));
253
+ vendoredVersion = (marker && marker.version) ? marker.version : null;
254
+ } catch {
255
+ // unparseable marker → treat as marker-absent
256
+ return { present: true, vendoredVersion: null, activeVersion, stale: true, reason: 'marker-absent' };
257
+ }
258
+
259
+ // Version mismatch → stale
260
+ if (activeVersion && vendoredVersion && vendoredVersion !== activeVersion) {
261
+ return { present: true, vendoredVersion, activeVersion, stale: true, reason: 'version-mismatch' };
262
+ }
263
+
264
+ return { present: true, vendoredVersion, activeVersion, stale: false, reason: 'ok' };
265
+ }
266
+
186
267
  // ── Exports ────────────────────────────────────────────────────────────────────
187
268
 
188
- module.exports = { checkNamespaces, validateManifest };
269
+ module.exports = { checkNamespaces, validateManifest, checkToolsVersion };
189
270
 
190
271
  // ── CLI ────────────────────────────────────────────────────────────────────────
191
272
 
@@ -108,9 +108,23 @@ function resolveTaskDir(task, sprintDirPath, engPath) {
108
108
  const normalizedTaskPath = task.path ? task.path.replace(/\\/g, '/').replace(/\/$/, '') : null;
109
109
  const normalizedEngPath = engPath ? engPath.replace(/\\/g, '/').replace(/\/$/, '') : '';
110
110
 
111
- // Case 1: path is under the engineering root — it IS the task directory
111
+ // Case 1: path is under the engineering root — it IS (or is a file inside)
112
+ // the task directory. Some store records carry task.path pointing at a file
113
+ // inside the task dir (e.g. .../FORGE-S22-T04/PLAN.md) rather than the dir
114
+ // itself; in that case basename(path) is the filename, not the dir. Only
115
+ // trust Case 1 when its candidate resolves to a real directory on disk —
116
+ // otherwise fall through to the filesystem scan, which resolves by taskId.
112
117
  if (normalizedTaskPath && normalizedEngPath && normalizedTaskPath.startsWith(normalizedEngPath + '/')) {
113
- return resultOk(path.basename(normalizedTaskPath));
118
+ const candidate = path.basename(normalizedTaskPath);
119
+ // Can't verify against disk — preserve legacy behaviour and trust the path.
120
+ if (!sprintDirPath) return resultOk(candidate);
121
+ const candidatePath = path.join(sprintDirPath, candidate);
122
+ // Trust Case 1 only when the candidate is a real directory. If it's a file
123
+ // (task.path pointed at a doc inside the dir) or absent, fall through to the
124
+ // filesystem scan below, which resolves the dir by taskId.
125
+ if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()) {
126
+ return resultOk(candidate);
127
+ }
114
128
  }
115
129
 
116
130
  // Case 2 (and fallback for case 1 missing): filesystem scan
@@ -597,6 +611,38 @@ function buildCostReport(sprint, events, orphanSidecars, huskPrimaries) {
597
611
  }
598
612
  }
599
613
 
614
+ // --- Section 3b: Incomplete passes (aborted / failed attempts) ---
615
+ // forge-cli ≥1.0.16 emits phase events with verdict "aborted" (user cancel)
616
+ // or "failed" (halt-on-failure) so the provider-billed tokens of incomplete
617
+ // attempts reach the store. Those tokens ARE included in the Per-Task /
618
+ // Per-Role / Model Split totals above (totals are verdict-agnostic — this
619
+ // closes the under-count where aborted passes were invisible); this section
620
+ // makes the incomplete share visible rather than silently mixed in.
621
+ lines.push('## Incomplete Passes', '');
622
+ {
623
+ const INCOMPLETE_VERDICTS = new Set(['aborted', 'failed']);
624
+ const incompleteEvents = tokenEvents.filter(e => INCOMPLETE_VERDICTS.has(e.verdict));
625
+ if (incompleteEvents.length === 0) {
626
+ lines.push('_No incomplete passes in this sprint._', '');
627
+ } else {
628
+ const rows = [['Task', 'Phase', 'Outcome', 'Input Tokens', 'Output Tokens', 'Est. Cost USD']];
629
+ const sorted = [...incompleteEvents].sort((a, b) =>
630
+ String(a.taskId || '').localeCompare(String(b.taskId || '')) ||
631
+ String(a.phase || '').localeCompare(String(b.phase || '')));
632
+ for (const e of sorted) {
633
+ rows.push([
634
+ e.taskId || '(unknown)',
635
+ e.phase || '(unknown)',
636
+ e.verdict,
637
+ fmtTokens(e.inputTokens || 0),
638
+ fmtTokens(e.outputTokens || 0),
639
+ fmtCost(e.estimatedCostUSD || 0),
640
+ ]);
641
+ }
642
+ lines.push(padTable(rows), '');
643
+ }
644
+ }
645
+
600
646
  // --- Section 4: Model split ---
601
647
  lines.push('## Model Split', '');
602
648
  {
@@ -418,7 +418,7 @@ if (subcmd === 'resolve-forge-root') {
418
418
 
419
419
  const { config } = readConfig();
420
420
  const forgeRef = getByPath(config, 'paths.forgeRef');
421
- const forgeRoot = getByPath(config, 'paths.forgeRoot');
421
+ // Note: paths.forgeRoot read removed in FORGE-S29-T03 (Priority 3 deprecated).
422
422
 
423
423
  // Priority 2: Scan cache/marketplace directories by forgeRef
424
424
  if (forgeRef && typeof forgeRef === 'string') {
@@ -442,14 +442,12 @@ if (subcmd === 'resolve-forge-root') {
442
442
  }
443
443
  }
444
444
 
445
- // Priority 3: Fallback to paths.forgeRoot (deprecated but still read)
446
- if (forgeRoot && typeof forgeRoot === 'string') {
447
- console.log(forgeRoot);
448
- process.exit(0);
449
- }
445
+ // Priority 3 (paths.forgeRoot fallback) removed FORGE-S29-T03.
446
+ // paths.forgeRoot is deprecated and no longer used as a resolution fallback.
447
+ // Relying on it masks misconfigured projects and blocks the FORGE_ROOT retirement.
450
448
 
451
449
  // No resolution possible
452
- console.error('× Cannot resolve Forge plugin root: no CLAUDE_PLUGIN_ROOT env var, no forgeRef cache match, and no forgeRoot in config.');
450
+ console.error('× Cannot resolve Forge plugin root: no CLAUDE_PLUGIN_ROOT env var and no forgeRef cache match. Run /forge:update to refresh.');
453
451
  process.exit(1);
454
452
  }
455
453
 
@@ -154,4 +154,76 @@ function parseAfter(rest, lineNo) {
154
154
  return { phase: m[1], verdict };
155
155
  }
156
156
 
157
- module.exports = { parseGates };
157
+ // parseOutputs same fence-scan logic as parseGates but for ```outputs phase=X blocks.
158
+ // Closed grammar: only `artifact` and `require` directives are allowed.
159
+ // Unknown directives throw (parity with parseGates).
160
+ const OUTPUTS_FENCE_OPEN = /^```outputs\s+phase=([A-Za-z0-9_-]+)\s*$/;
161
+
162
+ function parseOutputs(markdown) {
163
+ if (typeof markdown !== 'string' || markdown.length === 0) return {};
164
+ const lines = markdown.split('\n');
165
+ const result = {};
166
+ let currentPhase = null;
167
+ let inFence = false;
168
+ let fenceStartLine = -1;
169
+ let fenceBuffer = [];
170
+ for (let i = 0; i < lines.length; i++) {
171
+ const line = lines[i];
172
+ const lineNo = i + 1;
173
+ if (!inFence) {
174
+ const m = line.match(OUTPUTS_FENCE_OPEN);
175
+ if (m) {
176
+ currentPhase = m[1];
177
+ if (result[currentPhase]) {
178
+ throw new Error(
179
+ `parse-gates: line ${lineNo}: duplicate outputs block for phase "${currentPhase}"`,
180
+ );
181
+ }
182
+ inFence = true;
183
+ fenceStartLine = lineNo;
184
+ fenceBuffer = [];
185
+ }
186
+ continue;
187
+ }
188
+ // Inside an outputs fence
189
+ if (FENCE_CLOSE.test(line)) {
190
+ result[currentPhase] = parseOutputsBlock(fenceBuffer, fenceStartLine);
191
+ inFence = false;
192
+ currentPhase = null;
193
+ fenceBuffer = [];
194
+ continue;
195
+ }
196
+ fenceBuffer.push({ text: line, lineNo });
197
+ }
198
+ if (inFence) {
199
+ throw new Error(`parse-gates: unterminated \`\`\`outputs fence opened at line ${fenceStartLine}`);
200
+ }
201
+ return result;
202
+ }
203
+
204
+ function parseOutputsBlock(bufferedLines, _fenceStartLine) {
205
+ const spec = { artifacts: [], require: [] };
206
+ for (const { text, lineNo } of bufferedLines) {
207
+ const trimmed = text.trim();
208
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
209
+ const firstSpace = trimmed.search(/\s/);
210
+ if (firstSpace < 0) {
211
+ throw new Error(`parse-gates: line ${lineNo}: malformed directive "${trimmed}"`);
212
+ }
213
+ const directive = trimmed.slice(0, firstSpace);
214
+ const rest = trimmed.slice(firstSpace + 1).trim();
215
+ switch (directive) {
216
+ case 'artifact':
217
+ spec.artifacts.push(parseArtifact(rest, lineNo));
218
+ break;
219
+ case 'require':
220
+ spec.require.push(parsePredicate(rest, lineNo));
221
+ break;
222
+ default:
223
+ throw new Error(`parse-gates: line ${lineNo}: unknown directive "${directive}"`);
224
+ }
225
+ }
226
+ return spec;
227
+ }
228
+
229
+ module.exports = { parseGates, parseOutputs };
@@ -0,0 +1,298 @@
1
+ 'use strict';
2
+
3
+ // postflight-gate.cjs — evaluates a phase's declared outputs block against
4
+ // the filesystem / task state, post-subagent-return. Returns structured failure
5
+ // data so the orchestrator (run-task.ts) can halt before FSM advance when outputs
6
+ // are not satisfied.
7
+ //
8
+ // Pure function: only fs.existsSync / fs.statSync / fs.readFileSync. No writes,
9
+ // no network, no LLM, no process spawns.
10
+ //
11
+ // CLI shim: node postflight-gate.cjs --phase <name> --task <taskId>
12
+ // Exit codes: 0 (ok), 1 (guard failed), 2 (invalid args / parse error)
13
+ // Stdout on exit 1: single JSON line { phase, reasonCode, detail, remediation }
14
+ //
15
+ // New reasonCodes (FORGE-S26-T19):
16
+ // output-missing — artifact path does not exist
17
+ // output-stub — artifact exists but size < min= bytes
18
+ // require-failed — require predicate over state field failed
19
+ // tool-error — internal parse or store-read failure
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ /**
25
+ * postflight({ phase, outputs, state, substitutions })
26
+ *
27
+ * @param {string} phase - Phase name (e.g. 'implement')
28
+ * @param {object} outputs - Parsed outputs spec from parseOutputs() — { [phase]: { artifacts, require } }
29
+ * @param {object} [state] - Task/bug state for require predicates (e.g. { task: {...} })
30
+ * @param {object} [substitutions] - Template variable substitutions (e.g. { engineering, sprint, task })
31
+ * @returns {{ ok: boolean, missing: string[], reasonCode: string, detail: string, remediation: string }}
32
+ */
33
+ function postflight({ phase, outputs, state = {}, substitutions = {} }) {
34
+ const spec = outputs && outputs[phase];
35
+ if (!spec) {
36
+ // No outputs block for this phase — pass through (no-op)
37
+ return { ok: true, missing: [], reasonCode: null, detail: '', remediation: '' };
38
+ }
39
+
40
+ const missing = [];
41
+
42
+ for (const art of spec.artifacts || []) {
43
+ const resolved = applySubstitutions(art.path, substitutions);
44
+ let exists = false;
45
+ let size = 0;
46
+ try {
47
+ const st = fs.statSync(resolved);
48
+ exists = st.isFile();
49
+ size = st.size;
50
+ } catch (_) {
51
+ exists = false;
52
+ }
53
+ if (!exists) {
54
+ missing.push(`output-missing: artifact absent: ${resolved}`);
55
+ } else if (size < (art.minBytes || 0)) {
56
+ missing.push(
57
+ `output-stub: artifact too small: ${resolved} (${size} bytes, need >= ${art.minBytes})`,
58
+ );
59
+ }
60
+ }
61
+
62
+ for (const pred of spec.require || []) {
63
+ if (!evalPredicate(pred, state)) {
64
+ missing.push(
65
+ `require-failed: ${describePredicate(pred)} (got ${JSON.stringify(readField(pred.field, state))})`,
66
+ );
67
+ }
68
+ }
69
+
70
+ if (missing.length === 0) {
71
+ return { ok: true, missing: [], reasonCode: null, detail: '', remediation: '' };
72
+ }
73
+
74
+ const { reasonCode, detail, remediation } = buildStructuredFailure(phase, missing);
75
+ return { ok: false, missing, reasonCode, detail, remediation };
76
+ }
77
+
78
+ function applySubstitutions(template, subs) {
79
+ return template.replace(/\{(\w+)\}/g, (full, key) => {
80
+ if (Object.prototype.hasOwnProperty.call(subs, key)) return String(subs[key]);
81
+ return full;
82
+ });
83
+ }
84
+
85
+ function walkPath(dottedPath, root) {
86
+ const parts = dottedPath.split('.');
87
+ let cur = root;
88
+ for (const p of parts) {
89
+ if (cur === null || cur === undefined) return undefined;
90
+ cur = cur[p];
91
+ }
92
+ return cur;
93
+ }
94
+
95
+ function readField(dottedPath, state) {
96
+ // Direct walk first (supports explicit `task.status` / `bug.status` paths).
97
+ const direct = walkPath(dottedPath, state);
98
+ if (direct !== undefined) return direct;
99
+ // The materialized outputs blocks use BARE record paths
100
+ // (`summaries.plan.verdict`) while the CLI wraps the record as
101
+ // { task: record } — fall back to the entity record so those resolve.
102
+ // (CART-S03-T01 false-halt: every bare require evaluated undefined.)
103
+ if (state && state.task) {
104
+ const viaTask = walkPath(dottedPath, state.task);
105
+ if (viaTask !== undefined) return viaTask;
106
+ }
107
+ if (state && state.bug) {
108
+ const viaBug = walkPath(dottedPath, state.bug);
109
+ if (viaBug !== undefined) return viaBug;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ /**
115
+ * Build template substitutions for an entity's artifact directory.
116
+ * Canonical layouts:
117
+ * tasks: <engineering>/sprints/<sprintId>/<taskId>/
118
+ * bugs: <engineering>/bugs/<bugId>/
119
+ * {sprint} is defined as the path segment under <engineering> that contains
120
+ * the entity dir — `sprints/<sprintId>` for tasks, `bugs` for bugs.
121
+ * (CART-S03-T01 false-halt: {sprint} was substituted with the bare sprintId,
122
+ * dropping the `sprints/` segment, so every artifact check missed.)
123
+ */
124
+ function buildSubstitutions({ taskRecord, engineeringRoot, entityId }) {
125
+ const rec = taskRecord || {};
126
+ const isBug = Boolean(rec.bugId) || /-BUG-/.test(String(entityId || ''));
127
+ let sprint;
128
+ if (isBug) {
129
+ sprint = 'bugs';
130
+ } else if (rec.sprintId) {
131
+ sprint = `sprints/${rec.sprintId}`;
132
+ } else {
133
+ sprint = '{sprint}'; // unresolvable — leave the placeholder visible in the report
134
+ }
135
+ return {
136
+ engineering: engineeringRoot,
137
+ sprint,
138
+ task: entityId,
139
+ };
140
+ }
141
+
142
+ function evalPredicate(pred, state) {
143
+ const actual = readField(pred.field, state);
144
+ switch (pred.op) {
145
+ case '==':
146
+ return String(actual) === String(pred.value);
147
+ case '!=':
148
+ return String(actual) !== String(pred.value);
149
+ case 'in':
150
+ return (pred.value || []).map(String).includes(String(actual));
151
+ default:
152
+ throw new Error(`postflight-gate: unknown predicate op "${pred.op}"`);
153
+ }
154
+ }
155
+
156
+ function describePredicate(pred) {
157
+ if (pred.op === 'in') return `${pred.field} in [${(pred.value || []).join(', ')}]`;
158
+ return `${pred.field} ${pred.op} ${pred.value}`;
159
+ }
160
+
161
+ function buildStructuredFailure(phase, missing) {
162
+ let reasonCode = 'tool-error';
163
+ const detailParts = [];
164
+
165
+ for (const m of missing) {
166
+ detailParts.push(m);
167
+ if (reasonCode === 'tool-error') {
168
+ if (/^output-missing/i.test(m)) {
169
+ reasonCode = 'output-missing';
170
+ } else if (/^output-stub/i.test(m)) {
171
+ reasonCode = 'output-stub';
172
+ } else if (/^require-failed/i.test(m)) {
173
+ reasonCode = 'require-failed';
174
+ }
175
+ }
176
+ }
177
+
178
+ const detail = detailParts.join('; ');
179
+
180
+ const remediationMap = {
181
+ 'output-missing': 'Re-run the phase that produces this artifact (e.g. /forge:implement), then retry.',
182
+ 'output-stub': 'The artifact was produced but appears incomplete (stub). Ensure the phase wrote a complete file.',
183
+ 'require-failed': 'Correct the task/bug state so it satisfies the postflight require predicate, then retry.',
184
+ 'tool-error': 'Check the outputs block configuration and store records; run node .forge/tools/postflight-gate.cjs manually for diagnostics.',
185
+ };
186
+
187
+ return {
188
+ reasonCode,
189
+ detail,
190
+ remediation: remediationMap[reasonCode],
191
+ };
192
+ }
193
+
194
+ module.exports = { postflight, buildSubstitutions };
195
+
196
+ // CLI shim — only runs when invoked directly
197
+ if (require.main === module) {
198
+ const args = parseArgs(process.argv.slice(2));
199
+ if (!args.phase || !args.task) {
200
+ process.stderr.write('Usage: postflight-gate.cjs --phase <phaseName> --task <taskId>\n');
201
+ process.exit(2);
202
+ }
203
+
204
+ const { parseOutputs } = require('./parse-gates.cjs');
205
+
206
+ // Resolve config
207
+ let engineeringRoot = 'engineering';
208
+ try {
209
+ const cfg = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), '.forge/config.json'), 'utf8'));
210
+ if (cfg.paths && cfg.paths.engineering) engineeringRoot = cfg.paths.engineering;
211
+ } catch (_) { /* fall back to default */ }
212
+
213
+ // Load task record
214
+ let taskRecord = null;
215
+ try {
216
+ const store = require('./store.cjs');
217
+ taskRecord = store.getTask(args.task);
218
+ } catch (_) {
219
+ // store.cjs not available or task not found — continue with substitutions from args
220
+ }
221
+
222
+ // Build substitutions (canonical entity-dir resolution — sprints/<id> or bugs)
223
+ const substitutions = buildSubstitutions({
224
+ taskRecord,
225
+ engineeringRoot,
226
+ entityId: args.task,
227
+ });
228
+
229
+ // Load workflow markdown (scan .forge/workflows/ for phase)
230
+ const workflowMd = loadWorkflowMarkdown(args.phase);
231
+ if (!workflowMd) {
232
+ process.stderr.write(`postflight-gate: no outputs block defined for phase "${args.phase}" — skipping\n`);
233
+ process.exit(0);
234
+ }
235
+
236
+ let outputs;
237
+ try {
238
+ outputs = parseOutputs(workflowMd);
239
+ } catch (err) {
240
+ process.stderr.write(`postflight-gate: ${err.message}\n`);
241
+ process.exit(2);
242
+ }
243
+
244
+ if (!outputs[args.phase]) {
245
+ process.stderr.write(`postflight-gate: no outputs block for phase "${args.phase}" — skipping\n`);
246
+ process.exit(0);
247
+ }
248
+
249
+ // Build state from task record
250
+ const state = {};
251
+ if (taskRecord) state.task = taskRecord;
252
+
253
+ const result = postflight({ phase: args.phase, outputs, state, substitutions });
254
+
255
+ if (result.ok) process.exit(0);
256
+
257
+ process.stderr.write(`Postflight guard failed for phase "${args.phase}":\n`);
258
+ for (const m of result.missing) process.stderr.write(` - ${m}\n`);
259
+
260
+ // Emit structured JSON on stdout for orchestrators
261
+ process.stdout.write(JSON.stringify({
262
+ phase: args.phase,
263
+ reasonCode: result.reasonCode,
264
+ detail: result.detail,
265
+ remediation: result.remediation,
266
+ }) + '\n');
267
+ process.exit(1);
268
+ }
269
+
270
+ function parseArgs(argv) {
271
+ const out = {};
272
+ for (let i = 0; i < argv.length; i++) {
273
+ const a = argv[i];
274
+ if (a === '--phase') out.phase = argv[++i];
275
+ else if (a === '--task') out.task = argv[++i];
276
+ }
277
+ return out;
278
+ }
279
+
280
+ function loadWorkflowMarkdown(phaseName) {
281
+ const workflowsDir = path.resolve(process.cwd(), '.forge/workflows');
282
+ let entries;
283
+ try {
284
+ entries = fs.readdirSync(workflowsDir).filter((f) => f.endsWith('.md'));
285
+ } catch (_) {
286
+ return null;
287
+ }
288
+ const fencePattern = new RegExp('^```outputs\\s+phase=' + escapeRegex(phaseName) + '\\s*$', 'm');
289
+ for (const entry of entries) {
290
+ const md = fs.readFileSync(path.join(workflowsDir, entry), 'utf8');
291
+ if (fencePattern.test(md)) return md;
292
+ }
293
+ return null;
294
+ }
295
+
296
+ function escapeRegex(s) {
297
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
298
+ }