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.
- package/README.md +255 -211
- package/bin/batch-runner.mjs +339 -0
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +168 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +32 -0
- package/bin/engine-cli.mjs +222 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +896 -1540
- 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 +841 -0
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +136 -20
- package/bin/settings-hook.mjs +346 -0
- package/bin/tui.mjs +6 -3
- 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 +84 -12
- 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 +63 -4
- package/commands/design-bench.md +6 -1
- package/commands/design-render-critique-loop.md +2 -1
- package/commands/design.md +1 -0
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/forge-stats.md +122 -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 +24 -3
- 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 -2027
- 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 +326 -42
- 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 +612 -56
- 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 +19 -5
- package/commands/work-on/build/validate.md +62 -6
- package/commands/work-on/build.md +187 -30
- package/commands/work-on/close.md +132 -5
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +48 -4
- package/commands/work-on-monolithic.md +8 -2
- package/commands/work-on.md +247 -7
- package/docs/BRAND.md +16 -5
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +44 -42
- 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 +58 -12
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +10 -2
- 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/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -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 +12 -4
- 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/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/devdocs/agent/using-forgedock.md +14 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/eval-gate-scorecard.mjs — CI regression gate comparator (#1286)
|
|
4
|
+
*
|
|
5
|
+
* Deterministic, zero-network comparator invoked by `.github/workflows/eval-gate.yml`.
|
|
6
|
+
* Compares a fresh scorecard (produced by scripts/eval-scorecard-aggregator.mjs, #1285)
|
|
7
|
+
* against the committed baseline at scripts/eval-gate-baseline.json and fails CI when
|
|
8
|
+
* the one-shot success rate regresses beyond a defined threshold.
|
|
9
|
+
*
|
|
10
|
+
* It does NOT call any model, network, or live SDK — it only diffs two scorecard
|
|
11
|
+
* JSON objects. See docs/design/eval-gate-runbook.md for the full behavior spec.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node scripts/eval-gate-scorecard.mjs <scorecard.json> <baseline.json> # compare mode
|
|
15
|
+
* node scripts/eval-gate-scorecard.mjs <scorecard.json> --seed # seed mode
|
|
16
|
+
*
|
|
17
|
+
* Compare mode: exits 0 (pass) unless the fresh success_rate_pct drops more than
|
|
18
|
+
* RATE_THRESHOLD_PP percentage points below the baseline. The rate assertion is
|
|
19
|
+
* skipped (still exits 0) when the fresh run has fewer than MIN_ISSUES_FOR_RATE_ASSERTION
|
|
20
|
+
* issues — a subset run is too small for a meaningful rate comparison.
|
|
21
|
+
*
|
|
22
|
+
* Seed mode: overwrites scripts/eval-gate-baseline.json with the fresh scorecard.
|
|
23
|
+
* Used after a deliberate, confirmed improvement (see runbook "Updating the baseline").
|
|
24
|
+
*
|
|
25
|
+
* Exit codes:
|
|
26
|
+
* 0 = gate passed (or rate assertion skipped) / baseline seeded successfully
|
|
27
|
+
* 1 = gate failed (regression) or invalid input (bad JSON, missing required field)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { dirname, join } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
|
|
34
|
+
/** Fields the fresh scorecard must have (matches eval-scorecard-aggregator.mjs's output). */
|
|
35
|
+
export const REQUIRED_FIELDS = [
|
|
36
|
+
"schema_version",
|
|
37
|
+
"issues_run",
|
|
38
|
+
"successful_runs",
|
|
39
|
+
"failed_runs",
|
|
40
|
+
"success_rate_pct",
|
|
41
|
+
"run_mode",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Regression threshold in percentage points (see docs/design/eval-gate-runbook.md). */
|
|
45
|
+
export const RATE_THRESHOLD_PP = 5;
|
|
46
|
+
|
|
47
|
+
/** Below this many issues, the rate assertion is skipped as statistically meaningless. */
|
|
48
|
+
export const MIN_ISSUES_FOR_RATE_ASSERTION = 8;
|
|
49
|
+
|
|
50
|
+
/** Round to 2 decimal places (matches eval-scorecard-aggregator.mjs's success_rate_pct rounding). */
|
|
51
|
+
function round2(x) {
|
|
52
|
+
return Math.round(x * 100) / 100;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate a scorecard-shaped object. Throws a descriptive Error on any violation.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} sc
|
|
59
|
+
* @param {object} [opts]
|
|
60
|
+
* @param {string[]} [opts.requiredFields] - Fields that must be present.
|
|
61
|
+
* @param {string} [opts.label] - Label used in error messages (e.g. "fresh scorecard").
|
|
62
|
+
* @returns {void}
|
|
63
|
+
*/
|
|
64
|
+
export function validateScorecard(sc, { requiredFields = REQUIRED_FIELDS, label = "scorecard" } = {}) {
|
|
65
|
+
if (!sc || typeof sc !== "object" || Array.isArray(sc)) {
|
|
66
|
+
throw new Error(`${label} must be a JSON object`);
|
|
67
|
+
}
|
|
68
|
+
for (const field of requiredFields) {
|
|
69
|
+
if (!Object.prototype.hasOwnProperty.call(sc, field)) {
|
|
70
|
+
throw new Error(`${label} missing required field: "${field}"`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (typeof sc.success_rate_pct !== "number" || !Number.isFinite(sc.success_rate_pct)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${label}.success_rate_pct must be a finite number (got ${JSON.stringify(sc.success_rate_pct)})`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compare a fresh scorecard against the baseline and decide pass/fail.
|
|
82
|
+
* Does NOT re-validate beyond what's needed to compute the diff — call
|
|
83
|
+
* validateScorecard() first for full field validation (this function calls
|
|
84
|
+
* it internally, so it is safe to call directly).
|
|
85
|
+
*
|
|
86
|
+
* @param {object} fresh - Scorecard from the current CI run.
|
|
87
|
+
* @param {object} baseline - Committed baseline scorecard.
|
|
88
|
+
* @param {object} [opts]
|
|
89
|
+
* @param {number} [opts.thresholdPp] - Regression threshold in percentage points.
|
|
90
|
+
* @param {number} [opts.minIssuesForAssertion] - Min issues before the rate assertion applies.
|
|
91
|
+
* @returns {object} Scorecard-diff result — see docs/design/eval-gate-runbook.md
|
|
92
|
+
* "Reading the scorecard diff" for the field contract.
|
|
93
|
+
*/
|
|
94
|
+
export function evaluateGate(
|
|
95
|
+
fresh,
|
|
96
|
+
baseline,
|
|
97
|
+
{ thresholdPp = RATE_THRESHOLD_PP, minIssuesForAssertion = MIN_ISSUES_FOR_RATE_ASSERTION } = {},
|
|
98
|
+
) {
|
|
99
|
+
validateScorecard(fresh, { requiredFields: REQUIRED_FIELDS, label: "fresh scorecard" });
|
|
100
|
+
// Baseline may predate newer fields the aggregator emits — only success_rate_pct
|
|
101
|
+
// is strictly required for the comparison itself.
|
|
102
|
+
validateScorecard(baseline, { requiredFields: ["success_rate_pct"], label: "baseline" });
|
|
103
|
+
|
|
104
|
+
const freshIssuesRun = typeof fresh.issues_run === "number" ? fresh.issues_run : null;
|
|
105
|
+
const rateAssertionSkipped = freshIssuesRun !== null && freshIssuesRun < minIssuesForAssertion;
|
|
106
|
+
|
|
107
|
+
const dropPp = round2(baseline.success_rate_pct - fresh.success_rate_pct);
|
|
108
|
+
|
|
109
|
+
let pass;
|
|
110
|
+
let reason;
|
|
111
|
+
if (rateAssertionSkipped) {
|
|
112
|
+
pass = true;
|
|
113
|
+
reason =
|
|
114
|
+
`rate assertion skipped: fresh run has ${freshIssuesRun} issue(s), below the minimum of ` +
|
|
115
|
+
`${minIssuesForAssertion} required for a meaningful rate comparison`;
|
|
116
|
+
} else if (dropPp > thresholdPp) {
|
|
117
|
+
pass = false;
|
|
118
|
+
reason =
|
|
119
|
+
`REGRESSION: success rate dropped ${dropPp} pp (${fresh.success_rate_pct}% fresh vs ` +
|
|
120
|
+
`${baseline.success_rate_pct}% baseline). Threshold is ${thresholdPp} pp.`;
|
|
121
|
+
} else {
|
|
122
|
+
pass = true;
|
|
123
|
+
reason =
|
|
124
|
+
`success rate delta ${dropPp} pp is within the ${thresholdPp} pp threshold ` +
|
|
125
|
+
`(${fresh.success_rate_pct}% fresh vs ${baseline.success_rate_pct}% baseline).`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
pass,
|
|
130
|
+
fresh_success_rate_pct: fresh.success_rate_pct,
|
|
131
|
+
baseline_success_rate_pct: baseline.success_rate_pct,
|
|
132
|
+
drop_pp: dropPp,
|
|
133
|
+
threshold_pp: thresholdPp,
|
|
134
|
+
rate_assertion_skipped: rateAssertionSkipped,
|
|
135
|
+
fresh_issues_run: freshIssuesRun,
|
|
136
|
+
fresh_run_mode: fresh.run_mode ?? null,
|
|
137
|
+
fresh_run_id: fresh.run_id ?? null,
|
|
138
|
+
baseline_run_id: baseline.run_id ?? null,
|
|
139
|
+
reason,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// CLI entry point
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function readJsonFile(path) {
|
|
148
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Actionable next-steps text appended to a gate-failure message (see runbook). */
|
|
152
|
+
function actionableStepsText() {
|
|
153
|
+
return (
|
|
154
|
+
"\n" +
|
|
155
|
+
"1. Review the diff in commands/ to identify what changed.\n" +
|
|
156
|
+
"2. Re-run the full corpus locally:\n" +
|
|
157
|
+
" node scripts/eval-harness-runner.mjs --mode full\n" +
|
|
158
|
+
"3. If the regression is real: fix the spec, re-run, update the baseline\n" +
|
|
159
|
+
" (see docs/design/eval-gate-runbook.md).\n" +
|
|
160
|
+
"4. If the regression is a false positive: see the Override section in\n" +
|
|
161
|
+
" docs/design/eval-gate-runbook.md."
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Resolve scripts/eval-gate-baseline.json relative to this script's own directory,
|
|
166
|
+
* so seed mode is robust regardless of the invoking CWD. */
|
|
167
|
+
function defaultBaselinePath() {
|
|
168
|
+
return join(dirname(fileURLToPath(import.meta.url)), "eval-gate-baseline.json");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function usageError() {
|
|
172
|
+
process.stderr.write(
|
|
173
|
+
"ERROR: usage: eval-gate-scorecard.mjs <scorecard.json> <baseline.json>\n" +
|
|
174
|
+
" or: eval-gate-scorecard.mjs <scorecard.json> --seed\n",
|
|
175
|
+
);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function main() {
|
|
180
|
+
const [scorecardArg, secondArg] = process.argv.slice(2);
|
|
181
|
+
|
|
182
|
+
if (!scorecardArg) usageError();
|
|
183
|
+
|
|
184
|
+
let fresh;
|
|
185
|
+
try {
|
|
186
|
+
fresh = readJsonFile(scorecardArg);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
process.stderr.write(`ERROR: cannot read scorecard "${scorecardArg}": ${e.message}\n`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (secondArg === "--seed") {
|
|
193
|
+
try {
|
|
194
|
+
validateScorecard(fresh, { requiredFields: REQUIRED_FIELDS, label: "scorecard" });
|
|
195
|
+
} catch (e) {
|
|
196
|
+
process.stderr.write(`ERROR: ${e.message}\n`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
const baselinePath = defaultBaselinePath();
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(baselinePath, JSON.stringify(fresh, null, 2) + "\n");
|
|
202
|
+
} catch (e) {
|
|
203
|
+
process.stderr.write(`ERROR: cannot write baseline "${baselinePath}": ${e.message}\n`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
process.stdout.write(`Baseline updated: ${baselinePath}\n`);
|
|
207
|
+
process.stdout.write(`New baseline success_rate_pct: ${fresh.success_rate_pct}\n`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!secondArg) usageError();
|
|
212
|
+
|
|
213
|
+
let baseline;
|
|
214
|
+
try {
|
|
215
|
+
baseline = readJsonFile(secondArg);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
process.stderr.write(`ERROR: cannot read baseline "${secondArg}": ${e.message}\n`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let result;
|
|
222
|
+
try {
|
|
223
|
+
result = evaluateGate(fresh, baseline);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
process.stderr.write(`ERROR: ${e.message}\n`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
230
|
+
|
|
231
|
+
if (!result.pass) {
|
|
232
|
+
process.stderr.write(`\neval-gate FAILED: ${result.reason}${actionableStepsText()}\n`);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
process.stdout.write(`\neval-gate PASSED: ${result.reason}\n`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Run as CLI only when invoked directly, not when imported by tests.
|
|
240
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
241
|
+
main();
|
|
242
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/eval-scorecard-aggregator.mjs — Per-run results → scorecard aggregator (#1285)
|
|
4
|
+
*
|
|
5
|
+
* Deterministic, zero-network aggregator for the ForgeDock headless eval harness.
|
|
6
|
+
* Reads an array of per-run results (one per corpus issue) produced by
|
|
7
|
+
* bin/batch-runner.mjs and emits a scorecard JSON in the format consumed by
|
|
8
|
+
* scripts/eval-gate-scorecard.mjs (the CI regression gate, issue #1286).
|
|
9
|
+
*
|
|
10
|
+
* It does NOT call any model, network, or live SDK — it only aggregates the
|
|
11
|
+
* structured output that bin/batch-runner.mjs produces.
|
|
12
|
+
* See docs/spec/eval-run-result.md for the full JSON schema.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node scripts/eval-scorecard-aggregator.mjs <runs.json> # read file, print scorecard JSON
|
|
16
|
+
* node scripts/eval-scorecard-aggregator.mjs - # read runs JSON from stdin
|
|
17
|
+
* cat runs.json | node scripts/eval-scorecard-aggregator.mjs
|
|
18
|
+
*
|
|
19
|
+
* Input format: a JSON object with a "runs" array of per-run result objects,
|
|
20
|
+
* plus optional metadata fields (corpus_version, run_id, run_mode, spec_sha).
|
|
21
|
+
*
|
|
22
|
+
* Output format: scorecard JSON compatible with scripts/eval-gate-scorecard.mjs.
|
|
23
|
+
*
|
|
24
|
+
* Methodology rule (enforced): fewer than MIN_RUNS valid, scoreable runs is a
|
|
25
|
+
* HARD ERROR — the script exits non-zero rather than emit a misleading rate.
|
|
26
|
+
* Mirrors bench-scorecard.mjs's MIN_RUNS methodology rule.
|
|
27
|
+
*
|
|
28
|
+
* Exit codes:
|
|
29
|
+
* 0 = scorecard emitted successfully
|
|
30
|
+
* 1 = invalid input (bad JSON, n < MIN_RUNS, missing required fields)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Minimum number of scoreable (non-error, non-incomplete) runs required. */
|
|
34
|
+
export const MIN_RUNS = 5;
|
|
35
|
+
|
|
36
|
+
/** Valid status values for a per-run result object. */
|
|
37
|
+
export const VALID_STATUSES = new Set(["success", "failure", "incomplete", "error"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validate an array of raw run-result objects.
|
|
41
|
+
* Throws a descriptive Error on any violation.
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown[]} runs
|
|
44
|
+
* @returns {void}
|
|
45
|
+
*/
|
|
46
|
+
export function validateRuns(runs) {
|
|
47
|
+
if (!Array.isArray(runs)) {
|
|
48
|
+
throw new Error('"runs" must be a JSON array');
|
|
49
|
+
}
|
|
50
|
+
if (runs.length === 0) {
|
|
51
|
+
throw new Error('"runs" array is empty — nothing to score');
|
|
52
|
+
}
|
|
53
|
+
for (let i = 0; i < runs.length; i++) {
|
|
54
|
+
const r = runs[i];
|
|
55
|
+
if (!r || typeof r !== "object") {
|
|
56
|
+
throw new Error(`run[${i}]: must be an object`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof r.issue !== "number" || !Number.isInteger(r.issue)) {
|
|
59
|
+
throw new Error(`run[${i}]: "issue" must be an integer (got ${JSON.stringify(r.issue)})`);
|
|
60
|
+
}
|
|
61
|
+
if (!VALID_STATUSES.has(r.status)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`run[${i}] (issue ${r.issue}): "status" must be one of ${[...VALID_STATUSES].join(", ")} (got ${JSON.stringify(r.status)})`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (typeof r.wallClockMs !== "number" || !Number.isFinite(r.wallClockMs) || r.wallClockMs < 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`run[${i}] (issue ${r.issue}): "wallClockMs" must be a non-negative finite number (got ${JSON.stringify(r.wallClockMs)})`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (typeof r.interventionCount !== "number" || !Number.isInteger(r.interventionCount) || r.interventionCount < 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`run[${i}] (issue ${r.issue}): "interventionCount" must be a non-negative integer (got ${JSON.stringify(r.interventionCount)})`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
// cost is optional/nullable — validate only when present and non-null
|
|
77
|
+
if (r.cost !== undefined && r.cost !== null) {
|
|
78
|
+
if (typeof r.cost !== "number" || !Number.isFinite(r.cost) || r.cost < 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`run[${i}] (issue ${r.issue}): "cost" must be a non-negative finite number or null (got ${JSON.stringify(r.cost)})`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Enforce minimum sample size on scoreable runs (excludes "error" / "incomplete").
|
|
87
|
+
const scoreable = runs.filter((r) => r.status === "success" || r.status === "failure");
|
|
88
|
+
if (scoreable.length < MIN_RUNS) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`only ${scoreable.length} scoreable run(s) (status "success" or "failure") found; ` +
|
|
91
|
+
`minimum is ${MIN_RUNS}. Add more corpus issues or fix erroring/incomplete runs.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compute the median of a sorted numeric array.
|
|
98
|
+
* Returns null for an empty array.
|
|
99
|
+
*
|
|
100
|
+
* @param {number[]} sorted - Must be pre-sorted ascending.
|
|
101
|
+
* @returns {number|null}
|
|
102
|
+
*/
|
|
103
|
+
export function median(sorted) {
|
|
104
|
+
const n = sorted.length;
|
|
105
|
+
if (n === 0) return null;
|
|
106
|
+
const mid = Math.floor(n / 2);
|
|
107
|
+
return n % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Arithmetic mean of a numeric array.
|
|
112
|
+
* Returns null for an empty array.
|
|
113
|
+
*
|
|
114
|
+
* @param {number[]} values
|
|
115
|
+
* @returns {number|null}
|
|
116
|
+
*/
|
|
117
|
+
export function mean(values) {
|
|
118
|
+
if (values.length === 0) return null;
|
|
119
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Round to 2 decimal places (matches eval-gate-scorecard.mjs success_rate_pct rounding). */
|
|
123
|
+
function round2(x) {
|
|
124
|
+
return x === null ? null : Math.round(x * 100) / 100;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Round to 3 decimal places for wall-clock stats. */
|
|
128
|
+
function round3(x) {
|
|
129
|
+
return x === null ? null : Math.round(x * 1000) / 1000;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Aggregate validated run-result objects into a scorecard compatible with
|
|
134
|
+
* scripts/eval-gate-scorecard.mjs (#1286).
|
|
135
|
+
*
|
|
136
|
+
* Does NOT re-validate — call validateRuns() first.
|
|
137
|
+
*
|
|
138
|
+
* @param {object[]} runs
|
|
139
|
+
* @param {object} [meta]
|
|
140
|
+
* @param {string|null} [meta.run_id] - Unique run identifier.
|
|
141
|
+
* @param {string|null} [meta.corpus_version] - Corpus version label.
|
|
142
|
+
* @param {"full"|"subset"} [meta.run_mode] - "full" or "subset".
|
|
143
|
+
* @param {string|null} [meta.spec_sha] - git SHA of commands/ at run time.
|
|
144
|
+
* @param {number|null} [meta.corpus_size] - Size of the source corpus (defaults to issues_run).
|
|
145
|
+
* @returns {object} Scorecard compatible with eval-gate-scorecard.mjs.
|
|
146
|
+
*/
|
|
147
|
+
export function aggregate(runs, meta = {}) {
|
|
148
|
+
const issuesRun = runs.length;
|
|
149
|
+
const successfulRuns = runs.filter((r) => r.status === "success").length;
|
|
150
|
+
const failedRuns = runs.filter((r) => r.status === "failure").length;
|
|
151
|
+
|
|
152
|
+
// success_rate_pct: computed from ALL runs (including error/incomplete),
|
|
153
|
+
// matching the harness intent: incomplete runs count against the rate.
|
|
154
|
+
const successRatePct = issuesRun > 0 ? round2((successfulRuns / issuesRun) * 100) : 0;
|
|
155
|
+
|
|
156
|
+
// Wall-clock stats — include ALL runs.
|
|
157
|
+
const wallClockValues = runs.map((r) => r.wallClockMs).sort((a, b) => a - b);
|
|
158
|
+
const meanWallClockMs = round3(mean(wallClockValues));
|
|
159
|
+
const medianWallClockMs = round3(median(wallClockValues));
|
|
160
|
+
|
|
161
|
+
// Intervention stats.
|
|
162
|
+
const totalInterventionCount = runs.reduce((sum, r) => sum + r.interventionCount, 0);
|
|
163
|
+
|
|
164
|
+
// Cost: null if any run has cost === null or missing; sum otherwise.
|
|
165
|
+
const costValues = runs.map((r) => (r.cost !== undefined ? r.cost : null));
|
|
166
|
+
const totalCost = costValues.every((c) => c !== null)
|
|
167
|
+
? round3(costValues.reduce((a, b) => a + b, 0))
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
const runMode = meta.run_mode ?? "full";
|
|
171
|
+
if (runMode !== "full" && runMode !== "subset") {
|
|
172
|
+
throw new Error(`run_mode must be "full" or "subset" (got ${JSON.stringify(runMode)})`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
schema_version: "1",
|
|
177
|
+
run_id: meta.run_id ?? null,
|
|
178
|
+
generated_at: new Date().toISOString(),
|
|
179
|
+
corpus_size: meta.corpus_size ?? issuesRun,
|
|
180
|
+
issues_run: issuesRun,
|
|
181
|
+
successful_runs: successfulRuns,
|
|
182
|
+
failed_runs: failedRuns,
|
|
183
|
+
success_rate_pct: successRatePct,
|
|
184
|
+
mean_wall_clock_ms: meanWallClockMs,
|
|
185
|
+
median_wall_clock_ms: medianWallClockMs,
|
|
186
|
+
total_intervention_count: totalInterventionCount,
|
|
187
|
+
cost: totalCost,
|
|
188
|
+
run_mode: runMode,
|
|
189
|
+
spec_sha: meta.spec_sha ?? null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// CLI entry point
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
async function readInput(arg) {
|
|
198
|
+
const { readFileSync } = await import("node:fs");
|
|
199
|
+
if (!arg || arg === "-") {
|
|
200
|
+
return readFileSync(0, "utf8"); // fd 0 = stdin
|
|
201
|
+
}
|
|
202
|
+
return readFileSync(arg, "utf8");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function main() {
|
|
206
|
+
const arg = process.argv[2];
|
|
207
|
+
let raw;
|
|
208
|
+
try {
|
|
209
|
+
raw = await readInput(arg);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
process.stderr.write(`ERROR: cannot read input: ${e.message}\n`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let data;
|
|
216
|
+
try {
|
|
217
|
+
data = JSON.parse(raw);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
process.stderr.write(`ERROR: invalid JSON: ${e.message}\n`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!data || typeof data !== "object") {
|
|
224
|
+
process.stderr.write(`ERROR: input must be a JSON object with a "runs" array\n`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const { runs, run_id, corpus_version, run_mode, spec_sha, corpus_size } = data;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
validateRuns(runs);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
process.stderr.write(`ERROR: ${e.message}\n`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let scorecard;
|
|
238
|
+
try {
|
|
239
|
+
scorecard = aggregate(runs, { run_id, corpus_version, run_mode, spec_sha, corpus_size });
|
|
240
|
+
} catch (e) {
|
|
241
|
+
process.stderr.write(`ERROR: ${e.message}\n`);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
process.stdout.write(JSON.stringify(scorecard, null, 2) + "\n");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Run as CLI only when invoked directly, not when imported by tests.
|
|
249
|
+
import { fileURLToPath } from "node:url";
|
|
250
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
251
|
+
main();
|
|
252
|
+
}
|