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.
Files changed (37) hide show
  1. package/bin/engine/invariants.mjs +361 -0
  2. package/bin/hooks/pre-tool-use.mjs +182 -0
  3. package/bin/labels.json +10 -0
  4. package/bin/recall.mjs +540 -0
  5. package/commands/autopilot.md +957 -76
  6. package/commands/cleanup.md +52 -3
  7. package/commands/optimize.md +585 -0
  8. package/commands/orchestrate/phase-1-resolve.md +35 -25
  9. package/commands/orchestrate/phase-3-dependency.md +465 -34
  10. package/commands/orchestrate/phase-4-execution.md +447 -28
  11. package/commands/orchestrate/phase-6-report.md +198 -9
  12. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  13. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  14. package/commands/quality-gate.md +417 -0
  15. package/commands/review-pr.md +391 -13
  16. package/commands/spec-doctor.md +429 -0
  17. package/commands/validate.md +52 -0
  18. package/commands/work-on/build/architect.md +77 -0
  19. package/commands/work-on/build/context.md +357 -22
  20. package/commands/work-on/build/implement.md +2 -0
  21. package/commands/work-on/build/validate.md +28 -0
  22. package/commands/work-on/build.md +44 -0
  23. package/commands/work-on/close.md +621 -8
  24. package/commands/work-on/investigate.md +221 -0
  25. package/commands/work-on.md +15 -14
  26. package/docs/FORGE-PROTOCOL.md +101 -0
  27. package/docs/spec/forge-protocol-v1.md +97 -5
  28. package/docs/spec/label-state-machine.md +6 -1
  29. package/package.json +2 -2
  30. package/packages/protocol/src/cli.js +374 -84
  31. package/packages/protocol/src/types.js +75 -1
  32. package/scripts/build-knowledge-index.mjs +1761 -0
  33. package/scripts/calibration.mjs +996 -0
  34. package/scripts/danger-zones.mjs +782 -0
  35. package/scripts/doctor-pipeline-state.sh +88 -2
  36. package/scripts/select-fix-targets.sh +234 -0
  37. 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
+ }