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
package/bin/registry.mjs CHANGED
@@ -376,3 +376,142 @@ export async function clearNudgeSeen(dir) {
376
376
  delete registry.nudgeSeen[absDir];
377
377
  });
378
378
  }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Claude Code version detection and breakpoints resolution (issue #1252)
382
+ // ---------------------------------------------------------------------------
383
+
384
+ // execFileSync is not imported at the top of this module (it only imports fs
385
+ // builtins). Import it lazily here using createRequire — safe in Node ESM.
386
+ import { createRequire } from "module";
387
+ const _require = createRequire(import.meta.url);
388
+
389
+ /**
390
+ * Detect the installed Claude Code version by running `claude --version`.
391
+ *
392
+ * Returns the version string (e.g. "2.1.197") or null if Claude Code is not
393
+ * installed or the version cannot be parsed. Never throws — callers can safely
394
+ * check for null and degrade gracefully.
395
+ *
396
+ * @returns {string | null} Semver-style version string or null.
397
+ */
398
+ export function detectClaudeVersion() {
399
+ try {
400
+ const { execFileSync } = _require("child_process");
401
+ const raw = execFileSync("claude", ["--version"], {
402
+ encoding: "utf-8",
403
+ stdio: ["pipe", "pipe", "pipe"],
404
+ timeout: 5000,
405
+ }).trim();
406
+ // Claude Code outputs e.g. "Claude Code 2.1.197" or just "2.1.197"
407
+ const m = raw.match(/(\d+\.\d+\.\d+)/);
408
+ return m ? m[1] : null;
409
+ } catch {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Compare two semver-style version strings (e.g. "2.1.152" vs "2.1.197").
416
+ *
417
+ * Returns:
418
+ * -1 if a < b
419
+ * 0 if a === b
420
+ * 1 if a > b
421
+ *
422
+ * Non-numeric components are compared lexicographically. Handles missing
423
+ * components by treating them as 0.
424
+ *
425
+ * @param {string} a
426
+ * @param {string} b
427
+ * @returns {-1 | 0 | 1}
428
+ */
429
+ export function compareVersions(a, b) {
430
+ const pa = String(a).split(".").map((s) => parseInt(s, 10) || 0);
431
+ const pb = String(b).split(".").map((s) => parseInt(s, 10) || 0);
432
+ const len = Math.max(pa.length, pb.length);
433
+ for (let i = 0; i < len; i++) {
434
+ const va = pa[i] ?? 0;
435
+ const vb = pb[i] ?? 0;
436
+ if (va < vb) return -1;
437
+ if (va > vb) return 1;
438
+ }
439
+ return 0;
440
+ }
441
+
442
+ /**
443
+ * Load and parse docs/claude-breakpoints.json relative to FORGE_HOME.
444
+ *
445
+ * Returns the parsed breakpoints array or an empty array on any error (file
446
+ * missing, malformed JSON, etc.). Consumers must pass the FORGE_HOME path.
447
+ *
448
+ * @param {string} forgeHome - Absolute path to the ForgeDock repo root.
449
+ * @returns {Array<object>} Array of breakpoint objects.
450
+ */
451
+ export function loadBreakpoints(forgeHome) {
452
+ try {
453
+ const bpPath = join(forgeHome, "docs", "claude-breakpoints.json");
454
+ const raw = readFileSync(bpPath, "utf-8");
455
+ const parsed = JSON.parse(raw);
456
+ return Array.isArray(parsed.breakpoints) ? parsed.breakpoints : [];
457
+ } catch {
458
+ return [];
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Resolve all breakpoints applicable to a given Claude Code version.
464
+ *
465
+ * A breakpoint is "applicable" if its version is > the supplied version,
466
+ * meaning the user is on an older version and will be affected by this
467
+ * breakpoint. This surfaces the set of features/changes the user is missing.
468
+ *
469
+ * If no version is supplied, all breakpoints are returned (useful for
470
+ * listing the full registry).
471
+ *
472
+ * Results are sorted by version ascending.
473
+ *
474
+ * @param {Array<object>} breakpoints - Array from loadBreakpoints().
475
+ * @param {string | null} installedVersion - The user's installed Claude Code version, or null.
476
+ * @returns {Array<object>} Breakpoints the user is missing or all breakpoints if no version.
477
+ */
478
+ export function resolveBreakpoints(breakpoints, installedVersion) {
479
+ if (!installedVersion) return [...breakpoints].sort((a, b) => compareVersions(a.version, b.version));
480
+ return breakpoints
481
+ .filter((bp) => compareVersions(bp.version, installedVersion) > 0)
482
+ .sort((a, b) => compareVersions(a.version, b.version));
483
+ }
484
+
485
+ /**
486
+ * Resolve a single breakpoint by feature_key.
487
+ *
488
+ * Returns the breakpoint object with the matching `feature_key`, or null
489
+ * if not found. Useful for conditional logic: "does this install support
490
+ * effort_levels?" → `resolveBreakpoint(bps, 'effort_levels')`.
491
+ *
492
+ * @param {Array<object>} breakpoints - Array from loadBreakpoints().
493
+ * @param {string} featureKey - The `feature_key` value to look up.
494
+ * @returns {object | null} The matching breakpoint or null.
495
+ */
496
+ export function resolveBreakpoint(breakpoints, featureKey) {
497
+ return breakpoints.find((bp) => bp.feature_key === featureKey) ?? null;
498
+ }
499
+
500
+ /**
501
+ * Check whether a detected Claude Code version meets or exceeds the version
502
+ * required for a given feature_key.
503
+ *
504
+ * Returns true if the feature is available, false if not, and null if the
505
+ * feature_key is unknown or the installed version is not detectable.
506
+ *
507
+ * @param {Array<object>} breakpoints - Array from loadBreakpoints().
508
+ * @param {string} featureKey - Feature to check.
509
+ * @param {string | null} installedVersion - Detected Claude Code version.
510
+ * @returns {boolean | null}
511
+ */
512
+ export function hasFeature(breakpoints, featureKey, installedVersion) {
513
+ if (!installedVersion) return null;
514
+ const bp = resolveBreakpoint(breakpoints, featureKey);
515
+ if (!bp) return null;
516
+ return compareVersions(installedVersion, bp.version) >= 0;
517
+ }
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
+ }