forgedock 1.1.1 → 1.1.3

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 (174) hide show
  1. package/README.md +53 -8
  2. package/bin/batch-runner.mjs +339 -0
  3. package/bin/demo.mjs +92 -7
  4. package/bin/engine/phases.mjs +24 -2
  5. package/bin/engine/state.mjs +8 -3
  6. package/bin/engine-cli.mjs +177 -5
  7. package/bin/forgedock.mjs +409 -26
  8. package/bin/hooks/interactive-engine.mjs +509 -0
  9. package/bin/hooks/pre-tool-use.mjs +498 -0
  10. package/bin/hooks/subagent-stop-enforce.mjs +197 -0
  11. package/bin/journey.mjs +72 -14
  12. package/bin/registry.mjs +139 -0
  13. package/bin/report.mjs +447 -0
  14. package/bin/runner.mjs +70 -20
  15. package/bin/settings-hook.mjs +235 -1
  16. package/commands/adopt.md +4 -1
  17. package/commands/analytics.md +4 -1
  18. package/commands/audit-agents.md +110 -27
  19. package/commands/audit.md +20 -1
  20. package/commands/autopilot.md +518 -361
  21. package/commands/changelog.md +2 -1
  22. package/commands/ci-audit.md +2 -1
  23. package/commands/cleanup.md +161 -1
  24. package/commands/compat-audit.md +2 -1
  25. package/commands/deploy-info.md +58 -1
  26. package/commands/deploy-pr.md +485 -0
  27. package/commands/design-bench.md +5 -1
  28. package/commands/design-render-critique-loop.md +1 -1
  29. package/commands/diagnose.md +2 -1
  30. package/commands/explain.md +2 -1
  31. package/commands/fix-ci.md +577 -0
  32. package/commands/forge-stats.md +121 -2
  33. package/commands/forgedock-init.md +1 -1
  34. package/commands/geo-audit.md +3 -0
  35. package/commands/incident-response.md +2 -1
  36. package/commands/issue.md +23 -2
  37. package/commands/milestone.md +77 -12
  38. package/commands/optimize.md +2 -1
  39. package/commands/orchestrate/config.md +67 -0
  40. package/commands/orchestrate/phase-1-resolve.md +159 -0
  41. package/commands/orchestrate/phase-2-triage.md +126 -0
  42. package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
  43. package/commands/orchestrate/phase-3-dependency.md +591 -0
  44. package/commands/orchestrate/phase-4-execution.md +962 -0
  45. package/commands/orchestrate/phase-5-cleanup.md +47 -0
  46. package/commands/orchestrate/phase-6-report.md +198 -0
  47. package/commands/orchestrate/safety.md +62 -0
  48. package/commands/orchestrate.md +25 -2045
  49. package/commands/pipeline-health/config.md +34 -0
  50. package/commands/pipeline-health/phase-1-context.md +180 -0
  51. package/commands/pipeline-health/phase-2-metrics.md +1729 -0
  52. package/commands/pipeline-health/phase-3-analyze.md +204 -0
  53. package/commands/pipeline-health/phase-4-proposals.md +117 -0
  54. package/commands/pipeline-health/phase-5-report.md +284 -0
  55. package/commands/pipeline-health/phase-6-summary.md +52 -0
  56. package/commands/pipeline-health.md +25 -2401
  57. package/commands/pipeline-resume.md +24 -1
  58. package/commands/pipeline-status.md +65 -0
  59. package/commands/qa-sweep.md +4 -1
  60. package/commands/quality-gate.md +248 -10
  61. package/commands/recover-orphans.md +576 -0
  62. package/commands/replay.md +2 -1
  63. package/commands/review-pr-agents/api.md +135 -0
  64. package/commands/review-pr-agents/auth.md +137 -0
  65. package/commands/review-pr-agents/billing.md +101 -0
  66. package/commands/review-pr-agents/concurrency.md +145 -0
  67. package/commands/review-pr-agents/database.md +103 -0
  68. package/commands/review-pr-agents/frontend.md +89 -0
  69. package/commands/review-pr-agents/infra.md +385 -0
  70. package/commands/review-pr-agents/protocols.md +185 -0
  71. package/commands/review-pr-agents/scraper.md +185 -0
  72. package/commands/review-pr-agents/security.md +406 -0
  73. package/commands/review-pr-agents.md +45 -1722
  74. package/commands/review-pr-staging.md +196 -40
  75. package/commands/review-pr.md +591 -52
  76. package/commands/rollback.md +2 -1
  77. package/commands/scope.md +2 -1
  78. package/commands/security-audit.md +2 -1
  79. package/commands/signal-planner.md +623 -0
  80. package/commands/sync-ecosystem.md +1 -0
  81. package/commands/test-gate.md +17 -1
  82. package/commands/validate.md +2 -1
  83. package/commands/work-on/build/architect.md +28 -0
  84. package/commands/work-on/build/context.md +2 -0
  85. package/commands/work-on/build/implement.md +3 -1
  86. package/commands/work-on/build/validate.md +26 -7
  87. package/commands/work-on/build.md +163 -24
  88. package/commands/work-on/close.md +130 -3
  89. package/commands/work-on/decompose.md +19 -1
  90. package/commands/work-on/investigate.md +117 -2
  91. package/commands/work-on/review.md +24 -4
  92. package/commands/work-on-monolithic.md +7 -2
  93. package/commands/work-on.md +202 -4
  94. package/docs/BRAND.md +14 -7
  95. package/docs/CI.md +16 -2
  96. package/docs/CODEX.md +36 -1
  97. package/docs/CONFIG.md +5 -8
  98. package/docs/FORGE-PROTOCOL.md +15 -1064
  99. package/docs/anthropic-devrel-outreach.md +2 -2
  100. package/docs/articles/command-decomposition-token-savings.md +214 -0
  101. package/docs/articles/model-release-playbook.md +131 -0
  102. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
  103. package/docs/claude-breakpoints.json +75 -5
  104. package/docs/design/abc-benchmark.md +1 -1
  105. package/docs/design/design-spec-schema.md +1 -1
  106. package/docs/design/divergent-generation.md +1 -1
  107. package/docs/design/eval-gate-runbook.md +178 -0
  108. package/docs/design/show-hn-draft.md +26 -1
  109. package/docs/design/token-cost-baseline-1298.md +59 -0
  110. package/docs/eval/README.md +120 -0
  111. package/docs/eval/corpus/seed-v1.json +6 -0
  112. package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
  113. package/docs/site/annotations-explained.md +24 -0
  114. package/docs/site/command-learning-path.md +51 -14
  115. package/docs/site/command-reference.md +386 -111
  116. package/docs/site/for-companies.md +95 -0
  117. package/docs/site/forge-annotation-protocol.md +12 -256
  118. package/docs/site/getting-started.md +43 -4
  119. package/docs/site/github-is-the-memory.md +113 -0
  120. package/docs/site/how-it-works.md +3 -13
  121. package/docs/site/index.md +9 -1
  122. package/docs/spec/eval-run-result.md +147 -0
  123. package/docs/spec/forge-protocol-v1.md +441 -18
  124. package/docs/spec/label-state-machine.md +55 -0
  125. package/docs/spec/review-protocol.md +158 -0
  126. package/docs/spec-graph-schema.md +28 -0
  127. package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
  128. package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
  129. package/examples/forgedock-demo/README.md +41 -22
  130. package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
  131. package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
  132. package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
  133. package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
  134. package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
  135. package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
  136. package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
  137. package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
  138. package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
  139. package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
  140. package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
  141. package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
  142. package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
  143. package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
  144. package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
  145. package/examples/forgedock-demo/scripts/smoke.js +18 -1
  146. package/examples/forgedock-demo/src/db.js +36 -11
  147. package/examples/forgedock-demo/src/routes/notes.js +106 -9
  148. package/examples/forgedock-demo/src/server.js +4 -1
  149. package/package.json +3 -1
  150. package/packages/protocol/LICENSE +21 -0
  151. package/packages/protocol/src/cli.js +135 -0
  152. package/packages/protocol/src/emit.js +146 -0
  153. package/packages/protocol/src/index.js +43 -0
  154. package/packages/protocol/src/parse.js +254 -0
  155. package/packages/protocol/src/types.js +171 -0
  156. package/packages/protocol/src/validate.js +122 -0
  157. package/scripts/bench-topology-cost.mjs +372 -0
  158. package/scripts/check-command-docs-drift.sh +198 -0
  159. package/scripts/check-protocol-restatements.sh +176 -0
  160. package/scripts/check-registry/README.md +72 -0
  161. package/scripts/check-registry/manifest.json +6 -0
  162. package/scripts/code-index.sh +454 -0
  163. package/scripts/conformance-check.mjs +168 -0
  164. package/scripts/doctor-pipeline-state.sh +484 -0
  165. package/scripts/eval-gate-baseline.json +17 -0
  166. package/scripts/eval-gate-scorecard.mjs +242 -0
  167. package/scripts/eval-scorecard-aggregator.mjs +252 -0
  168. package/scripts/flaky-quarantine.sh +241 -0
  169. package/scripts/forge-annotation.sh +567 -0
  170. package/scripts/issue-dedup.sh +236 -0
  171. package/scripts/validate-annotation-node.mjs +233 -0
  172. package/scripts/worktree-lifecycle.sh +3 -3
  173. package/templates/devdocs/agent/using-forgedock.md +14 -0
  174. package/docs/site/.vitepress/config.mjs +0 -85
package/bin/report.mjs ADDED
@@ -0,0 +1,447 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ /**
5
+ * bin/report.mjs — ForgeDock pipeline impact report
6
+ *
7
+ * Computes a 30-day (default) summary of pipeline-driven activity for a
8
+ * forge-managed repo:
9
+ * - Issues closed and share with FORGE annotations
10
+ * - Median and p90 open→close time
11
+ * - Merged PR count and share with Closes/Fixes/Resolves cross-references
12
+ * - review-finding and workflow:invalid issue counts
13
+ * - Machine-filed intent share (bot/app authored issues)
14
+ *
15
+ * Usage:
16
+ * npx forgedock report [--days 30] [--md] [--json] [--quiet]
17
+ *
18
+ * Flags:
19
+ * --days N Look-back window in days (default: 30)
20
+ * --md Emit paste-ready Markdown block instead of terminal summary
21
+ * --json Emit raw JSON for scripting (supersedes --md)
22
+ * --quiet Suppress the optional ForgeDock fleet pointer in --md output
23
+ */
24
+
25
+ import { execSync, execFileSync } from "child_process";
26
+ import { readFileSync, existsSync } from "fs";
27
+ import { resolve, dirname, join } from "path";
28
+ import { fileURLToPath } from "url";
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = dirname(__filename);
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Colours (copied from tui.mjs to keep this module self-contained)
35
+ // ---------------------------------------------------------------------------
36
+ const RESET = "\x1b[0m";
37
+ const BOLD = "\x1b[1m";
38
+ const DIM = "\x1b[2m";
39
+ const GREEN = "\x1b[32m";
40
+ const YELLOW = "\x1b[33m";
41
+ const CYAN = "\x1b[36m";
42
+ const RED = "\x1b[31m";
43
+ const dim = (s) => `${DIM}${s}${RESET}`;
44
+ const bold = (s) => `${BOLD}${s}${RESET}`;
45
+ const green = (s) => `${GREEN}${s}${RESET}`;
46
+ const cyan = (s) => `${CYAN}${s}${RESET}`;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Helpers
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Run a `gh` command via execFileSync (no shell — argv array only) and
54
+ * return parsed JSON, or null on failure.
55
+ *
56
+ * MUST receive an argv array, never a shell string — forge.yaml-derived
57
+ * values (owner/repo) flow into these arguments and must never be
58
+ * interpolated into a shell command string. See #1586.
59
+ *
60
+ * Exported (in addition to the default CLI entry point `runReport`) so
61
+ * tests can exercise the real no-shell execution path directly.
62
+ */
63
+ export function ghJson(args) {
64
+ try {
65
+ const out = execFileSync("gh", args, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
66
+ return JSON.parse(out.trim());
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Validate that a forge.yaml-derived owner/repo value is safe to use as a
74
+ * `gh -R owner/repo` argument. GitHub usernames/orgs and repo names are
75
+ * restricted to alphanumerics, hyphens, underscores, and dots.
76
+ *
77
+ * Exported for the same testability reason as `ghJson` above.
78
+ */
79
+ export function isValidGhIdentifier(value) {
80
+ return typeof value === "string" && /^[\w.-]+$/.test(value);
81
+ }
82
+
83
+ /**
84
+ * Read and parse forge.yaml from cwd, returning the project section.
85
+ * Returns null if not found or unparseable.
86
+ */
87
+ function readForgeYaml(cwd) {
88
+ const candidates = [join(cwd, "forge.yaml"), join(cwd, ".forge.yaml")];
89
+ for (const p of candidates) {
90
+ if (existsSync(p)) {
91
+ try {
92
+ // Minimal inline parser — avoids external deps
93
+ const raw = readFileSync(p, "utf-8");
94
+ const result = {};
95
+ let currentSection = null;
96
+ for (const line of raw.split(/\r?\n/)) {
97
+ if (/^\s*#/.test(line) || /^\s*$/.test(line)) continue;
98
+ const sectionMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s*(?:#.*)?$/);
99
+ if (sectionMatch) { currentSection = sectionMatch[1]; result[currentSection] = {}; continue; }
100
+ const topMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s+"([^"]*)"(?:\s*#.*)?$|^([a-zA-Z_][a-zA-Z0-9_]*):\s+([^#\n]+?)(?:\s*#.*)?$/);
101
+ if (topMatch && !line.startsWith(" ") && !line.startsWith("\t")) {
102
+ const key = topMatch[1] ?? topMatch[3];
103
+ const value = topMatch[2] ?? topMatch[4]?.trim();
104
+ if (key && value !== undefined) { currentSection = null; result[key] = value; }
105
+ continue;
106
+ }
107
+ if (currentSection && /^\s+/.test(line)) {
108
+ const nestedMatch = line.match(/^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s+"([^"]*)"(?:\s*#.*)?$|^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s+([^#\n]+?)(?:\s*#.*)?$/);
109
+ if (nestedMatch) {
110
+ const key = nestedMatch[1] ?? nestedMatch[3];
111
+ const value = nestedMatch[2] ?? nestedMatch[4]?.trim();
112
+ if (key && value !== undefined && typeof result[currentSection] === "object") {
113
+ result[currentSection][key] = value;
114
+ }
115
+ }
116
+ }
117
+ }
118
+ return result;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * Compute median of a sorted numeric array.
129
+ *
130
+ * Exported (in addition to `p90`/`fmtMinutes` below) so tests can exercise
131
+ * the real math directly instead of reimplementing it. See #1592.
132
+ */
133
+ export function median(sorted) {
134
+ if (!sorted.length) return null;
135
+ const mid = Math.floor(sorted.length / 2);
136
+ return sorted.length % 2 !== 0
137
+ ? sorted[mid]
138
+ : (sorted[mid - 1] + sorted[mid]) / 2;
139
+ }
140
+
141
+ /**
142
+ * Compute p90 of a sorted numeric array.
143
+ */
144
+ export function p90(sorted) {
145
+ if (!sorted.length) return null;
146
+ const idx = Math.ceil(sorted.length * 0.9) - 1;
147
+ return sorted[Math.max(0, idx)];
148
+ }
149
+
150
+ /**
151
+ * Format minutes as a human-readable duration.
152
+ */
153
+ export function fmtMinutes(mins) {
154
+ if (mins === null || mins === undefined) return "n/a";
155
+ if (mins < 60) return `${Math.round(mins)}m`;
156
+ if (mins < 1440) return `${Math.round(mins / 60)}h`;
157
+ return `${Math.round(mins / 1440)}d`;
158
+ }
159
+
160
+ /**
161
+ * Check whether gh is authenticated.
162
+ */
163
+ function isGhAuthenticated() {
164
+ try {
165
+ execSync("gh auth status", { stdio: "pipe" });
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Main report computation
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Pure computation of window-scoped issue/PR statistics from already-fetched
178
+ * GitHub API data. Contains no I/O — extracted from `computeStats()` so it
179
+ * can be unit-tested directly against fixture data instead of being
180
+ * reimplemented inline in the test file. See #1592.
181
+ *
182
+ * @param {object} args
183
+ * @param {object[]} args.closedIssues - Raw `gh issue list --state closed` JSON.
184
+ * @param {object[]} args.mergedPRs - Raw `gh pr list --state merged` JSON.
185
+ * @param {string} args.since - ISO timestamp; window lower bound.
186
+ * @param {string} args.ghRepo - `owner/repo` string, echoed back in the result.
187
+ * @param {number} args.days - Look-back window in days, echoed back in the result.
188
+ */
189
+ export function computeWindowStats({ closedIssues, mergedPRs, since, ghRepo, days }) {
190
+ const windowIssues = closedIssues.filter((i) => i.closedAt && i.closedAt >= since);
191
+
192
+ // Annotated issues (contain FORGE:INVESTIGATOR or FORGE:TRAJECTORY)
193
+ const annotatedCount = windowIssues.filter(
194
+ (i) => (i.body && (i.body.includes("FORGE:INVESTIGATOR") || i.body.includes("FORGE:TRAJECTORY")))
195
+ ).length;
196
+
197
+ // Time-to-close in minutes
198
+ const ttcMinutes = windowIssues
199
+ .filter((i) => i.createdAt && i.closedAt)
200
+ .map((i) => (new Date(i.closedAt) - new Date(i.createdAt)) / 60000)
201
+ .filter((m) => m >= 0)
202
+ .sort((a, b) => a - b);
203
+
204
+ const medianTtc = median(ttcMinutes);
205
+ const p90Ttc = p90(ttcMinutes);
206
+
207
+ // Machine-filed issues (bot/app authors)
208
+ const machineFiled = windowIssues.filter((i) => {
209
+ const t = (i.author?.type || "").toLowerCase();
210
+ return t === "bot" || t === "app" || (i.author?.login || "").endsWith("[bot]");
211
+ }).length;
212
+
213
+ // workflow:invalid issues
214
+ const invalidCount = windowIssues.filter((i) =>
215
+ (i.labels || []).some((l) => l.name === "workflow:invalid")
216
+ ).length;
217
+
218
+ // review-finding issues
219
+ const reviewFindingCount = windowIssues.filter((i) =>
220
+ (i.labels || []).some((l) => l.name === "review-finding")
221
+ ).length;
222
+
223
+ const windowPRs = mergedPRs.filter((p) => p.mergedAt && p.mergedAt >= since);
224
+
225
+ // PRs that reference an issue (Closes/Fixes/Resolves #N)
226
+ const linkedPRs = windowPRs.filter((p) =>
227
+ p.body && /(?:closes?|fixes?|resolves?)\s+#\d+/i.test(p.body)
228
+ ).length;
229
+
230
+ // Check if counts are approximate (search-indexed — flag if limit was hit)
231
+ const issueApprox = closedIssues.length >= 500;
232
+ const prApprox = mergedPRs.length >= 500;
233
+
234
+ return {
235
+ repo: ghRepo,
236
+ days,
237
+ since,
238
+ issues: {
239
+ closed: windowIssues.length,
240
+ approx: issueApprox,
241
+ annotated: annotatedCount,
242
+ annotatedPct: windowIssues.length
243
+ ? Math.round((annotatedCount / windowIssues.length) * 100)
244
+ : 0,
245
+ medianTtc,
246
+ p90Ttc,
247
+ machineFiled,
248
+ machineFiledPct: windowIssues.length
249
+ ? Math.round((machineFiled / windowIssues.length) * 100)
250
+ : 0,
251
+ invalid: invalidCount,
252
+ reviewFindings: reviewFindingCount,
253
+ },
254
+ prs: {
255
+ merged: windowPRs.length,
256
+ approx: prApprox,
257
+ linked: linkedPRs,
258
+ linkedPct: windowPRs.length
259
+ ? Math.round((linkedPRs / windowPRs.length) * 100)
260
+ : 0,
261
+ },
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Fetch and compute all stats for the given repo and window.
267
+ * Returns a structured result object. Throws on hard failures.
268
+ */
269
+ async function computeStats({ owner, repo, days }) {
270
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
271
+ const ghRepo = `${owner}/${repo}`;
272
+
273
+ const closedIssues = ghJson([
274
+ "issue", "list", "-R", ghRepo, "--state", "closed", "--limit", "500",
275
+ "--json", "number,title,body,labels,createdAt,closedAt,author",
276
+ ]) ?? [];
277
+
278
+ const mergedPRs = ghJson([
279
+ "pr", "list", "-R", ghRepo, "--state", "merged", "--limit", "500",
280
+ "--json", "number,title,body,mergedAt",
281
+ ]) ?? [];
282
+
283
+ return computeWindowStats({ closedIssues, mergedPRs, since, ghRepo, days });
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Output renderers
288
+ // ---------------------------------------------------------------------------
289
+
290
+ function renderTerminal(stats) {
291
+ const { repo, days, issues, prs } = stats;
292
+ const approxIssue = issues.approx ? "~" : "";
293
+ const approxPr = prs.approx ? "~" : "";
294
+
295
+ const lines = [
296
+ "",
297
+ bold(` ForgeDock Pipeline Impact Report`),
298
+ dim(` ${repo} · last ${days} days`),
299
+ "",
300
+ bold(" Issues"),
301
+ ` Closed: ${green(approxIssue + issues.closed)}`,
302
+ ` Pipeline-driven: ${approxIssue}${issues.annotated} (${issues.annotatedPct}% carry FORGE annotations)`,
303
+ ` Median time: ${fmtMinutes(issues.medianTtc)}`,
304
+ ` p90 time: ${fmtMinutes(issues.p90Ttc)}`,
305
+ ` Machine-filed: ${issues.machineFiled} (${issues.machineFiledPct}%)`,
306
+ ` Review findings: ${issues.reviewFindings}`,
307
+ ` Invalid/discarded:${issues.invalid}`,
308
+ "",
309
+ bold(" Pull Requests"),
310
+ ` Merged: ${green(approxPr + prs.merged)}`,
311
+ ` Linked to issue: ${approxPr}${prs.linked} (${prs.linkedPct}%)`,
312
+ "",
313
+ ];
314
+
315
+ return lines.join("\n");
316
+ }
317
+
318
+ function renderMarkdown(stats, quiet) {
319
+ const { repo, days, issues, prs } = stats;
320
+ const approxIssue = issues.approx ? "~" : "";
321
+ const approxPr = prs.approx ? "~" : "";
322
+
323
+ const lines = [
324
+ `## ForgeDock Pipeline Impact — last ${days} days`,
325
+ ``,
326
+ `**Repo:** \`${repo}\``,
327
+ ``,
328
+ `### Issues`,
329
+ `| Metric | Value |`,
330
+ `|--------|-------|`,
331
+ `| Closed | ${approxIssue}${issues.closed} |`,
332
+ `| Pipeline-driven (FORGE annotations) | ${approxIssue}${issues.annotated} (${issues.annotatedPct}%) |`,
333
+ `| Median open→close | ${fmtMinutes(issues.medianTtc)} |`,
334
+ `| p90 open→close | ${fmtMinutes(issues.p90Ttc)} |`,
335
+ `| Machine-filed intent | ${issues.machineFiled} (${issues.machineFiledPct}%) |`,
336
+ `| Defects caught by review | ${issues.reviewFindings} |`,
337
+ `| Invalid/discarded | ${issues.invalid} |`,
338
+ ``,
339
+ `### Pull Requests`,
340
+ `| Metric | Value |`,
341
+ `|--------|-------|`,
342
+ `| Merged | ${approxPr}${prs.merged} |`,
343
+ `| Linked to issue | ${approxPr}${prs.linked} (${prs.linkedPct}%) |`,
344
+ ``,
345
+ ];
346
+
347
+ if (!quiet) {
348
+ lines.push(
349
+ `---`,
350
+ `*Generated by [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock). Cross-repo fleet view: [forgedock.com/fleet](https://forgedock.com/fleet)*`,
351
+ ``,
352
+ );
353
+ }
354
+
355
+ return lines.join("\n");
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // CLI entry point
360
+ // ---------------------------------------------------------------------------
361
+
362
+ export async function runReport(args) {
363
+ // Parse flags
364
+ let days = 30;
365
+ let mdMode = false;
366
+ let jsonMode = false;
367
+ let quiet = false;
368
+
369
+ for (let i = 0; i < args.length; i++) {
370
+ const a = args[i];
371
+ if (a === "--days" || a === "-d") { days = parseInt(args[++i], 10) || 30; }
372
+ else if (a.startsWith("--days=")) { days = parseInt(a.slice(7), 10) || 30; }
373
+ else if (a === "--md") { mdMode = true; }
374
+ else if (a === "--json") { jsonMode = true; }
375
+ else if (a === "--quiet" || a === "-q") { quiet = true; }
376
+ }
377
+
378
+ // Auth check
379
+ if (!isGhAuthenticated()) {
380
+ process.stderr.write(
381
+ `\n ${RED}Error: gh CLI is not authenticated.${RESET}\n` +
382
+ ` Fix: run ${cyan("gh auth login")} and try again.\n\n`
383
+ );
384
+ process.exit(1);
385
+ }
386
+
387
+ // Resolve owner/repo from forge.yaml
388
+ const config = readForgeYaml(process.cwd());
389
+ if (!config || !config.project) {
390
+ process.stderr.write(
391
+ `\n ${RED}Error: forge.yaml not found or missing project section.${RESET}\n` +
392
+ ` Fix: run ${cyan("npx forgedock init")} to generate it.\n\n`
393
+ );
394
+ process.exit(1);
395
+ }
396
+
397
+ const project = config.project;
398
+ const owner = project.owner;
399
+ const repo = project.repo;
400
+
401
+ if (!owner || !repo) {
402
+ process.stderr.write(
403
+ `\n ${RED}Error: forge.yaml is missing project.owner or project.repo.${RESET}\n` +
404
+ ` Fix: run ${cyan("npx forgedock init")} to regenerate it.\n\n`
405
+ );
406
+ process.exit(1);
407
+ }
408
+
409
+ // Validate owner/repo before they ever reach a subprocess call — a hostile
410
+ // forge.yaml (e.g. `owner: "x; echo pwned"`) must be rejected here, not
411
+ // passed through. See #1586.
412
+ if (!isValidGhIdentifier(owner) || !isValidGhIdentifier(repo)) {
413
+ process.stderr.write(
414
+ `\n ${RED}Error: forge.yaml project.owner/project.repo contains invalid characters.${RESET}\n` +
415
+ ` Fix: owner and repo must match ${cyan("/^[\\w.-]+$/")} (letters, digits, ., _, -).\n\n`
416
+ );
417
+ process.exit(1);
418
+ }
419
+
420
+ // Check for ForgeDock history
421
+ const testCheck = ghJson(["issue", "list", "-R", `${owner}/${repo}`, "--limit", "1", "--state", "closed", "--json", "number"]) ?? [];
422
+ if (!testCheck.length) {
423
+ process.stdout.write(
424
+ `\n ${YELLOW}No closed issues found in ${owner}/${repo}.${RESET}\n` +
425
+ ` Run ${cyan("/work-on")} on your first issue to start building a pipeline history.\n\n`
426
+ );
427
+ process.exit(0);
428
+ }
429
+
430
+ // Compute stats
431
+ let stats;
432
+ try {
433
+ stats = await computeStats({ owner, repo, days });
434
+ } catch (err) {
435
+ process.stderr.write(`\n ${RED}Error computing stats: ${err.message}${RESET}\n\n`);
436
+ process.exit(1);
437
+ }
438
+
439
+ // Output
440
+ if (jsonMode) {
441
+ process.stdout.write(JSON.stringify(stats, null, 2) + "\n");
442
+ } else if (mdMode) {
443
+ process.stdout.write(renderMarkdown(stats, quiet) + "\n");
444
+ } else {
445
+ process.stdout.write(renderTerminal(stats) + "\n");
446
+ }
447
+ }
package/bin/runner.mjs CHANGED
@@ -46,9 +46,9 @@ import {
46
46
  import { join, dirname, basename, relative, isAbsolute } from "path";
47
47
  import { execSync, spawnSync } from "child_process";
48
48
 
49
- const DEFAULT_MODEL = "claude-sonnet-4-5";
49
+ const DEFAULT_MODEL = "claude-sonnet-5";
50
50
  const DEFAULT_MAX_ITERATIONS = 50;
51
- const DEFAULT_MAX_TOKENS = 8192;
51
+ const DEFAULT_MAX_TOKENS = 16384;
52
52
  // Default wall-clock limit for a single run_bash command. Chosen to be
53
53
  // generous enough for CI steps (git clones, test suites) while bounding
54
54
  // the worst-case hang to 5 minutes. Override via FORGEDOCK_BASH_TIMEOUT (ms).
@@ -582,6 +582,29 @@ export function getToolHandlers(cwd) {
582
582
  });
583
583
  const stdout = result.stdout ? String(result.stdout) : "";
584
584
  const stderr = result.stderr ? String(result.stderr) : "";
585
+ // Check ENOBUFS BEFORE the timedOut fallback. When a command both exceeds
586
+ // maxBuffer AND runs past timeoutMs, both result.error.code === "ENOBUFS"
587
+ // and elapsedMs >= timeoutMs are simultaneously true. The elapsedMs
588
+ // fallback (check 2 below) is a heuristic catch-all that must not
589
+ // override a specific, authoritative error code. ENOBUFS is always the
590
+ // correct diagnosis when present — surface it first. (issue #1364)
591
+ if (result.error?.code === "ENOBUFS") {
592
+ // The process ran but produced more output than maxBuffer allows.
593
+ // spawnSync populates result.stdout/stderr up to the limit before
594
+ // setting result.error — surface what was captured so the agent has
595
+ // actionable context rather than a misleading "failed to start" error.
596
+ const partial = (stdout + stderr).trim();
597
+ const bufDisplay =
598
+ maxBuffer < 1024
599
+ ? `${maxBuffer} bytes`
600
+ : maxBuffer < 1024 * 1024
601
+ ? `${Math.round(maxBuffer / 1024)}KB`
602
+ : `${Math.round(maxBuffer / (1024 * 1024))}MB`;
603
+ throw new Error(
604
+ `Command output exceeded ${bufDisplay} buffer limit (output truncated).` +
605
+ (partial ? `\nPartial output:\n${partial}` : ""),
606
+ );
607
+ }
585
608
  // Detect timeout. Two reliable indicators:
586
609
  // 1. result.error.code === "ETIMEDOUT" — Node sets this on the error
587
610
  // object specifically when spawnSync's own `timeout` option fires
@@ -602,6 +625,8 @@ export function getToolHandlers(cwd) {
602
625
  //
603
626
  // Gate all of this on `result.status === null` so a legitimately-
604
627
  // completed process near the timeout boundary is never misreported.
628
+ // ENOBUFS is excluded above — it is checked first so a command that both
629
+ // overflows the buffer AND exceeds the timeout gets the correct message.
605
630
  const elapsedMs = Date.now() - startMs;
606
631
  const timedOut =
607
632
  result.status === null &&
@@ -615,18 +640,6 @@ export function getToolHandlers(cwd) {
615
640
  (partial ? `\nPartial output:\n${partial}` : ""),
616
641
  );
617
642
  }
618
- if (result.error?.code === "ENOBUFS") {
619
- // The process ran but produced more output than maxBuffer allows.
620
- // spawnSync populates result.stdout/stderr up to the limit before
621
- // setting result.error — surface what was captured so the agent has
622
- // actionable context rather than a misleading "failed to start" error.
623
- const partial = (stdout + stderr).trim();
624
- const bufMB = Math.round(maxBuffer / (1024 * 1024));
625
- throw new Error(
626
- `Command output exceeded ${bufMB}MB buffer limit (output truncated).` +
627
- (partial ? `\nPartial output:\n${partial}` : ""),
628
- );
629
- }
630
643
  if (result.error) {
631
644
  // spawnSync-level failure to even launch the process (e.g. ENOENT
632
645
  // for a missing shell binary) — distinct from a nonzero exit status
@@ -676,20 +689,36 @@ export function renderDryRun(ctx) {
676
689
 
677
690
  /**
678
691
  * Render the pipeline summary card emitted on completion.
679
- * @param {{command: string, args: string[], iterations: number, stopReason: string}} ctx
692
+ * @param {{command: string, args: string[], iterations: number, stopReason: string, usage?: object|null}} ctx
680
693
  * @returns {string}
681
694
  */
682
695
  export function renderSummaryCard(ctx) {
683
- const { command, args, iterations, stopReason } = ctx;
696
+ const { command, args, iterations, stopReason, usage = null } = ctx;
684
697
  const argStr = Array.isArray(args) ? args.join(" ") : String(args ?? "");
685
- return [
698
+ const lines = [
686
699
  ``,
687
700
  `┌─ ForgeDock pipeline summary ────────────────────────────`,
688
701
  `│ command: /${command} ${argStr}`.trimEnd(),
689
702
  `│ iterations: ${iterations}`,
690
703
  `│ stop: ${stopReason}`,
704
+ ];
705
+ if (usage) {
706
+ lines.push(
707
+ `│ tokens: ${usage.input_tokens} in / ${usage.output_tokens} out`,
708
+ `│ cache: ${usage.cache_read_input_tokens} read / ${usage.cache_creation_input_tokens} write`,
709
+ );
710
+ } else {
711
+ lines.push(`│ tokens: N/A`);
712
+ }
713
+ lines.push(
691
714
  `└─────────────────────────────────────────────────────────`,
692
- ].join("\n");
715
+ );
716
+ if (usage) {
717
+ lines.push(
718
+ `FORGE:USAGE_JSON:${JSON.stringify(usage)}`,
719
+ );
720
+ }
721
+ return lines.join("\n");
693
722
  }
694
723
 
695
724
  // ---------------------------------------------------------------------------
@@ -730,7 +759,7 @@ export async function runCommand(opts = {}) {
730
759
 
731
760
  if (dryRun) {
732
761
  logger.log(renderDryRun({ spec, systemPrompt, userMessage, model, maxIterations }));
733
- return { status: "dry-run", command: spec.name, args, specPath: spec.path };
762
+ return { status: "dry-run", command: spec.name, args, specPath: spec.path, model };
734
763
  }
735
764
 
736
765
  if (!apiKey) {
@@ -771,6 +800,15 @@ export async function runCommand(opts = {}) {
771
800
  const handlers = getToolHandlers(cwd);
772
801
  const messages = [{ role: "user", content: userMessage }];
773
802
 
803
+ // Accumulate token usage across all messages.create() calls in this run.
804
+ // Field names match the Anthropic SDK's response.usage object exactly.
805
+ const usage = {
806
+ input_tokens: 0,
807
+ output_tokens: 0,
808
+ cache_creation_input_tokens: 0,
809
+ cache_read_input_tokens: 0,
810
+ };
811
+
774
812
  let iterations = 0;
775
813
  while (iterations < maxIterations) {
776
814
  iterations++;
@@ -783,6 +821,14 @@ export async function runCommand(opts = {}) {
783
821
  messages,
784
822
  });
785
823
 
824
+ // Accumulate usage — guard each field for null/undefined (SDK omits
825
+ // cache fields when prompt caching is not active).
826
+ const ru = response.usage ?? {};
827
+ usage.input_tokens += ru.input_tokens ?? 0;
828
+ usage.output_tokens += ru.output_tokens ?? 0;
829
+ usage.cache_creation_input_tokens += ru.cache_creation_input_tokens ?? 0;
830
+ usage.cache_read_input_tokens += ru.cache_read_input_tokens ?? 0;
831
+
786
832
  messages.push({ role: "assistant", content: response.content });
787
833
 
788
834
  for (const block of response.content) {
@@ -802,6 +848,7 @@ export async function runCommand(opts = {}) {
802
848
  args,
803
849
  iterations,
804
850
  stopReason: response.stop_reason,
851
+ usage,
805
852
  }),
806
853
  );
807
854
  return {
@@ -809,6 +856,8 @@ export async function runCommand(opts = {}) {
809
856
  command: spec.name,
810
857
  iterations,
811
858
  stopReason: response.stop_reason,
859
+ usage,
860
+ model,
812
861
  };
813
862
  }
814
863
 
@@ -841,7 +890,8 @@ export async function runCommand(opts = {}) {
841
890
  args,
842
891
  iterations,
843
892
  stopReason: "max_iterations",
893
+ usage,
844
894
  }),
845
895
  );
846
- return { status: "max-iterations", command: spec.name, iterations };
896
+ return { status: "max-iterations", command: spec.name, iterations, usage, model };
847
897
  }