forgedock 1.1.7 → 1.1.8
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/bin/engine/invariants.mjs +361 -0
- package/bin/hooks/pre-tool-use.mjs +182 -0
- package/bin/labels.json +10 -0
- package/bin/recall.mjs +540 -0
- package/commands/autopilot.md +957 -76
- package/commands/cleanup.md +52 -3
- package/commands/optimize.md +585 -0
- package/commands/orchestrate/phase-1-resolve.md +35 -25
- package/commands/orchestrate/phase-3-dependency.md +465 -34
- package/commands/orchestrate/phase-4-execution.md +447 -28
- package/commands/orchestrate/phase-6-report.md +198 -9
- package/commands/pipeline-health/phase-2-metrics.md +305 -1
- package/commands/pipeline-health/phase-4-proposals.md +127 -0
- package/commands/quality-gate.md +417 -0
- package/commands/review-pr.md +391 -13
- package/commands/spec-doctor.md +429 -0
- package/commands/validate.md +52 -0
- package/commands/work-on/build/architect.md +77 -0
- package/commands/work-on/build/context.md +357 -22
- package/commands/work-on/build/implement.md +2 -0
- package/commands/work-on/build/validate.md +28 -0
- package/commands/work-on/build.md +44 -0
- package/commands/work-on/close.md +621 -8
- package/commands/work-on/investigate.md +221 -0
- package/commands/work-on.md +15 -14
- package/docs/FORGE-PROTOCOL.md +101 -0
- package/docs/spec/forge-protocol-v1.md +97 -5
- package/docs/spec/label-state-machine.md +6 -1
- package/package.json +2 -2
- package/packages/protocol/src/cli.js +374 -84
- package/packages/protocol/src/types.js +75 -1
- package/scripts/build-knowledge-index.mjs +1761 -0
- package/scripts/calibration.mjs +996 -0
- package/scripts/danger-zones.mjs +782 -0
- package/scripts/doctor-pipeline-state.sh +88 -2
- package/scripts/select-fix-targets.sh +234 -0
- package/scripts/transition-label.sh +157 -6
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* calibration.mjs — Confidence calibration from run outcomes.
|
|
7
|
+
*
|
|
8
|
+
* Reads the Forge Ledger (knowledge.jsonl) to extract investigation cards
|
|
9
|
+
* (confidence + task type per issue), then queries GitHub to determine whether
|
|
10
|
+
* each merged issue "survived" — i.e., no review-finding or revert landed on
|
|
11
|
+
* the same files within 14 days of merge.
|
|
12
|
+
*
|
|
13
|
+
* Outputs a per-(task-type × confidence) calibration table as JSON.
|
|
14
|
+
* Optionally publishes the table to the `forge-knowledge` orphan branch at
|
|
15
|
+
* `calibration/table.json` for consumption by pipeline-health and review-pr.
|
|
16
|
+
*
|
|
17
|
+
* Outcome definition (spec: forge#1741):
|
|
18
|
+
* survived = no review-finding (label: review-finding) OR revert PR touching
|
|
19
|
+
* overlapping files was created within 14 days of merge.
|
|
20
|
+
* failed = at least one review-finding or revert PR touching the same files
|
|
21
|
+
* was created within 14 days of merge.
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* node scripts/calibration.mjs [options]
|
|
25
|
+
*
|
|
26
|
+
* Options:
|
|
27
|
+
* --repo <owner/repo> GitHub repository (default: reads forge.yaml)
|
|
28
|
+
* --index <dir> Override index directory (default: ~/.forge/index)
|
|
29
|
+
* --window <days> Outcome window in days (default: 14)
|
|
30
|
+
* --min-samples <n> Minimum samples before trusting a (task-type × confidence) cell (default: 10)
|
|
31
|
+
* --provenance Also build and (when --publish) publish the provenance table
|
|
32
|
+
* (task-type × normalized-modules) to calibration/provenance.json
|
|
33
|
+
* --provenance-min-samples <n> Minimum samples before trusting a provenance cell (default: 5)
|
|
34
|
+
* --publish Publish calibration table (and provenance table when --provenance) to forge-knowledge branch
|
|
35
|
+
* --no-mirror Skip orphan branch mirror when --publish is used
|
|
36
|
+
* --dry-run Compute table(s) but do not write any files
|
|
37
|
+
* --verbose Verbose logging
|
|
38
|
+
* --issue <number> Compute outcome for a single issue only (debugging)
|
|
39
|
+
*
|
|
40
|
+
* Exit codes:
|
|
41
|
+
* 0 — success
|
|
42
|
+
* 1 — fatal error (cannot read index, invalid args)
|
|
43
|
+
* 2 — partial (some issues failed but table was produced)
|
|
44
|
+
*
|
|
45
|
+
* @module calibration
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
49
|
+
import { spawnSync } from 'child_process';
|
|
50
|
+
import { homedir } from 'os';
|
|
51
|
+
import { dirname, join, resolve } from 'path';
|
|
52
|
+
import { fileURLToPath } from 'url';
|
|
53
|
+
import { createReadStream } from 'fs';
|
|
54
|
+
import { createInterface } from 'readline';
|
|
55
|
+
|
|
56
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
57
|
+
const __dirname = dirname(__filename);
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Constants
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const DEFAULT_INDEX_DIR = join(homedir(), '.forge', 'index');
|
|
64
|
+
const CARDS_FILE = 'knowledge.jsonl';
|
|
65
|
+
const CALIBRATION_DIR = 'calibration';
|
|
66
|
+
const TABLE_FILE = 'table.json';
|
|
67
|
+
const PROVENANCE_FILE = 'provenance.json';
|
|
68
|
+
const MIRROR_BRANCH = 'forge-knowledge';
|
|
69
|
+
const DEFAULT_WINDOW_DAYS = 14;
|
|
70
|
+
const DEFAULT_MIN_SAMPLES = 10;
|
|
71
|
+
const SCHEMA_VERSION = 1;
|
|
72
|
+
|
|
73
|
+
// Minimum samples before a provenance cell is trusted for intensity decisions.
|
|
74
|
+
// Lower than the confidence table default (10) because the module key-space is
|
|
75
|
+
// larger and cells accumulate samples more slowly.
|
|
76
|
+
const DEFAULT_PROVENANCE_MIN_SAMPLES = 5;
|
|
77
|
+
|
|
78
|
+
// Survival rate threshold above which a cell is classified as PROVEN (eligible
|
|
79
|
+
// for optional-agent de-escalation).
|
|
80
|
+
const PROVENANCE_PROVEN_THRESHOLD = 0.90;
|
|
81
|
+
|
|
82
|
+
// Survival rate floor below which a NOVEL-tier cell is marked for needs-human.
|
|
83
|
+
// Below this threshold the cell has not accumulated enough evidence to trust.
|
|
84
|
+
const PROVENANCE_NOVEL_NEEDS_HUMAN_THRESHOLD = 0.70;
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Argument parsing
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse CLI arguments.
|
|
92
|
+
* @param {string[]} argv
|
|
93
|
+
* @returns {object}
|
|
94
|
+
*/
|
|
95
|
+
export function parseArgs(argv) {
|
|
96
|
+
const args = {
|
|
97
|
+
repo: null,
|
|
98
|
+
indexDir: DEFAULT_INDEX_DIR,
|
|
99
|
+
windowDays: DEFAULT_WINDOW_DAYS,
|
|
100
|
+
minSamples: DEFAULT_MIN_SAMPLES,
|
|
101
|
+
provenanceMinSamples: DEFAULT_PROVENANCE_MIN_SAMPLES,
|
|
102
|
+
publish: false,
|
|
103
|
+
provenance: false,
|
|
104
|
+
noMirror: false,
|
|
105
|
+
dryRun: false,
|
|
106
|
+
verbose: false,
|
|
107
|
+
issue: null,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
for (let i = 2; i < argv.length; i++) {
|
|
111
|
+
const arg = argv[i];
|
|
112
|
+
if (arg === '--repo' && argv[i + 1]) { args.repo = argv[++i]; }
|
|
113
|
+
else if (arg === '--index' && argv[i + 1]) { args.indexDir = resolve(argv[++i]); }
|
|
114
|
+
else if (arg === '--window' && argv[i + 1]) { args.windowDays = parseInt(argv[++i], 10); }
|
|
115
|
+
else if (arg === '--min-samples' && argv[i + 1]) { args.minSamples = parseInt(argv[++i], 10); }
|
|
116
|
+
else if (arg === '--provenance-min-samples' && argv[i + 1]) { args.provenanceMinSamples = parseInt(argv[++i], 10); }
|
|
117
|
+
else if (arg === '--publish') { args.publish = true; }
|
|
118
|
+
else if (arg === '--provenance') { args.provenance = true; }
|
|
119
|
+
else if (arg === '--no-mirror') { args.noMirror = true; }
|
|
120
|
+
else if (arg === '--dry-run') { args.dryRun = true; }
|
|
121
|
+
else if (arg === '--verbose') { args.verbose = true; }
|
|
122
|
+
else if (arg === '--issue' && argv[i + 1]) { args.issue = parseInt(argv[++i], 10); }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return args;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Utilities
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
let _verbose = false;
|
|
133
|
+
|
|
134
|
+
function log(msg) { process.stderr.write(`[calibration] ${msg}\n`); }
|
|
135
|
+
function debug(msg) { if (_verbose) log(msg); }
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read forge.yaml to extract repo name.
|
|
139
|
+
* @param {string} repoPath - path to repo root
|
|
140
|
+
* @returns {string|null} repo name (owner/repo) or null
|
|
141
|
+
*/
|
|
142
|
+
export function readForgeYaml(repoPath) {
|
|
143
|
+
const configPath = join(repoPath, 'forge.yaml');
|
|
144
|
+
if (!existsSync(configPath)) return null;
|
|
145
|
+
try {
|
|
146
|
+
const result = spawnSync('yq', ['-r', '.project.owner + "/" + .project.repo', configPath], {
|
|
147
|
+
encoding: 'utf8', timeout: 5000,
|
|
148
|
+
});
|
|
149
|
+
if (result.status === 0 && result.stdout.trim()) return result.stdout.trim();
|
|
150
|
+
} catch (_) { /* ignore */ }
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Call the gh CLI and return parsed JSON, or throw on error.
|
|
156
|
+
* @param {string} apiPath
|
|
157
|
+
* @param {string[]} flags
|
|
158
|
+
* @returns {string} raw stdout
|
|
159
|
+
*/
|
|
160
|
+
export function ghApi(apiPath, flags = []) {
|
|
161
|
+
const result = spawnSync('gh', ['api', apiPath, ...flags], {
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
timeout: 30000,
|
|
164
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
165
|
+
});
|
|
166
|
+
if (result.status !== 0) {
|
|
167
|
+
throw new Error(`gh api ${apiPath} failed: ${(result.stderr || result.stdout || '').trim()}`);
|
|
168
|
+
}
|
|
169
|
+
return result.stdout;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read a JSONL file and return array of parsed objects.
|
|
174
|
+
* @param {string} filePath
|
|
175
|
+
* @returns {object[]}
|
|
176
|
+
*/
|
|
177
|
+
export function readJsonl(filePath) {
|
|
178
|
+
if (!existsSync(filePath)) return [];
|
|
179
|
+
const lines = readFileSync(filePath, 'utf8').split('\n');
|
|
180
|
+
const results = [];
|
|
181
|
+
for (const line of lines) {
|
|
182
|
+
const trimmed = line.trim();
|
|
183
|
+
if (!trimmed) continue;
|
|
184
|
+
try {
|
|
185
|
+
results.push(JSON.parse(trimmed));
|
|
186
|
+
} catch (_) {
|
|
187
|
+
debug(`Skipping malformed JSONL line: ${trimmed.slice(0, 80)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return results;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Outcome classification
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Compute the date that is `days` days after a given ISO date string.
|
|
199
|
+
* @param {string} isoDate
|
|
200
|
+
* @param {number} days
|
|
201
|
+
* @returns {Date}
|
|
202
|
+
*/
|
|
203
|
+
export function addDays(isoDate, days) {
|
|
204
|
+
const d = new Date(isoDate);
|
|
205
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
206
|
+
return d;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check whether two sets of file paths have any overlap.
|
|
211
|
+
* Comparison is case-insensitive and strips leading slashes.
|
|
212
|
+
* @param {string[]} filesA
|
|
213
|
+
* @param {string[]} filesB
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
export function hasFileOverlap(filesA, filesB) {
|
|
217
|
+
if (!filesA || !filesB || filesA.length === 0 || filesB.length === 0) return false;
|
|
218
|
+
const normalize = (f) => f.replace(/^\/+/, '').toLowerCase();
|
|
219
|
+
const setA = new Set(filesA.map(normalize));
|
|
220
|
+
for (const f of filesB) {
|
|
221
|
+
if (setA.has(normalize(f))) return true;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check if a date string falls within the outcome window.
|
|
228
|
+
* @param {string} dateStr - ISO date string of the candidate event
|
|
229
|
+
* @param {string} mergedAt - ISO date string of the merge
|
|
230
|
+
* @param {number} windowDays - outcome window in days
|
|
231
|
+
* @returns {boolean} true if dateStr is after mergedAt AND within windowDays
|
|
232
|
+
*/
|
|
233
|
+
export function isWithinWindow(dateStr, mergedAt, windowDays) {
|
|
234
|
+
if (!dateStr || !mergedAt) return false;
|
|
235
|
+
const eventDate = new Date(dateStr);
|
|
236
|
+
const mergeDate = new Date(mergedAt);
|
|
237
|
+
const windowEnd = addDays(mergedAt, windowDays);
|
|
238
|
+
return eventDate > mergeDate && eventDate <= windowEnd;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Classify a single run as survived or failed.
|
|
243
|
+
*
|
|
244
|
+
* Queries GitHub for:
|
|
245
|
+
* 1. Review-finding issues whose files overlap with the run's files AND
|
|
246
|
+
* created_at is within windowDays of the run's merge date.
|
|
247
|
+
* 2. PRs with "revert" in the title that touch overlapping files AND
|
|
248
|
+
* created_at is within windowDays of the merge date.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} params
|
|
251
|
+
* @param {number} params.issueNumber
|
|
252
|
+
* @param {string} params.mergedAt - ISO date string
|
|
253
|
+
* @param {string[]} params.files - files changed in the PR
|
|
254
|
+
* @param {string} params.repo - owner/repo
|
|
255
|
+
* @param {number} params.windowDays
|
|
256
|
+
* @returns {{ outcome: 'survived' | 'failed', reason: string }}
|
|
257
|
+
*/
|
|
258
|
+
export function classifyOutcome({ issueNumber, mergedAt, files, repo, windowDays }) {
|
|
259
|
+
if (!mergedAt) {
|
|
260
|
+
return { outcome: 'survived', reason: 'no merge date — treated as survived (no outcome data)' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const windowEnd = addDays(mergedAt, windowDays);
|
|
264
|
+
const windowEndStr = windowEnd.toISOString().slice(0, 10);
|
|
265
|
+
debug(`Issue #${issueNumber}: checking outcome window ${mergedAt} → ${windowEndStr} (${windowDays} days)`);
|
|
266
|
+
|
|
267
|
+
// Check review-finding issues created in the window
|
|
268
|
+
try {
|
|
269
|
+
const raw = ghApi(
|
|
270
|
+
`repos/${repo}/issues?labels=review-finding&state=all&since=${encodeURIComponent(mergedAt)}&per_page=100`
|
|
271
|
+
);
|
|
272
|
+
const reviewFindings = JSON.parse(raw);
|
|
273
|
+
for (const rf of reviewFindings) {
|
|
274
|
+
if (!isWithinWindow(rf.created_at, mergedAt, windowDays)) continue;
|
|
275
|
+
// Fetch PR files to check overlap
|
|
276
|
+
// review-finding issues reference the source PR in their title or body
|
|
277
|
+
const prMatch = (rf.body || rf.title || '').match(/PR #?(\d+)|pull\/(\d+)/i);
|
|
278
|
+
if (prMatch) {
|
|
279
|
+
const prNum = prMatch[1] || prMatch[2];
|
|
280
|
+
try {
|
|
281
|
+
const prFilesRaw = ghApi(`repos/${repo}/pulls/${prNum}/files?per_page=100`);
|
|
282
|
+
const prFiles = JSON.parse(prFilesRaw).map(f => f.filename);
|
|
283
|
+
if (hasFileOverlap(files, prFiles)) {
|
|
284
|
+
return {
|
|
285
|
+
outcome: 'failed',
|
|
286
|
+
reason: `review-finding #${rf.number} created within ${windowDays} days with overlapping files (PR #${prNum})`
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
} catch (_) {
|
|
290
|
+
// If we can't get PR files, fall back to checking body for file mentions
|
|
291
|
+
if (files.some(f => (rf.body || '').includes(f))) {
|
|
292
|
+
return {
|
|
293
|
+
outcome: 'failed',
|
|
294
|
+
reason: `review-finding #${rf.number} created within ${windowDays} days (file match via body)`
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
// No PR reference — check if any of our files are mentioned in the finding body
|
|
300
|
+
if (files.some(f => (rf.body || rf.title || '').includes(f.split('/').pop()))) {
|
|
301
|
+
return {
|
|
302
|
+
outcome: 'failed',
|
|
303
|
+
reason: `review-finding #${rf.number} created within ${windowDays} days (file basename match)`
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch (e) {
|
|
309
|
+
debug(`Issue #${issueNumber}: review-finding check failed: ${e.message}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Check revert PRs created in the window
|
|
313
|
+
try {
|
|
314
|
+
const raw = ghApi(
|
|
315
|
+
`repos/${repo}/pulls?state=all&per_page=50&sort=created&direction=desc`
|
|
316
|
+
);
|
|
317
|
+
const prs = JSON.parse(raw);
|
|
318
|
+
for (const pr of prs) {
|
|
319
|
+
if (!pr.title.toLowerCase().includes('revert')) continue;
|
|
320
|
+
if (!isWithinWindow(pr.created_at, mergedAt, windowDays)) continue;
|
|
321
|
+
try {
|
|
322
|
+
const prFilesRaw = ghApi(`repos/${repo}/pulls/${pr.number}/files?per_page=100`);
|
|
323
|
+
const prFiles = JSON.parse(prFilesRaw).map(f => f.filename);
|
|
324
|
+
if (hasFileOverlap(files, prFiles)) {
|
|
325
|
+
return {
|
|
326
|
+
outcome: 'failed',
|
|
327
|
+
reason: `revert PR #${pr.number} ("${pr.title}") created within ${windowDays} days with overlapping files`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
} catch (_) {
|
|
331
|
+
debug(`Issue #${issueNumber}: could not fetch files for revert PR #${pr.number}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch (e) {
|
|
335
|
+
debug(`Issue #${issueNumber}: revert PR check failed: ${e.message}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { outcome: 'survived', reason: 'no review-finding or revert with overlapping files in window' };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Table building
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Build a per-(task-type × confidence) calibration table from run records.
|
|
347
|
+
*
|
|
348
|
+
* @param {object[]} runs - array of { issueNumber, taskType, confidence, outcome }
|
|
349
|
+
* @param {number} minSamples - minimum samples before trusting a cell
|
|
350
|
+
* @returns {object} calibration table
|
|
351
|
+
*/
|
|
352
|
+
export function buildTable(runs, minSamples = DEFAULT_MIN_SAMPLES) {
|
|
353
|
+
// Accumulate per-cell counts
|
|
354
|
+
const cells = {}; // key: `${taskType}::${confidence}`
|
|
355
|
+
|
|
356
|
+
for (const run of runs) {
|
|
357
|
+
const { taskType, confidence, outcome } = run;
|
|
358
|
+
if (!taskType || !confidence || !outcome) continue;
|
|
359
|
+
const key = `${taskType}::${confidence}`;
|
|
360
|
+
if (!cells[key]) {
|
|
361
|
+
cells[key] = { taskType, confidence, survived: 0, failed: 0, total: 0 };
|
|
362
|
+
}
|
|
363
|
+
cells[key].total++;
|
|
364
|
+
if (outcome === 'survived') cells[key].survived++;
|
|
365
|
+
else cells[key].failed++;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Build table rows with survival rates and flags
|
|
369
|
+
const rows = Object.values(cells).map(cell => {
|
|
370
|
+
const survivalRate = cell.total > 0 ? cell.survived / cell.total : null;
|
|
371
|
+
const trusted = cell.total >= minSamples;
|
|
372
|
+
let flag = null;
|
|
373
|
+
|
|
374
|
+
if (trusted && survivalRate !== null) {
|
|
375
|
+
if (cell.confidence === 'HIGH' && survivalRate < 0.8) {
|
|
376
|
+
flag = 'overconfidence'; // HIGH confidence but poor survival
|
|
377
|
+
} else if (survivalRate > 0.95) {
|
|
378
|
+
flag = 'overcaution-candidate'; // very high survival — needs-human may be unnecessary
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
taskType: cell.taskType,
|
|
384
|
+
confidence: cell.confidence,
|
|
385
|
+
survivalRate: survivalRate !== null ? Math.round(survivalRate * 1000) / 1000 : null,
|
|
386
|
+
survived: cell.survived,
|
|
387
|
+
failed: cell.failed,
|
|
388
|
+
sampleCount: cell.total,
|
|
389
|
+
trusted,
|
|
390
|
+
flag,
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Sort by task type, then confidence
|
|
395
|
+
rows.sort((a, b) => {
|
|
396
|
+
const tt = a.taskType.localeCompare(b.taskType);
|
|
397
|
+
if (tt !== 0) return tt;
|
|
398
|
+
return a.confidence.localeCompare(b.confidence);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
schemaVersion: SCHEMA_VERSION,
|
|
403
|
+
computedAt: new Date().toISOString(),
|
|
404
|
+
windowDays: DEFAULT_WINDOW_DAYS,
|
|
405
|
+
minSamples,
|
|
406
|
+
totalRuns: runs.length,
|
|
407
|
+
rows,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Lookup a calibration table cell for a specific task type and confidence.
|
|
413
|
+
* Returns null if the cell is absent or not trusted (below minSamples).
|
|
414
|
+
*
|
|
415
|
+
* @param {object} table - calibration table from buildTable()
|
|
416
|
+
* @param {string} taskType
|
|
417
|
+
* @param {string} confidence
|
|
418
|
+
* @returns {object|null} table row or null
|
|
419
|
+
*/
|
|
420
|
+
export function lookupCell(table, taskType, confidence) {
|
|
421
|
+
if (!table || !Array.isArray(table.rows)) return null;
|
|
422
|
+
const row = table.rows.find(
|
|
423
|
+
r => r.taskType === taskType && r.confidence === confidence
|
|
424
|
+
);
|
|
425
|
+
if (!row || !row.trusted) return null;
|
|
426
|
+
return row;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Provenance table — (task-type × normalized-modules) survival tracking
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Normalize a list of changed file paths into a deterministic module-set key.
|
|
435
|
+
*
|
|
436
|
+
* Normalization rules:
|
|
437
|
+
* 1. Extract the top-level directory prefix for each path (e.g. "commands",
|
|
438
|
+
* "scripts", "services/api"). For paths that start with a well-known
|
|
439
|
+
* two-level prefix (services/, apps/, packages/, etc.), include the second
|
|
440
|
+
* directory segment too.
|
|
441
|
+
* 2. Deduplicate.
|
|
442
|
+
* 3. Sort lexicographically.
|
|
443
|
+
* 4. Join with "|".
|
|
444
|
+
*
|
|
445
|
+
* Example:
|
|
446
|
+
* ["commands/review-pr.md", "commands/work-on.md", "scripts/calibration.mjs"]
|
|
447
|
+
* → "commands|scripts"
|
|
448
|
+
*
|
|
449
|
+
* ["services/api/app/routers/billing.py", "services/worker/tasks/send.py"]
|
|
450
|
+
* → "services/api|services/worker"
|
|
451
|
+
*
|
|
452
|
+
* @param {string[]} files - list of repo-relative file paths
|
|
453
|
+
* @returns {string} normalized module key
|
|
454
|
+
*/
|
|
455
|
+
export function normalizeModules(files) {
|
|
456
|
+
if (!files || files.length === 0) return '';
|
|
457
|
+
|
|
458
|
+
const TWO_LEVEL_PREFIXES = new Set(['services', 'apps', 'packages', 'clients', 'sdk']);
|
|
459
|
+
|
|
460
|
+
const prefixes = new Set();
|
|
461
|
+
for (const f of files) {
|
|
462
|
+
// Strip leading slashes
|
|
463
|
+
const clean = f.replace(/^\/+/, '');
|
|
464
|
+
const parts = clean.split('/');
|
|
465
|
+
if (parts.length === 0 || !parts[0]) continue;
|
|
466
|
+
|
|
467
|
+
const top = parts[0];
|
|
468
|
+
if (TWO_LEVEL_PREFIXES.has(top) && parts.length >= 2 && parts[1]) {
|
|
469
|
+
prefixes.add(`${top}/${parts[1]}`);
|
|
470
|
+
} else {
|
|
471
|
+
prefixes.add(top);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return [...prefixes].sort().join('|');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Build a per-(task-type × normalized-modules) provenance table from run records.
|
|
480
|
+
*
|
|
481
|
+
* Each cell tracks survival rate for a specific (task-type, module-set) pair.
|
|
482
|
+
* The table is published to calibration/provenance.json on the forge-knowledge
|
|
483
|
+
* branch for consumption by review-pr.md Phase 3B.5.
|
|
484
|
+
*
|
|
485
|
+
* @param {object[]} runs - array of { issueNumber, taskType, files, outcome }
|
|
486
|
+
* @param {number} minSamples - minimum samples before trusting a cell
|
|
487
|
+
* @returns {object} provenance table
|
|
488
|
+
*/
|
|
489
|
+
export function buildProvenanceTable(runs, minSamples = DEFAULT_PROVENANCE_MIN_SAMPLES) {
|
|
490
|
+
const cells = {}; // key: `${taskType}::${normalizedModules}`
|
|
491
|
+
|
|
492
|
+
for (const run of runs) {
|
|
493
|
+
const { taskType, files, outcome } = run;
|
|
494
|
+
if (!taskType || !outcome) continue;
|
|
495
|
+
const modules = normalizeModules(files || []);
|
|
496
|
+
if (!modules) continue;
|
|
497
|
+
|
|
498
|
+
const key = `${taskType}::${modules}`;
|
|
499
|
+
if (!cells[key]) {
|
|
500
|
+
cells[key] = { taskType, modules, survived: 0, failed: 0, total: 0 };
|
|
501
|
+
}
|
|
502
|
+
cells[key].total++;
|
|
503
|
+
if (outcome === 'survived') cells[key].survived++;
|
|
504
|
+
else cells[key].failed++;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const rows = Object.entries(cells).map(([key, cell]) => {
|
|
508
|
+
const survivalRate = cell.total > 0 ? cell.survived / cell.total : null;
|
|
509
|
+
const trusted = cell.total >= minSamples;
|
|
510
|
+
let intensityTier = 'NOVEL'; // default: no trusted data → treat as novel
|
|
511
|
+
|
|
512
|
+
if (trusted && survivalRate !== null) {
|
|
513
|
+
if (survivalRate >= PROVENANCE_PROVEN_THRESHOLD) {
|
|
514
|
+
intensityTier = 'PROVEN';
|
|
515
|
+
} else if (survivalRate < PROVENANCE_NOVEL_NEEDS_HUMAN_THRESHOLD) {
|
|
516
|
+
intensityTier = 'NOVEL_NEEDS_HUMAN';
|
|
517
|
+
} else {
|
|
518
|
+
intensityTier = 'NOVEL';
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
key,
|
|
524
|
+
taskType: cell.taskType,
|
|
525
|
+
modules: cell.modules,
|
|
526
|
+
survivalRate: survivalRate !== null ? Math.round(survivalRate * 1000) / 1000 : null,
|
|
527
|
+
survived: cell.survived,
|
|
528
|
+
failed: cell.failed,
|
|
529
|
+
sampleCount: cell.total,
|
|
530
|
+
trusted,
|
|
531
|
+
intensityTier,
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
rows.sort((a, b) => {
|
|
536
|
+
const tt = a.taskType.localeCompare(b.taskType);
|
|
537
|
+
if (tt !== 0) return tt;
|
|
538
|
+
return a.modules.localeCompare(b.modules);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
schemaVersion: SCHEMA_VERSION,
|
|
543
|
+
computedAt: new Date().toISOString(),
|
|
544
|
+
windowDays: DEFAULT_WINDOW_DAYS,
|
|
545
|
+
minSamples,
|
|
546
|
+
provenThreshold: PROVENANCE_PROVEN_THRESHOLD,
|
|
547
|
+
totalRuns: runs.length,
|
|
548
|
+
rows,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Lookup a provenance table cell for a specific task type and module-set key.
|
|
554
|
+
* Returns null if the cell is absent or not trusted (below minSamples).
|
|
555
|
+
*
|
|
556
|
+
* @param {object} table - provenance table from buildProvenanceTable()
|
|
557
|
+
* @param {string} taskType
|
|
558
|
+
* @param {string} modules - normalized module key from normalizeModules()
|
|
559
|
+
* @returns {object|null} table row or null
|
|
560
|
+
*/
|
|
561
|
+
export function lookupProvenanceCell(table, taskType, modules) {
|
|
562
|
+
if (!table || !Array.isArray(table.rows)) return null;
|
|
563
|
+
const key = `${taskType}::${modules}`;
|
|
564
|
+
const row = table.rows.find(r => r.key === key);
|
|
565
|
+
if (!row || !row.trusted) return null;
|
|
566
|
+
return row;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Read the provenance table from the forge-knowledge branch (if it exists).
|
|
571
|
+
* Returns null on any error (fail-safe: caller falls back to SHADOW mode).
|
|
572
|
+
*
|
|
573
|
+
* @param {string} repoPath - absolute path to the git repo
|
|
574
|
+
* @returns {object|null} parsed provenance table or null
|
|
575
|
+
*/
|
|
576
|
+
export function readPublishedProvenanceTable(repoPath) {
|
|
577
|
+
try {
|
|
578
|
+
const result = spawnSync(
|
|
579
|
+
'git',
|
|
580
|
+
['show', `${MIRROR_BRANCH}:${CALIBRATION_DIR}/${PROVENANCE_FILE}`],
|
|
581
|
+
{ cwd: repoPath, encoding: 'utf8', timeout: 10000 }
|
|
582
|
+
);
|
|
583
|
+
if (result.status !== 0) return null;
|
|
584
|
+
return JSON.parse(result.stdout);
|
|
585
|
+
} catch (_) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Publish the provenance table to the forge-knowledge orphan branch alongside
|
|
592
|
+
* the existing calibration/table.json. Non-blocking: any failure is logged.
|
|
593
|
+
*
|
|
594
|
+
* @param {object} table - provenance table from buildProvenanceTable()
|
|
595
|
+
* @param {string} repoPath - absolute path to the git repo
|
|
596
|
+
* @param {boolean} dryRun
|
|
597
|
+
*/
|
|
598
|
+
export function publishProvenanceTable(table, repoPath, dryRun = false) {
|
|
599
|
+
if (dryRun) {
|
|
600
|
+
log('--dry-run: skipping provenance table publish to forge-knowledge branch');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
const currentBranchResult = spawnSync('git', ['branch', '--show-current'], {
|
|
606
|
+
cwd: repoPath, encoding: 'utf8', timeout: 5000,
|
|
607
|
+
});
|
|
608
|
+
const currentBranch = (currentBranchResult.stdout || '').trim();
|
|
609
|
+
|
|
610
|
+
// Ensure the forge-knowledge branch is present (reuse publishTable's checkout logic)
|
|
611
|
+
const branchCheck = spawnSync('git', ['show-ref', '--quiet', `refs/heads/${MIRROR_BRANCH}`], {
|
|
612
|
+
cwd: repoPath, encoding: 'utf8', timeout: 5000,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
if (branchCheck.status !== 0) {
|
|
616
|
+
spawnSync('git', ['fetch', 'origin', `${MIRROR_BRANCH}:${MIRROR_BRANCH}`], {
|
|
617
|
+
cwd: repoPath, encoding: 'utf8', timeout: 30000,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Check again after fetch
|
|
622
|
+
const recheckResult = spawnSync('git', ['show-ref', '--quiet', `refs/heads/${MIRROR_BRANCH}`], {
|
|
623
|
+
cwd: repoPath, encoding: 'utf8', timeout: 5000,
|
|
624
|
+
});
|
|
625
|
+
if (recheckResult.status !== 0) {
|
|
626
|
+
log(`WARNING: ${MIRROR_BRANCH} branch not found after fetch — skipping provenance publish`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// A checkout aborts on a dirty working tree; if it fails we must NOT proceed —
|
|
631
|
+
// otherwise the provenance table gets committed onto the CURRENT branch instead
|
|
632
|
+
// of the mirror. <!-- forge#1838 review SEC-1 -->
|
|
633
|
+
const switchResult = spawnSync('git', ['checkout', MIRROR_BRANCH], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
634
|
+
if (switchResult.status !== 0) {
|
|
635
|
+
log(`WARNING: Could not switch to ${MIRROR_BRANCH} (working tree dirty?) — skipping provenance publish`);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const calibDir = join(repoPath, CALIBRATION_DIR);
|
|
640
|
+
mkdirSync(calibDir, { recursive: true });
|
|
641
|
+
|
|
642
|
+
const provenanceFile = join(calibDir, PROVENANCE_FILE);
|
|
643
|
+
writeFileSync(provenanceFile, JSON.stringify(table, null, 2) + '\n', 'utf8');
|
|
644
|
+
|
|
645
|
+
spawnSync('git', ['add', join(CALIBRATION_DIR, PROVENANCE_FILE)], {
|
|
646
|
+
cwd: repoPath, encoding: 'utf8', timeout: 10000,
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const commitResult = spawnSync('git', [
|
|
650
|
+
'commit', '-m', `chore(calibration): update provenance trust table [skip ci]`,
|
|
651
|
+
'--allow-empty',
|
|
652
|
+
], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
653
|
+
|
|
654
|
+
if (commitResult.status === 0) {
|
|
655
|
+
const pushResult = spawnSync('git', ['push', 'origin', MIRROR_BRANCH, '--force-with-lease'], {
|
|
656
|
+
cwd: repoPath, encoding: 'utf8', timeout: 30000,
|
|
657
|
+
});
|
|
658
|
+
if (pushResult.status === 0) {
|
|
659
|
+
log(`Published provenance table to ${MIRROR_BRANCH}:${CALIBRATION_DIR}/${PROVENANCE_FILE}`);
|
|
660
|
+
} else {
|
|
661
|
+
log(`WARNING: Push of provenance table to ${MIRROR_BRANCH} failed — ${pushResult.stderr || ''}`);
|
|
662
|
+
}
|
|
663
|
+
} else {
|
|
664
|
+
log(`WARNING: Commit of provenance table to ${MIRROR_BRANCH} failed — ${commitResult.stderr || ''}`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (currentBranch) {
|
|
668
|
+
spawnSync('git', ['checkout', currentBranch], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
669
|
+
}
|
|
670
|
+
} catch (e) {
|
|
671
|
+
log(`WARNING: publishProvenanceTable to ${MIRROR_BRANCH} failed: ${e.message} — continuing`);
|
|
672
|
+
try {
|
|
673
|
+
spawnSync('git', ['checkout', '-'], { cwd: repoPath, encoding: 'utf8', timeout: 5000 });
|
|
674
|
+
} catch (_) { /* ignore */ }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// Publishing
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Read calibration table from the forge-knowledge branch (if it exists).
|
|
684
|
+
* Returns null on any error (fail-safe: caller falls back to static behavior).
|
|
685
|
+
*
|
|
686
|
+
* @param {string} repoPath - absolute path to the git repo
|
|
687
|
+
* @returns {object|null} parsed table or null
|
|
688
|
+
*/
|
|
689
|
+
export function readPublishedTable(repoPath) {
|
|
690
|
+
try {
|
|
691
|
+
const result = spawnSync(
|
|
692
|
+
'git',
|
|
693
|
+
['show', `${MIRROR_BRANCH}:${CALIBRATION_DIR}/${TABLE_FILE}`],
|
|
694
|
+
{ cwd: repoPath, encoding: 'utf8', timeout: 10000 }
|
|
695
|
+
);
|
|
696
|
+
if (result.status !== 0) return null;
|
|
697
|
+
return JSON.parse(result.stdout);
|
|
698
|
+
} catch (_) {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Publish the calibration table to the forge-knowledge orphan branch.
|
|
705
|
+
* Non-blocking: any failure is logged and execution continues.
|
|
706
|
+
*
|
|
707
|
+
* @param {object} table - calibration table
|
|
708
|
+
* @param {string} repoPath - absolute path to the git repo
|
|
709
|
+
* @param {boolean} dryRun
|
|
710
|
+
*/
|
|
711
|
+
export function publishTable(table, repoPath, dryRun = false) {
|
|
712
|
+
if (dryRun) {
|
|
713
|
+
log('--dry-run: skipping publish to forge-knowledge branch');
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
// Stash current branch name so we can return
|
|
719
|
+
const currentBranchResult = spawnSync('git', ['branch', '--show-current'], {
|
|
720
|
+
cwd: repoPath, encoding: 'utf8', timeout: 5000,
|
|
721
|
+
});
|
|
722
|
+
const currentBranch = (currentBranchResult.stdout || '').trim();
|
|
723
|
+
|
|
724
|
+
// Check if forge-knowledge branch exists locally
|
|
725
|
+
const branchCheck = spawnSync('git', ['show-ref', '--quiet', `refs/heads/${MIRROR_BRANCH}`], {
|
|
726
|
+
cwd: repoPath, encoding: 'utf8', timeout: 5000,
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
if (branchCheck.status !== 0) {
|
|
730
|
+
// Try to fetch from remote first
|
|
731
|
+
spawnSync('git', ['fetch', 'origin', `${MIRROR_BRANCH}:${MIRROR_BRANCH}`], {
|
|
732
|
+
cwd: repoPath, encoding: 'utf8', timeout: 30000,
|
|
733
|
+
});
|
|
734
|
+
// Re-check
|
|
735
|
+
const recheckResult = spawnSync('git', ['show-ref', '--quiet', `refs/heads/${MIRROR_BRANCH}`], {
|
|
736
|
+
cwd: repoPath, encoding: 'utf8', timeout: 5000,
|
|
737
|
+
});
|
|
738
|
+
if (recheckResult.status !== 0) {
|
|
739
|
+
// Create orphan branch
|
|
740
|
+
const initResult = spawnSync('git', ['checkout', '--orphan', MIRROR_BRANCH], {
|
|
741
|
+
cwd: repoPath, encoding: 'utf8', timeout: 10000,
|
|
742
|
+
});
|
|
743
|
+
if (initResult.status !== 0) {
|
|
744
|
+
log(`WARNING: Could not create ${MIRROR_BRANCH} branch — skipping publish`);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// Remove all tracked files from orphan branch working tree
|
|
748
|
+
spawnSync('git', ['rm', '-rf', '--quiet', '.'], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
749
|
+
// Create directory structure
|
|
750
|
+
mkdirSync(join(repoPath, CALIBRATION_DIR), { recursive: true });
|
|
751
|
+
} else {
|
|
752
|
+
const s = spawnSync('git', ['checkout', MIRROR_BRANCH], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
753
|
+
if (s.status !== 0) { log(`WARNING: Could not switch to ${MIRROR_BRANCH} (working tree dirty?) — skipping publish`); return; }
|
|
754
|
+
}
|
|
755
|
+
} else {
|
|
756
|
+
// A checkout aborts on a dirty working tree; if it fails we must NOT proceed —
|
|
757
|
+
// otherwise the table gets written and committed onto the CURRENT branch
|
|
758
|
+
// instead of the mirror. <!-- forge#1838 review SEC-1 -->
|
|
759
|
+
const switchResult = spawnSync('git', ['checkout', MIRROR_BRANCH], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
760
|
+
if (switchResult.status !== 0) { log(`WARNING: Could not switch to ${MIRROR_BRANCH} (working tree dirty?) — skipping publish`); return; }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Ensure calibration directory exists on the branch
|
|
764
|
+
const calibDir = join(repoPath, CALIBRATION_DIR);
|
|
765
|
+
mkdirSync(calibDir, { recursive: true });
|
|
766
|
+
|
|
767
|
+
// Write the table file
|
|
768
|
+
const tableFile = join(calibDir, TABLE_FILE);
|
|
769
|
+
writeFileSync(tableFile, JSON.stringify(table, null, 2) + '\n', 'utf8');
|
|
770
|
+
|
|
771
|
+
// Stage and commit
|
|
772
|
+
spawnSync('git', ['add', join(CALIBRATION_DIR, TABLE_FILE)], {
|
|
773
|
+
cwd: repoPath, encoding: 'utf8', timeout: 10000,
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const commitResult = spawnSync('git', [
|
|
777
|
+
'commit', '-m', `chore(calibration): update confidence calibration table [skip ci]`,
|
|
778
|
+
'--allow-empty',
|
|
779
|
+
], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
780
|
+
|
|
781
|
+
if (commitResult.status === 0) {
|
|
782
|
+
// Push to remote (non-blocking)
|
|
783
|
+
const pushResult = spawnSync('git', ['push', 'origin', MIRROR_BRANCH, '--force-with-lease'], {
|
|
784
|
+
cwd: repoPath, encoding: 'utf8', timeout: 30000,
|
|
785
|
+
});
|
|
786
|
+
if (pushResult.status === 0) {
|
|
787
|
+
log(`Published calibration table to ${MIRROR_BRANCH}:${CALIBRATION_DIR}/${TABLE_FILE}`);
|
|
788
|
+
} else {
|
|
789
|
+
log(`WARNING: Push to ${MIRROR_BRANCH} failed — ${pushResult.stderr || ''}`);
|
|
790
|
+
}
|
|
791
|
+
} else {
|
|
792
|
+
log(`WARNING: Commit to ${MIRROR_BRANCH} failed — ${commitResult.stderr || ''}`);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Return to original branch
|
|
796
|
+
if (currentBranch) {
|
|
797
|
+
spawnSync('git', ['checkout', currentBranch], { cwd: repoPath, encoding: 'utf8', timeout: 10000 });
|
|
798
|
+
}
|
|
799
|
+
} catch (e) {
|
|
800
|
+
log(`WARNING: publish to ${MIRROR_BRANCH} failed: ${e.message} — continuing`);
|
|
801
|
+
// Non-blocking: attempt to restore branch
|
|
802
|
+
try {
|
|
803
|
+
spawnSync('git', ['checkout', '-'], { cwd: repoPath, encoding: 'utf8', timeout: 5000 });
|
|
804
|
+
} catch (_) { /* ignore */ }
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
// Main pipeline
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Main entry point (exported for testing).
|
|
814
|
+
*
|
|
815
|
+
* @param {object} args - parsed CLI args
|
|
816
|
+
* @param {string} repoPath - repo root path (for forge.yaml + git ops)
|
|
817
|
+
* @returns {object} calibration table
|
|
818
|
+
*/
|
|
819
|
+
export async function run(args, repoPath) {
|
|
820
|
+
_verbose = args.verbose;
|
|
821
|
+
|
|
822
|
+
// Resolve repo name
|
|
823
|
+
const repo = args.repo || readForgeYaml(repoPath);
|
|
824
|
+
if (!repo) {
|
|
825
|
+
throw new Error('Cannot determine repo: no --repo flag and forge.yaml not found or missing project.owner/repo');
|
|
826
|
+
}
|
|
827
|
+
log(`Repository: ${repo}`);
|
|
828
|
+
|
|
829
|
+
// Read investigation cards from the knowledge index
|
|
830
|
+
const cardsFile = join(args.indexDir, CARDS_FILE);
|
|
831
|
+
if (!existsSync(cardsFile)) {
|
|
832
|
+
throw new Error(
|
|
833
|
+
`Knowledge index not found at ${cardsFile}. Run: node scripts/build-knowledge-index.mjs --repo ${repo}`
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const allCards = readJsonl(cardsFile);
|
|
838
|
+
const investigationCards = allCards.filter(c => c.kind === 'investigation' && c.verdict && c.confidence);
|
|
839
|
+
log(`Loaded ${allCards.length} cards, ${investigationCards.length} investigation cards`);
|
|
840
|
+
|
|
841
|
+
// Filter to single issue if requested
|
|
842
|
+
const targetCards = args.issue
|
|
843
|
+
? investigationCards.filter(c => c.issue === args.issue)
|
|
844
|
+
: investigationCards;
|
|
845
|
+
|
|
846
|
+
if (targetCards.length === 0) {
|
|
847
|
+
log('No investigation cards to process — producing empty table');
|
|
848
|
+
const table = buildTable([], args.minSamples);
|
|
849
|
+
return table;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// For each investigation card, fetch merge date and changed files from GitHub
|
|
853
|
+
const runs = [];
|
|
854
|
+
let errorCount = 0;
|
|
855
|
+
|
|
856
|
+
for (const card of targetCards) {
|
|
857
|
+
const issueNumber = card.issue;
|
|
858
|
+
debug(`Processing issue #${issueNumber} (${card.taskType} / ${card.confidence})`);
|
|
859
|
+
|
|
860
|
+
let mergedAt = null;
|
|
861
|
+
let changedFiles = [];
|
|
862
|
+
|
|
863
|
+
try {
|
|
864
|
+
// Find the merged PR for this issue
|
|
865
|
+
// Try FORGE:DECISION_RECORD first (most reliable)
|
|
866
|
+
const commentsRaw = ghApi(`repos/${repo}/issues/${issueNumber}/comments?per_page=100`);
|
|
867
|
+
const comments = JSON.parse(commentsRaw);
|
|
868
|
+
const gdrComment = comments.find(c => c.body && c.body.includes('FORGE:DECISION_RECORD'));
|
|
869
|
+
|
|
870
|
+
if (gdrComment) {
|
|
871
|
+
// Extract merge_commit and pr from GDR JSON
|
|
872
|
+
const jsonMatch = gdrComment.body.match(/```json\s*([\s\S]*?)```/);
|
|
873
|
+
if (jsonMatch) {
|
|
874
|
+
try {
|
|
875
|
+
const gdr = JSON.parse(jsonMatch[1]);
|
|
876
|
+
mergedAt = gdr.merge && gdr.merge.merged_at;
|
|
877
|
+
const prNum = gdr.pr;
|
|
878
|
+
if (prNum) {
|
|
879
|
+
const prFilesRaw = ghApi(`repos/${repo}/pulls/${prNum}/files?per_page=100`);
|
|
880
|
+
changedFiles = JSON.parse(prFilesRaw).map(f => f.filename);
|
|
881
|
+
}
|
|
882
|
+
} catch (_) { /* fall through */ }
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Fallback: search for a merged PR that references this issue
|
|
887
|
+
if (!mergedAt) {
|
|
888
|
+
const prsRaw = ghApi(
|
|
889
|
+
`repos/${repo}/pulls?state=closed&per_page=50&sort=updated&direction=desc`
|
|
890
|
+
);
|
|
891
|
+
const prs = JSON.parse(prsRaw);
|
|
892
|
+
for (const pr of prs) {
|
|
893
|
+
if (!pr.merged_at) continue;
|
|
894
|
+
const body = pr.body || '';
|
|
895
|
+
if (body.includes(`#${issueNumber}`) || body.includes(`/${issueNumber}`)) {
|
|
896
|
+
mergedAt = pr.merged_at;
|
|
897
|
+
const prFilesRaw = ghApi(`repos/${repo}/pulls/${pr.number}/files?per_page=100`);
|
|
898
|
+
changedFiles = JSON.parse(prFilesRaw).map(f => f.filename);
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (!mergedAt) {
|
|
905
|
+
debug(`Issue #${issueNumber}: no merged PR found — skipping outcome classification`);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Classify outcome
|
|
910
|
+
const { outcome, reason } = classifyOutcome({
|
|
911
|
+
issueNumber,
|
|
912
|
+
mergedAt,
|
|
913
|
+
files: changedFiles,
|
|
914
|
+
repo,
|
|
915
|
+
windowDays: args.windowDays,
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
debug(`Issue #${issueNumber}: ${outcome} — ${reason}`);
|
|
919
|
+
|
|
920
|
+
runs.push({
|
|
921
|
+
issueNumber,
|
|
922
|
+
taskType: card.taskType,
|
|
923
|
+
confidence: card.confidence,
|
|
924
|
+
verdict: card.verdict,
|
|
925
|
+
outcome,
|
|
926
|
+
reason,
|
|
927
|
+
mergedAt,
|
|
928
|
+
files: changedFiles,
|
|
929
|
+
filesCount: changedFiles.length,
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
} catch (e) {
|
|
933
|
+
log(`WARNING: Failed to process issue #${issueNumber}: ${e.message}`);
|
|
934
|
+
errorCount++;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
log(`Processed ${runs.length} runs (${errorCount} errors)`);
|
|
939
|
+
|
|
940
|
+
// Build the calibration table (task-type × confidence)
|
|
941
|
+
const table = buildTable(runs, args.minSamples);
|
|
942
|
+
log(`Calibration table: ${table.rows.length} cells from ${table.totalRuns} runs`);
|
|
943
|
+
|
|
944
|
+
// Optionally build the provenance table (task-type × normalized-modules)
|
|
945
|
+
let provenanceTable = null;
|
|
946
|
+
if (args.provenance) {
|
|
947
|
+
provenanceTable = buildProvenanceTable(runs, args.provenanceMinSamples);
|
|
948
|
+
log(`Provenance table: ${provenanceTable.rows.length} cells from ${provenanceTable.totalRuns} runs`);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return { table, provenanceTable, runs, errorCount };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
// CLI entry point
|
|
956
|
+
// ---------------------------------------------------------------------------
|
|
957
|
+
|
|
958
|
+
// Guard: only run main() when invoked directly (not when imported by tests)
|
|
959
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
960
|
+
const args = parseArgs(process.argv);
|
|
961
|
+
const repoPath = resolve(__dirname, '..');
|
|
962
|
+
|
|
963
|
+
run(args, repoPath)
|
|
964
|
+
.then(result => {
|
|
965
|
+
const table = result.table || result;
|
|
966
|
+
const provenanceTable = result.provenanceTable || null;
|
|
967
|
+
const errorCount = result.errorCount || 0;
|
|
968
|
+
|
|
969
|
+
if (args.dryRun) {
|
|
970
|
+
// Print calibration table to stdout
|
|
971
|
+
process.stdout.write(JSON.stringify(table, null, 2) + '\n');
|
|
972
|
+
if (provenanceTable) {
|
|
973
|
+
process.stderr.write('[calibration] Provenance table (dry-run):\n');
|
|
974
|
+
process.stderr.write(JSON.stringify(provenanceTable, null, 2) + '\n');
|
|
975
|
+
}
|
|
976
|
+
log('--dry-run complete — no files written');
|
|
977
|
+
process.exit(0);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (args.publish) {
|
|
981
|
+
publishTable(table, repoPath, false);
|
|
982
|
+
if (provenanceTable) {
|
|
983
|
+
publishProvenanceTable(provenanceTable, repoPath, false);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Always print calibration table to stdout
|
|
988
|
+
process.stdout.write(JSON.stringify(table, null, 2) + '\n');
|
|
989
|
+
|
|
990
|
+
process.exit(errorCount > 0 ? 2 : 0);
|
|
991
|
+
})
|
|
992
|
+
.catch(err => {
|
|
993
|
+
process.stderr.write(`[calibration] FATAL: ${err.message}\n`);
|
|
994
|
+
process.exit(1);
|
|
995
|
+
});
|
|
996
|
+
}
|