forgedock 1.0.22 → 1.1.2

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 (184) hide show
  1. package/README.md +255 -211
  2. package/bin/batch-runner.mjs +339 -0
  3. package/bin/cinema.mjs +320 -0
  4. package/bin/demo.mjs +92 -7
  5. package/bin/engine/phases.mjs +168 -0
  6. package/bin/engine/projector.mjs +32 -0
  7. package/bin/engine/reconcile.mjs +11 -0
  8. package/bin/engine/runlog.mjs +79 -0
  9. package/bin/engine/state.mjs +32 -0
  10. package/bin/engine-cli.mjs +222 -0
  11. package/bin/engine.mjs +138 -0
  12. package/bin/forgedock.mjs +896 -1540
  13. package/bin/hooks/interactive-engine.mjs +509 -0
  14. package/bin/hooks/pre-tool-use.mjs +498 -0
  15. package/bin/hooks/subagent-stop-enforce.mjs +197 -0
  16. package/bin/journey.mjs +841 -0
  17. package/bin/registry.mjs +139 -0
  18. package/bin/report.mjs +447 -0
  19. package/bin/runner.mjs +136 -20
  20. package/bin/settings-hook.mjs +346 -0
  21. package/bin/tui.mjs +6 -3
  22. package/commands/adopt.md +4 -1
  23. package/commands/analytics.md +4 -1
  24. package/commands/audit-agents.md +110 -27
  25. package/commands/audit.md +20 -1
  26. package/commands/autopilot.md +84 -12
  27. package/commands/changelog.md +2 -1
  28. package/commands/ci-audit.md +2 -1
  29. package/commands/cleanup.md +161 -1
  30. package/commands/compat-audit.md +2 -1
  31. package/commands/deploy-info.md +63 -4
  32. package/commands/design-bench.md +6 -1
  33. package/commands/design-render-critique-loop.md +2 -1
  34. package/commands/design.md +1 -0
  35. package/commands/diagnose.md +2 -1
  36. package/commands/explain.md +2 -1
  37. package/commands/forge-stats.md +122 -2
  38. package/commands/forgedock-init.md +1 -1
  39. package/commands/geo-audit.md +3 -0
  40. package/commands/incident-response.md +2 -1
  41. package/commands/issue.md +24 -3
  42. package/commands/milestone.md +77 -12
  43. package/commands/optimize.md +2 -1
  44. package/commands/orchestrate/config.md +67 -0
  45. package/commands/orchestrate/phase-1-resolve.md +159 -0
  46. package/commands/orchestrate/phase-2-triage.md +126 -0
  47. package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
  48. package/commands/orchestrate/phase-3-dependency.md +591 -0
  49. package/commands/orchestrate/phase-4-execution.md +962 -0
  50. package/commands/orchestrate/phase-5-cleanup.md +47 -0
  51. package/commands/orchestrate/phase-6-report.md +198 -0
  52. package/commands/orchestrate/safety.md +62 -0
  53. package/commands/orchestrate.md +25 -2027
  54. package/commands/pipeline-health/config.md +34 -0
  55. package/commands/pipeline-health/phase-1-context.md +180 -0
  56. package/commands/pipeline-health/phase-2-metrics.md +1729 -0
  57. package/commands/pipeline-health/phase-3-analyze.md +204 -0
  58. package/commands/pipeline-health/phase-4-proposals.md +117 -0
  59. package/commands/pipeline-health/phase-5-report.md +284 -0
  60. package/commands/pipeline-health/phase-6-summary.md +52 -0
  61. package/commands/pipeline-health.md +25 -2401
  62. package/commands/pipeline-resume.md +24 -1
  63. package/commands/pipeline-status.md +65 -0
  64. package/commands/qa-sweep.md +4 -1
  65. package/commands/quality-gate.md +326 -42
  66. package/commands/replay.md +2 -1
  67. package/commands/review-pr-agents/api.md +135 -0
  68. package/commands/review-pr-agents/auth.md +137 -0
  69. package/commands/review-pr-agents/billing.md +101 -0
  70. package/commands/review-pr-agents/concurrency.md +145 -0
  71. package/commands/review-pr-agents/database.md +103 -0
  72. package/commands/review-pr-agents/frontend.md +89 -0
  73. package/commands/review-pr-agents/infra.md +385 -0
  74. package/commands/review-pr-agents/protocols.md +185 -0
  75. package/commands/review-pr-agents/scraper.md +185 -0
  76. package/commands/review-pr-agents/security.md +406 -0
  77. package/commands/review-pr-agents.md +45 -1722
  78. package/commands/review-pr-staging.md +196 -40
  79. package/commands/review-pr.md +612 -56
  80. package/commands/rollback.md +2 -1
  81. package/commands/scope.md +2 -1
  82. package/commands/security-audit.md +2 -1
  83. package/commands/signal-planner.md +623 -0
  84. package/commands/sync-ecosystem.md +1 -0
  85. package/commands/test-gate.md +17 -1
  86. package/commands/validate.md +2 -1
  87. package/commands/work-on/build/architect.md +28 -0
  88. package/commands/work-on/build/context.md +2 -0
  89. package/commands/work-on/build/implement.md +19 -5
  90. package/commands/work-on/build/validate.md +62 -6
  91. package/commands/work-on/build.md +187 -30
  92. package/commands/work-on/close.md +132 -5
  93. package/commands/work-on/decompose.md +19 -1
  94. package/commands/work-on/investigate.md +117 -2
  95. package/commands/work-on/review.md +48 -4
  96. package/commands/work-on-monolithic.md +8 -2
  97. package/commands/work-on.md +247 -7
  98. package/docs/BRAND.md +16 -5
  99. package/docs/CI.md +2 -2
  100. package/docs/CODEX.md +36 -1
  101. package/docs/CONFIG.md +44 -42
  102. package/docs/FORGE-PROTOCOL.md +15 -1064
  103. package/docs/anthropic-devrel-outreach.md +2 -2
  104. package/docs/articles/command-decomposition-token-savings.md +214 -0
  105. package/docs/articles/model-release-playbook.md +131 -0
  106. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
  107. package/docs/claude-breakpoints.json +75 -5
  108. package/docs/design/abc-benchmark.md +1 -1
  109. package/docs/design/design-spec-schema.md +1 -1
  110. package/docs/design/divergent-generation.md +1 -1
  111. package/docs/design/eval-gate-runbook.md +178 -0
  112. package/docs/design/show-hn-draft.md +26 -1
  113. package/docs/design/token-cost-baseline-1298.md +59 -0
  114. package/docs/eval/README.md +117 -0
  115. package/docs/eval/corpus/seed-v1.json +6 -0
  116. package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
  117. package/docs/site/annotations-explained.md +24 -0
  118. package/docs/site/command-learning-path.md +51 -14
  119. package/docs/site/command-reference.md +339 -113
  120. package/docs/site/for-companies.md +95 -0
  121. package/docs/site/forge-annotation-protocol.md +12 -256
  122. package/docs/site/getting-started.md +58 -12
  123. package/docs/site/github-is-the-memory.md +113 -0
  124. package/docs/site/how-it-works.md +3 -13
  125. package/docs/site/index.md +10 -2
  126. package/docs/spec/eval-run-result.md +147 -0
  127. package/docs/spec/forge-protocol-v1.md +441 -18
  128. package/docs/spec/label-state-machine.md +55 -0
  129. package/docs/spec/review-protocol.md +158 -0
  130. package/docs/spec-graph-schema.md +28 -0
  131. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  132. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  133. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  134. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  135. package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
  136. package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
  137. package/examples/forgedock-demo/README.md +41 -22
  138. package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
  139. package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
  140. package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
  141. package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
  142. package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
  143. package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
  144. package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
  145. package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
  146. package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
  147. package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
  148. package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
  149. package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
  150. package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
  151. package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
  152. package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
  153. package/examples/forgedock-demo/scripts/smoke.js +18 -1
  154. package/examples/forgedock-demo/src/db.js +36 -11
  155. package/examples/forgedock-demo/src/routes/notes.js +106 -9
  156. package/examples/forgedock-demo/src/server.js +4 -1
  157. package/package.json +12 -4
  158. package/packages/protocol/LICENSE +21 -0
  159. package/packages/protocol/src/cli.js +135 -0
  160. package/packages/protocol/src/emit.js +146 -0
  161. package/packages/protocol/src/index.js +43 -0
  162. package/packages/protocol/src/parse.js +229 -0
  163. package/packages/protocol/src/types.js +171 -0
  164. package/packages/protocol/src/validate.js +122 -0
  165. package/scripts/bench-topology-cost.mjs +372 -0
  166. package/scripts/check-command-docs-drift.sh +198 -0
  167. package/scripts/check-protocol-restatements.sh +176 -0
  168. package/scripts/check-registry/README.md +72 -0
  169. package/scripts/check-registry/manifest.json +6 -0
  170. package/scripts/code-index.sh +454 -0
  171. package/scripts/conformance-check.mjs +168 -0
  172. package/scripts/doctor-pipeline-state.sh +484 -0
  173. package/scripts/eval-gate-baseline.json +17 -0
  174. package/scripts/eval-gate-scorecard.mjs +242 -0
  175. package/scripts/eval-scorecard-aggregator.mjs +252 -0
  176. package/scripts/flaky-quarantine.sh +241 -0
  177. package/scripts/forge-annotation.sh +567 -0
  178. package/scripts/issue-dedup.sh +236 -0
  179. package/scripts/validate-annotation-node.mjs +233 -0
  180. package/scripts/verify-env-vars.sh +18 -2
  181. package/scripts/verify-host-headers.sh +4 -4
  182. package/scripts/verify-route-registration.sh +2 -2
  183. package/scripts/worktree-lifecycle.sh +202 -0
  184. package/templates/devdocs/agent/using-forgedock.md +14 -0
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/batch-runner.mjs — Batch driver for the ForgeDock pipeline eval harness (#1285)
4
+ *
5
+ * Iterates a corpus of GitHub issue numbers, invokes runCommand() (from
6
+ * bin/runner.mjs) for each issue against the /work-on spec, and writes a
7
+ * structured per-run results file consumable by scripts/eval-gate-scorecard.mjs.
8
+ *
9
+ * This is the batch/corpus increment of the standalone runtime — the natural
10
+ * next step after bin/runner.mjs's single-command foundational increment (#1151).
11
+ *
12
+ * Exports (all pure, no SDK, no network):
13
+ * loadCorpus(path) → {corpus_version, issues}
14
+ * makeRunResult(issue, status, opts) → RunResult object
15
+ * classifyRunnerResult(result) → "success" | "failure" | "incomplete"
16
+ * writeResults(outputPath, results, meta) → void
17
+ * runCorpus(opts) → Promise<RunResult[]>
18
+ *
19
+ * CLI usage:
20
+ * node bin/batch-runner.mjs <corpus.json> [output.json]
21
+ * node bin/batch-runner.mjs <corpus.json> [output.json] --dry-run
22
+ *
23
+ * ANTHROPIC_API_KEY must be set for live runs.
24
+ * FORGEDOCK_MODEL overrides the model (default: runner.mjs default).
25
+ *
26
+ * Corpus file format (docs/spec/eval-run-result.md):
27
+ * { "corpus_version": "v1", "issues": [1001, 1002, 1003] }
28
+ *
29
+ * Output file format:
30
+ * { "corpus_version": "v1", "runs": [ ...RunResult ] }
31
+ *
32
+ * See docs/spec/eval-run-result.md for the full JSON schema.
33
+ */
34
+
35
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
36
+ import { dirname, resolve } from "node:path";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Corpus loading
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Load and validate a corpus JSON file.
44
+ *
45
+ * Throws a descriptive Error if the file is missing, invalid JSON, or does not
46
+ * contain a non-empty integer array at the "issues" key.
47
+ *
48
+ * @param {string} corpusPath - Absolute or relative path to the corpus JSON.
49
+ * @returns {{ corpus_version: string|null, issues: number[] }}
50
+ */
51
+ export function loadCorpus(corpusPath) {
52
+ let raw;
53
+ try {
54
+ raw = readFileSync(corpusPath, "utf-8");
55
+ } catch (e) {
56
+ throw new Error(`Cannot read corpus file "${corpusPath}": ${e.message}`);
57
+ }
58
+
59
+ let data;
60
+ try {
61
+ data = JSON.parse(raw);
62
+ } catch (e) {
63
+ throw new Error(`Corpus file "${corpusPath}" contains invalid JSON: ${e.message}`);
64
+ }
65
+
66
+ if (!data || typeof data !== "object") {
67
+ throw new Error(`Corpus file must be a JSON object; got ${typeof data}`);
68
+ }
69
+ if (!Array.isArray(data.issues) || data.issues.length === 0) {
70
+ throw new Error(`Corpus file must contain a non-empty "issues" array`);
71
+ }
72
+ for (let i = 0; i < data.issues.length; i++) {
73
+ const n = data.issues[i];
74
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
75
+ throw new Error(
76
+ `issues[${i}]: must be a positive integer (got ${JSON.stringify(n)})`,
77
+ );
78
+ }
79
+ }
80
+
81
+ return {
82
+ corpus_version: data.corpus_version ?? null,
83
+ issues: data.issues,
84
+ };
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Run-result construction helpers
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Map a runCommand() result status + stop reason to a scorecard status.
93
+ *
94
+ * runCommand() returns:
95
+ * status: "complete" | "incomplete" | "dry-run" | "max-iterations"
96
+ *
97
+ * We map these to the eval-result schema's status:
98
+ * "success" — run completed cleanly (status "complete")
99
+ * "incomplete" — run was cut short (status "incomplete" or "max-iterations" or "dry-run")
100
+ *
101
+ * The caller is responsible for "failure" — that requires inspecting GitHub
102
+ * state (whether the issue reached workflow:merged). For the purposes of the
103
+ * batch driver, a clean runner completion is classified as "success"; the
104
+ * scorecard consumer or a post-run hook can downgrade to "failure" by
105
+ * inspecting the issue label state.
106
+ *
107
+ * @param {{ status: string, stopReason?: string }} result
108
+ * @returns {"success" | "incomplete"}
109
+ */
110
+ export function classifyRunnerResult(result) {
111
+ if (result.status === "complete") return "success";
112
+ return "incomplete";
113
+ }
114
+
115
+ /**
116
+ * Construct a per-run result object conforming to docs/spec/eval-run-result.md.
117
+ *
118
+ * @param {number} issue - GitHub issue number.
119
+ * @param {"success"|"failure"|"incomplete"|"error"} status
120
+ * @param {object} [opts]
121
+ * @param {number} [opts.wallClockMs] - Wall-clock duration (ms).
122
+ * @param {number} [opts.interventionCount] - Human intervention count.
123
+ * @param {number|null} [opts.cost] - Token cost (null until #1255).
124
+ * @param {number} [opts.iterations] - Tool-loop iteration count.
125
+ * @param {string} [opts.stopReason] - Anthropic stop_reason or "max_iterations".
126
+ * @param {string|null} [opts.error] - Error message when status === "error".
127
+ * @param {string} [opts.specVersion] - Package version at run time.
128
+ * @param {string} [opts.model] - Model used.
129
+ * @param {string} [opts.runAt] - ISO-8601 timestamp.
130
+ * @returns {object}
131
+ */
132
+ export function makeRunResult(issue, status, opts = {}) {
133
+ return {
134
+ issue,
135
+ status,
136
+ wallClockMs: opts.wallClockMs ?? 0,
137
+ interventionCount: opts.interventionCount ?? 0,
138
+ cost: opts.cost !== undefined ? opts.cost : null,
139
+ iterations: opts.iterations ?? null,
140
+ stopReason: opts.stopReason ?? null,
141
+ error: opts.error ?? null,
142
+ specVersion: opts.specVersion ?? null,
143
+ model: opts.model ?? null,
144
+ runAt: opts.runAt ?? new Date().toISOString(),
145
+ };
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Output writing
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Serialize run results to a JSON file.
154
+ * Creates parent directories as needed.
155
+ *
156
+ * @param {string} outputPath - Absolute or relative path to write.
157
+ * @param {object[]} results - Array of per-run result objects.
158
+ * @param {object} [meta] - Top-level metadata (corpus_version, spec_version, model, corpus_size).
159
+ * @returns {void}
160
+ */
161
+ export function writeResults(outputPath, results, meta = {}) {
162
+ const resolved = resolve(outputPath);
163
+ mkdirSync(dirname(resolved), { recursive: true });
164
+ const payload = {
165
+ corpus_version: meta.corpus_version ?? null,
166
+ corpus_size: meta.corpus_size ?? null,
167
+ spec_version: meta.spec_version ?? null,
168
+ model: meta.model ?? null,
169
+ generated_at: new Date().toISOString(),
170
+ runs: results,
171
+ };
172
+ writeFileSync(resolved, JSON.stringify(payload, null, 2) + "\n", "utf-8");
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Batch orchestrator
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Run the full corpus headlessly.
181
+ *
182
+ * For each issue number in `corpus.issues`, invokes runCommand() against the
183
+ * /work-on spec in a shared working directory, records a per-run result, and
184
+ * returns the full array of results.
185
+ *
186
+ * Does NOT write output itself — callers should call writeResults() on the
187
+ * returned array. The CLI entry point below does this automatically.
188
+ *
189
+ * @param {object} opts
190
+ * @param {string} opts.commandsDir - Absolute path to commands/.
191
+ * @param {{ corpus_version: string|null, issues: number[] }} opts.corpus
192
+ * @param {string} [opts.cwd] - Working directory for runner (default process.cwd()).
193
+ * @param {string} [opts.apiKey] - Anthropic API key.
194
+ * @param {string} [opts.model] - Model id.
195
+ * @param {number} [opts.maxIterations] - Per-issue tool-loop bound.
196
+ * @param {boolean} [opts.dryRun] - If true, no API calls are made.
197
+ * @param {{log: Function, error?: Function}} [opts.logger]
198
+ * @returns {Promise<object[]>} - Array of per-run result objects.
199
+ */
200
+ export async function runCorpus(opts = {}) {
201
+ const {
202
+ commandsDir,
203
+ corpus,
204
+ cwd = process.cwd(),
205
+ apiKey = process.env.ANTHROPIC_API_KEY,
206
+ model,
207
+ maxIterations,
208
+ dryRun = false,
209
+ logger = console,
210
+ } = opts;
211
+
212
+ // Lazy import of runCommand so this module stays import-safe without the SDK.
213
+ const { runCommand } = await import("./runner.mjs");
214
+
215
+ const results = [];
216
+
217
+ for (const issue of corpus.issues) {
218
+ logger.log(`[batch-runner] Starting issue #${issue}…`);
219
+ const startMs = Date.now();
220
+ let result;
221
+ try {
222
+ result = await runCommand({
223
+ commandsDir,
224
+ commandName: "work-on",
225
+ args: [String(issue)],
226
+ cwd,
227
+ apiKey,
228
+ ...(model ? { model } : {}),
229
+ ...(maxIterations !== undefined ? { maxIterations } : {}),
230
+ dryRun,
231
+ logger,
232
+ });
233
+ const wallClockMs = Date.now() - startMs;
234
+ const status = classifyRunnerResult(result);
235
+ results.push(
236
+ makeRunResult(issue, status, {
237
+ wallClockMs,
238
+ interventionCount: 0,
239
+ cost: null, // wired in once #1255 lands
240
+ iterations: result.iterations ?? null,
241
+ stopReason: result.stopReason ?? null,
242
+ model: model ?? result.model ?? null,
243
+ runAt: new Date().toISOString(),
244
+ }),
245
+ );
246
+ logger.log(`[batch-runner] Issue #${issue} → ${status} (${wallClockMs}ms)`);
247
+ } catch (e) {
248
+ const wallClockMs = Date.now() - startMs;
249
+ results.push(
250
+ makeRunResult(issue, "error", {
251
+ wallClockMs,
252
+ interventionCount: 0,
253
+ cost: null,
254
+ error: e.message,
255
+ model: model ?? null,
256
+ runAt: new Date().toISOString(),
257
+ }),
258
+ );
259
+ logger.error
260
+ ? logger.error(`[batch-runner] Issue #${issue} → error: ${e.message}`)
261
+ : logger.log(`[batch-runner] Issue #${issue} → error: ${e.message}`);
262
+ }
263
+ }
264
+
265
+ return results;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // CLI entry point
270
+ // ---------------------------------------------------------------------------
271
+
272
+ async function main() {
273
+ const [, , corpusArg, outputArg, ...flags] = process.argv;
274
+ const dryRun = flags.includes("--dry-run");
275
+
276
+ if (!corpusArg) {
277
+ process.stderr.write(
278
+ "Usage: node bin/batch-runner.mjs <corpus.json> [output.json] [--dry-run]\n",
279
+ );
280
+ process.exit(1);
281
+ }
282
+
283
+ // Resolve paths relative to cwd of the calling shell, not __dirname.
284
+ const corpusPath = resolve(corpusArg);
285
+ const outputPath = outputArg ? resolve(outputArg) : resolve("eval-results.json");
286
+
287
+ let corpus;
288
+ try {
289
+ corpus = loadCorpus(corpusPath);
290
+ } catch (e) {
291
+ process.stderr.write(`ERROR: ${e.message}\n`);
292
+ process.exit(1);
293
+ }
294
+
295
+ // commandsDir: sibling of bin/ at the repo root.
296
+ const { fileURLToPath } = await import("node:url");
297
+ const { join } = await import("node:path");
298
+ const binDir = dirname(fileURLToPath(import.meta.url));
299
+ const commandsDir = join(binDir, "..", "commands");
300
+
301
+ // Read package version for specVersion field.
302
+ let specVersion = null;
303
+ try {
304
+ const pkgPath = join(binDir, "..", "package.json");
305
+ specVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? null;
306
+ } catch {
307
+ // non-fatal — specVersion stays null
308
+ }
309
+
310
+ console.log(
311
+ `[batch-runner] Running ${corpus.issues.length} issue(s) from corpus "${corpus.corpus_version ?? "(no version)"}"${dryRun ? " (dry-run)" : ""}`,
312
+ );
313
+
314
+ const results = await runCorpus({
315
+ commandsDir,
316
+ corpus,
317
+ dryRun,
318
+ logger: console,
319
+ });
320
+
321
+ const meta = {
322
+ corpus_version: corpus.corpus_version,
323
+ corpus_size: corpus.issues.length,
324
+ spec_version: specVersion,
325
+ model: results.find((r) => r.model)?.model ?? null,
326
+ };
327
+
328
+ writeResults(outputPath, results, meta);
329
+ console.log(`[batch-runner] Results written to ${outputPath}`);
330
+
331
+ const nSuccess = results.filter((r) => r.status === "success").length;
332
+ const nTotal = results.length;
333
+ console.log(`[batch-runner] ${nSuccess}/${nTotal} runs succeeded`);
334
+ }
335
+
336
+ import { fileURLToPath } from "node:url";
337
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
338
+ main();
339
+ }
package/bin/cinema.mjs ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * bin/cinema.mjs — Cinematic rendering primitives for ForgeDock's TUI.
3
+ *
4
+ * Zero-dependency. Pure presentation: gradients, the block-art F mark, and
5
+ * (in Task 2) shimmer / reveal / progress animation primitives.
6
+ *
7
+ * Two independent gates (spec: degradation ladder):
8
+ * colorMode() — 'truecolor' | '256' | 'none' (NO_COLOR, TTY, TERM)
9
+ * motionEnabled() — animation frames on/off (TTY, FORGE_NO_MOTION, CI, --fast)
10
+ *
11
+ * All functions take env/stdout/argv as parameters (with process defaults)
12
+ * so tests never need module-cache busting.
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Palette (spec: Chrome & Ember)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const CHROME_STOPS = ["#f7f3ea", "#d9cfba", "#a99a82", "#6e6252"];
20
+ export const EMBER_STOPS = ["#ff4d00", "#ff8c1a", "#ffd166"];
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Gates
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Decide the color capability of the terminal.
28
+ * @returns {'truecolor'|'256'|'none'}
29
+ */
30
+ export function colorMode(env = process.env, stdout = process.stdout) {
31
+ if (stdout.isTTY !== true) return "none";
32
+ if (env.NO_COLOR) return "none";
33
+ if (env.TERM === "dumb") return "none";
34
+ const ct = (env.COLORTERM || "").toLowerCase();
35
+ if (ct.includes("truecolor") || ct.includes("24bit")) return "truecolor";
36
+ if ((env.TERM || "").includes("256")) return "256";
37
+ // Windows Terminal / modern conhost support truecolor but don't set COLORTERM.
38
+ if (process.platform === "win32") return "truecolor";
39
+ return "256";
40
+ }
41
+
42
+ /**
43
+ * Decide whether animation frames run. Independent of color:
44
+ * NO_COLOR keeps motion (monochrome choreography); non-TTY/CI/--fast kill it.
45
+ * @returns {boolean}
46
+ */
47
+ export function motionEnabled(
48
+ argv = process.argv,
49
+ env = process.env,
50
+ stdout = process.stdout,
51
+ ) {
52
+ if (stdout.isTTY !== true) return false;
53
+ if (env.FORGE_NO_MOTION) return false;
54
+ if (env.CI) return false;
55
+ if (argv.includes("--fast")) return false;
56
+ return true;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Gradient math
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function hexToRgb(hex) {
64
+ const h = hex.replace("#", "");
65
+ return [
66
+ parseInt(h.slice(0, 2), 16),
67
+ parseInt(h.slice(2, 4), 16),
68
+ parseInt(h.slice(4, 6), 16),
69
+ ];
70
+ }
71
+
72
+ /**
73
+ * Sample a multi-stop gradient at position t ∈ [0,1].
74
+ * @returns {[number, number, number]} RGB triple.
75
+ */
76
+ export function sampleGradient(stops, t) {
77
+ const rgb = stops.map(hexToRgb);
78
+ const clamped = Math.min(Math.max(t, 0), 1);
79
+ const seg = Math.min(Math.floor(clamped * (rgb.length - 1)), rgb.length - 2);
80
+ const localT = clamped * (rgb.length - 1) - seg;
81
+ const [a, b] = [rgb[seg], rgb[seg + 1]];
82
+ const mix = (x, y) => Math.round(x + (y - x) * localT);
83
+ return [mix(a[0], b[0]), mix(a[1], b[1]), mix(a[2], b[2])];
84
+ }
85
+
86
+ /** Quantize an RGB triple to the xterm-256 6×6×6 color cube. */
87
+ function to256([r, g, b]) {
88
+ const q = (v) => Math.round((v / 255) * 5);
89
+ return 16 + 36 * q(r) + 6 * q(g) + q(b);
90
+ }
91
+
92
+ /** Foreground escape for an RGB triple in the given mode ('' for none). */
93
+ function fg(rgb, mode) {
94
+ if (mode === "none") return "";
95
+ if (mode === "256") return `\x1b[38;5;${to256(rgb)}m`;
96
+ return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
97
+ }
98
+
99
+ /**
100
+ * Render one line of text with a per-character horizontal gradient.
101
+ * Spaces stay uncolored. `phase` shifts the gradient start (for diagonals).
102
+ * In 'none' mode returns the input text verbatim (no reset appended).
103
+ */
104
+ export function gradientLine(text, stops, mode, phase = 0) {
105
+ if (mode === "none") return text;
106
+ const chars = [...text];
107
+ const n = Math.max(chars.length - 1, 1);
108
+ let out = "";
109
+ for (let i = 0; i < chars.length; i++) {
110
+ if (chars[i] === " ") {
111
+ out += " ";
112
+ continue;
113
+ }
114
+ const t = Math.min(i / n + phase, 1);
115
+ out += fg(sampleGradient(stops, t), mode) + chars[i];
116
+ }
117
+ return out + "\x1b[0m";
118
+ }
119
+
120
+ /**
121
+ * Render a block of lines with a diagonal gradient (each row's phase shifts
122
+ * slightly, approximating the logo's 135° gradient).
123
+ * @returns {string[]}
124
+ */
125
+ export function gradientBlock(lines, stops, mode) {
126
+ return lines.map((line, row) => gradientLine(line, stops, mode, row * 0.06));
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // The mark — block-art interpretation of the ForgeDock "F" (design approved
131
+ // from the 2026-07-03 brainstorm mockups; geometry may be pixel-tuned).
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export const HERO_MARK = [
135
+ " ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
136
+ " ▄▄██████████████████",
137
+ " ▄█████████████▀▀▀▀▀▀▀▀▀",
138
+ " ▄██████████▀▀",
139
+ " ▀▀▀▀▀ ▄▄███████▀",
140
+ " ▄████████▀",
141
+ " ▄██████▀",
142
+ " ▀▀▀▀",
143
+ ];
144
+
145
+ export const COMPACT_MARK = [
146
+ " ▄▄████████",
147
+ " ▄█████▀▀▀▀▀",
148
+ " ▀▀ ▄████▀",
149
+ " ▀▀▀",
150
+ ];
151
+
152
+ /**
153
+ * Render the chrome-gradient mark.
154
+ * @param {'hero'|'compact'} size
155
+ * @param {'truecolor'|'256'|'none'} mode
156
+ * @returns {string[]}
157
+ */
158
+ export function renderMark(size, mode) {
159
+ const art = size === "hero" ? HERO_MARK : COMPACT_MARK;
160
+ if (mode === "none") return [...art];
161
+ return gradientBlock(art, CHROME_STOPS, mode);
162
+ }
163
+
164
+ /** Ember-gradient text shorthand (wordmarks, act headers). */
165
+ export function ember(text, mode) {
166
+ return gradientLine(text, EMBER_STOPS, mode);
167
+ }
168
+
169
+ /** Promise-based sleep for animation pacing. */
170
+ export function sleep(ms) {
171
+ return new Promise((r) => setTimeout(r, ms));
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Animation primitives (Task 2)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ import { box } from "./tui.mjs";
179
+
180
+ const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
181
+
182
+ /** Ember-tinted status glyph for a result row. */
183
+ function statusGlyph(ok, mode) {
184
+ const rgb = ok ? [255, 179, 71] : [224, 112, 80]; // amber / ember-red
185
+ const glyph = ok ? "✔" : "✖";
186
+ if (mode === "none") return glyph;
187
+ return `${fg(rgb, mode)}${glyph}\x1b[0m`;
188
+ }
189
+
190
+ /** Dim wrapper honoring the mode gate. */
191
+ function dimText(s, mode) {
192
+ return mode === "none" ? s : `\x1b[2m${s}\x1b[22m`;
193
+ }
194
+
195
+ /** Badge like [high] / [med] / [low], colorized per confidence. */
196
+ function badgeText(badge, mode) {
197
+ const label = badge === "medium" ? "med" : badge;
198
+ if (mode === "none") return `[${label}]`;
199
+ const rgb = badge === "high" ? [125, 219, 132] : badge === "medium" ? [232, 192, 96] : [224, 112, 80];
200
+ return `${fg(rgb, mode)}[${label}]\x1b[0m`;
201
+ }
202
+
203
+ /** Ember-bordered fix card (wraps tui.box; border color applied when able). */
204
+ export function fixCard(lines, mode) {
205
+ const boxed = box(lines.map((l) => ` ${l} `), { title: "fix" });
206
+ if (mode === "none") return boxed;
207
+ const ember = fg([255, 107, 53], mode);
208
+ return boxed
209
+ .split("\n")
210
+ .map((l) => l.replace(/[╭╮╰╯─│]/gu, (m) => `${ember}${m}\x1b[0m`))
211
+ .join("\n");
212
+ }
213
+
214
+ function formatRow(label, res, mode) {
215
+ const parts = [` ${statusGlyph(res.ok, mode)} ${label.padEnd(16)}`];
216
+ if (res.detail) parts.push(dimText(String(res.detail), mode));
217
+ if (res.badge) parts.push(badgeText(res.badge, mode));
218
+ return parts.join(" ");
219
+ }
220
+
221
+ /**
222
+ * Reveal rows one at a time: spinner while `run()` is in flight, settling to
223
+ * a ✔/✖ line (+ optional fix card). Without motion: run, print final line.
224
+ * Every row's `run` executes regardless of motion — this is display-only.
225
+ */
226
+ export async function revealRows(
227
+ rows,
228
+ { mode, motion, writer = process.stdout, interval = 80, minDisplayMs = 120 } = {},
229
+ ) {
230
+ const results = [];
231
+ for (const row of rows) {
232
+ let res;
233
+ if (!motion) {
234
+ res = await row.run();
235
+ } else {
236
+ let frame = 0;
237
+ const spinnerColor = fg([255, 107, 53], mode);
238
+ const reset = mode === "none" ? "" : "\x1b[0m";
239
+ writer.write(` ${spinnerColor}${BRAILLE[0]}${reset} ${row.label}\n`);
240
+ const timer = setInterval(() => {
241
+ frame = (frame + 1) % BRAILLE.length;
242
+ writer.write(`\x1b[1A\x1b[2K ${spinnerColor}${BRAILLE[frame]}${reset} ${row.label}\n`);
243
+ }, interval);
244
+ const started = Date.now();
245
+ try {
246
+ res = await row.run();
247
+ const elapsed = Date.now() - started;
248
+ if (elapsed < minDisplayMs) await sleep(minDisplayMs - elapsed);
249
+ } finally {
250
+ clearInterval(timer);
251
+ }
252
+ writer.write("\x1b[1A\x1b[2K");
253
+ }
254
+ writer.write(formatRow(row.label, res, mode) + "\n");
255
+ if (!res.ok && res.fix && res.fix.length > 0) {
256
+ writer.write(fixCard(res.fix, mode) + "\n");
257
+ }
258
+ results.push(res);
259
+ }
260
+ return results;
261
+ }
262
+
263
+ /**
264
+ * One-pass highlight sweep over gradient block art, settling to the static
265
+ * gradientBlock render. In-place redraw via cursor-up; never alt-screen.
266
+ */
267
+ export async function shimmer(
268
+ lines,
269
+ stops,
270
+ { mode, motion, writer = process.stdout, frames = 14, interval = 55 } = {},
271
+ ) {
272
+ const settled = gradientBlock(lines, stops, mode);
273
+ if (!motion || mode === "none") {
274
+ writer.write(settled.join("\n") + "\n");
275
+ return;
276
+ }
277
+ const width = Math.max(...lines.map((l) => l.length));
278
+ for (let f = 0; f <= frames; f++) {
279
+ const band = (f / frames) * (width + 16) - 8;
280
+ const frameLines = lines.map((line, row) => {
281
+ const chars = [...line];
282
+ let out = "";
283
+ for (let i = 0; i < chars.length; i++) {
284
+ if (chars[i] === " ") {
285
+ out += " ";
286
+ continue;
287
+ }
288
+ const t = Math.min(i / Math.max(chars.length - 1, 1) + row * 0.06, 1);
289
+ let rgb = sampleGradient(stops, t);
290
+ const dist = Math.abs(i - band + row * 1.5);
291
+ if (dist < 6) {
292
+ const boost = 1 - dist / 6;
293
+ rgb = rgb.map((v) => Math.round(v + (255 - v) * boost));
294
+ }
295
+ out += `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${chars[i]}`;
296
+ }
297
+ return out + "\x1b[0m";
298
+ });
299
+ if (f > 0) writer.write(`\x1b[${lines.length}A`);
300
+ writer.write(frameLines.join("\n") + "\n");
301
+ await sleep(interval);
302
+ }
303
+ writer.write(`\x1b[${lines.length}A`);
304
+ writer.write(settled.join("\n") + "\n");
305
+ }
306
+
307
+ /**
308
+ * One static frame of the molten progress bar: solid fill with a ▓▒ leading
309
+ * edge and dim ░ remainder. Caller redraws with cursor-up for animation.
310
+ */
311
+ export function moltenBar(current, total, { width = 24, mode } = {}) {
312
+ const ratio = total === 0 ? 1 : Math.min(current / total, 1);
313
+ const filled = Math.round(ratio * width);
314
+ const solidLen = Math.max(filled - 2, 0);
315
+ const edge = "▓▒".slice(0, filled - solidLen);
316
+ const body = "█".repeat(solidLen) + edge;
317
+ const rest = "░".repeat(Math.max(width - filled, 0));
318
+ if (mode === "none") return `[${body}${rest}]`;
319
+ return gradientLine(body, EMBER_STOPS, mode) + dimText(rest, mode);
320
+ }