forgedock 1.1.1 → 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.
- package/README.md +53 -8
- package/bin/batch-runner.mjs +339 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +20 -0
- package/bin/engine/state.mjs +8 -3
- package/bin/engine-cli.mjs +177 -5
- package/bin/forgedock.mjs +409 -26
- package/bin/hooks/interactive-engine.mjs +509 -0
- package/bin/hooks/pre-tool-use.mjs +498 -0
- package/bin/hooks/subagent-stop-enforce.mjs +197 -0
- package/bin/journey.mjs +72 -14
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +68 -19
- package/bin/settings-hook.mjs +235 -1
- package/commands/adopt.md +4 -1
- package/commands/analytics.md +4 -1
- package/commands/audit-agents.md +110 -27
- package/commands/audit.md +20 -1
- package/commands/autopilot.md +80 -8
- package/commands/changelog.md +2 -1
- package/commands/ci-audit.md +2 -1
- package/commands/cleanup.md +161 -1
- package/commands/compat-audit.md +2 -1
- package/commands/deploy-info.md +58 -1
- package/commands/design-bench.md +5 -1
- package/commands/design-render-critique-loop.md +1 -1
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/forge-stats.md +121 -2
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +3 -0
- package/commands/incident-response.md +2 -1
- package/commands/issue.md +23 -2
- package/commands/milestone.md +77 -12
- package/commands/optimize.md +2 -1
- package/commands/orchestrate/config.md +67 -0
- package/commands/orchestrate/phase-1-resolve.md +159 -0
- package/commands/orchestrate/phase-2-triage.md +126 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
- package/commands/orchestrate/phase-3-dependency.md +591 -0
- package/commands/orchestrate/phase-4-execution.md +962 -0
- package/commands/orchestrate/phase-5-cleanup.md +47 -0
- package/commands/orchestrate/phase-6-report.md +198 -0
- package/commands/orchestrate/safety.md +62 -0
- package/commands/orchestrate.md +25 -2045
- package/commands/pipeline-health/config.md +34 -0
- package/commands/pipeline-health/phase-1-context.md +180 -0
- package/commands/pipeline-health/phase-2-metrics.md +1729 -0
- package/commands/pipeline-health/phase-3-analyze.md +204 -0
- package/commands/pipeline-health/phase-4-proposals.md +117 -0
- package/commands/pipeline-health/phase-5-report.md +284 -0
- package/commands/pipeline-health/phase-6-summary.md +52 -0
- package/commands/pipeline-health.md +25 -2401
- package/commands/pipeline-resume.md +24 -1
- package/commands/pipeline-status.md +65 -0
- package/commands/qa-sweep.md +4 -1
- package/commands/quality-gate.md +248 -10
- package/commands/replay.md +2 -1
- package/commands/review-pr-agents/api.md +135 -0
- package/commands/review-pr-agents/auth.md +137 -0
- package/commands/review-pr-agents/billing.md +101 -0
- package/commands/review-pr-agents/concurrency.md +145 -0
- package/commands/review-pr-agents/database.md +103 -0
- package/commands/review-pr-agents/frontend.md +89 -0
- package/commands/review-pr-agents/infra.md +385 -0
- package/commands/review-pr-agents/protocols.md +185 -0
- package/commands/review-pr-agents/scraper.md +185 -0
- package/commands/review-pr-agents/security.md +406 -0
- package/commands/review-pr-agents.md +45 -1722
- package/commands/review-pr-staging.md +196 -40
- package/commands/review-pr.md +591 -52
- package/commands/rollback.md +2 -1
- package/commands/scope.md +2 -1
- package/commands/security-audit.md +2 -1
- package/commands/signal-planner.md +623 -0
- package/commands/sync-ecosystem.md +1 -0
- package/commands/test-gate.md +17 -1
- package/commands/validate.md +2 -1
- package/commands/work-on/build/architect.md +28 -0
- package/commands/work-on/build/context.md +2 -0
- package/commands/work-on/build/implement.md +3 -1
- package/commands/work-on/build/validate.md +26 -7
- package/commands/work-on/build.md +163 -24
- package/commands/work-on/close.md +130 -3
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +24 -4
- package/commands/work-on-monolithic.md +7 -2
- package/commands/work-on.md +202 -4
- package/docs/BRAND.md +14 -7
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +5 -8
- package/docs/FORGE-PROTOCOL.md +15 -1064
- package/docs/anthropic-devrel-outreach.md +2 -2
- package/docs/articles/command-decomposition-token-savings.md +214 -0
- package/docs/articles/model-release-playbook.md +131 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
- package/docs/claude-breakpoints.json +75 -5
- package/docs/design/abc-benchmark.md +1 -1
- package/docs/design/design-spec-schema.md +1 -1
- package/docs/design/divergent-generation.md +1 -1
- package/docs/design/eval-gate-runbook.md +178 -0
- package/docs/design/show-hn-draft.md +26 -1
- package/docs/design/token-cost-baseline-1298.md +59 -0
- package/docs/eval/README.md +117 -0
- package/docs/eval/corpus/seed-v1.json +6 -0
- package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
- package/docs/site/annotations-explained.md +24 -0
- package/docs/site/command-learning-path.md +51 -14
- package/docs/site/command-reference.md +339 -113
- package/docs/site/for-companies.md +95 -0
- package/docs/site/forge-annotation-protocol.md +12 -256
- package/docs/site/getting-started.md +43 -4
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +9 -1
- package/docs/spec/eval-run-result.md +147 -0
- package/docs/spec/forge-protocol-v1.md +441 -18
- package/docs/spec/label-state-machine.md +55 -0
- package/docs/spec/review-protocol.md +158 -0
- package/docs/spec-graph-schema.md +28 -0
- package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
- package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
- package/examples/forgedock-demo/README.md +41 -22
- package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
- package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
- package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
- package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
- package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
- package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
- package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
- package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
- package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
- package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
- package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
- package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
- package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
- package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
- package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
- package/examples/forgedock-demo/scripts/smoke.js +18 -1
- package/examples/forgedock-demo/src/db.js +36 -11
- package/examples/forgedock-demo/src/routes/notes.js +106 -9
- package/examples/forgedock-demo/src/server.js +4 -1
- package/package.json +3 -1
- package/packages/protocol/LICENSE +21 -0
- package/packages/protocol/src/cli.js +135 -0
- package/packages/protocol/src/emit.js +146 -0
- package/packages/protocol/src/index.js +43 -0
- package/packages/protocol/src/parse.js +229 -0
- package/packages/protocol/src/types.js +171 -0
- package/packages/protocol/src/validate.js +122 -0
- package/scripts/bench-topology-cost.mjs +372 -0
- package/scripts/check-command-docs-drift.sh +198 -0
- package/scripts/check-protocol-restatements.sh +176 -0
- package/scripts/check-registry/README.md +72 -0
- package/scripts/check-registry/manifest.json +6 -0
- package/scripts/code-index.sh +454 -0
- package/scripts/conformance-check.mjs +168 -0
- package/scripts/doctor-pipeline-state.sh +484 -0
- package/scripts/eval-gate-baseline.json +17 -0
- package/scripts/eval-gate-scorecard.mjs +242 -0
- package/scripts/eval-scorecard-aggregator.mjs +252 -0
- package/scripts/flaky-quarantine.sh +241 -0
- package/scripts/forge-annotation.sh +567 -0
- package/scripts/issue-dedup.sh +236 -0
- package/scripts/validate-annotation-node.mjs +233 -0
- package/scripts/worktree-lifecycle.sh +3 -3
- package/templates/devdocs/agent/using-forgedock.md +14 -0
- 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-
|
|
49
|
+
const DEFAULT_MODEL = "claude-sonnet-5";
|
|
50
50
|
const DEFAULT_MAX_ITERATIONS = 50;
|
|
51
|
-
const DEFAULT_MAX_TOKENS =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -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,7 @@ export async function runCommand(opts = {}) {
|
|
|
809
856
|
command: spec.name,
|
|
810
857
|
iterations,
|
|
811
858
|
stopReason: response.stop_reason,
|
|
859
|
+
usage,
|
|
812
860
|
};
|
|
813
861
|
}
|
|
814
862
|
|
|
@@ -841,7 +889,8 @@ export async function runCommand(opts = {}) {
|
|
|
841
889
|
args,
|
|
842
890
|
iterations,
|
|
843
891
|
stopReason: "max_iterations",
|
|
892
|
+
usage,
|
|
844
893
|
}),
|
|
845
894
|
);
|
|
846
|
-
return { status: "max-iterations", command: spec.name, iterations };
|
|
895
|
+
return { status: "max-iterations", command: spec.name, iterations, usage };
|
|
847
896
|
}
|