forge-workflow 0.1.0-beta.2 → 0.1.0-beta.3

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 (57) hide show
  1. package/.forge/hooks/check-tdd.js +79 -5
  2. package/.forge/hooks/forge-native-hook.js +194 -8
  3. package/AGENTS.md +1 -0
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge.js +90 -19
  8. package/docs/guides/SETUP.md +4 -1
  9. package/docs/guides/SUPPORT.md +5 -0
  10. package/docs/reference/COMMANDS.md +9 -0
  11. package/docs/reference/shepherd.md +42 -2
  12. package/lib/activation/ensure-forge-home.js +135 -0
  13. package/lib/adapters/beads-kernel-compat.js +67 -0
  14. package/lib/adoption-profiles.js +17 -4
  15. package/lib/beads-detect.js +60 -0
  16. package/lib/beads-nudge.js +91 -0
  17. package/lib/commands/_aliases.js +248 -0
  18. package/lib/commands/_issue.js +39 -0
  19. package/lib/commands/_manifest.js +2 -0
  20. package/lib/commands/_registry.js +14 -0
  21. package/lib/commands/_resolve-command-opts.js +0 -31
  22. package/lib/commands/gate.js +19 -2
  23. package/lib/commands/hooks.js +139 -4
  24. package/lib/commands/init.js +26 -20
  25. package/lib/commands/memory.js +81 -0
  26. package/lib/commands/migrate.js +0 -161
  27. package/lib/commands/plan.js +48 -8
  28. package/lib/commands/pr.js +88 -0
  29. package/lib/commands/push.js +66 -0
  30. package/lib/commands/recall.js +67 -12
  31. package/lib/commands/recap.js +18 -4
  32. package/lib/commands/release.js +14 -1
  33. package/lib/commands/remember.js +86 -20
  34. package/lib/commands/setup.js +135 -72
  35. package/lib/commands/shepherd.js +67 -2
  36. package/lib/commands/ship.js +40 -4
  37. package/lib/commands/worktree.js +60 -4
  38. package/lib/core/runtime-graph.js +34 -3
  39. package/lib/gate-events.js +54 -55
  40. package/lib/global-flags.js +30 -0
  41. package/lib/grounding/context-events.js +230 -0
  42. package/lib/grounding/read-first.js +112 -0
  43. package/lib/hook-renderer.js +93 -3
  44. package/lib/kernel/backing-issue.js +7 -1
  45. package/lib/kernel/owned-kernel.js +43 -0
  46. package/lib/kernel/sqlite-driver.js +37 -1
  47. package/lib/pr-monitor/auto-actions.js +175 -0
  48. package/lib/pr-monitor/digest.js +206 -0
  49. package/lib/pr-monitor/render-sticky.js +43 -8
  50. package/lib/pr-monitor/upsert-sticky.js +169 -0
  51. package/lib/pr-pull.js +43 -2
  52. package/lib/release-readiness.js +17 -1
  53. package/lib/upgrade-safety.js +53 -1
  54. package/lib/workflow/enforce-stage.js +59 -2
  55. package/package.json +2 -2
  56. package/scripts/pr-auto-actions.js +93 -0
  57. package/scripts/pr-verdict-label.js +50 -0
package/lib/pr-pull.js CHANGED
@@ -884,9 +884,14 @@ function rankConflict(v) {
884
884
  return null;
885
885
  }
886
886
 
887
- /** Rank 3: branch behind base. */
887
+ /** Rank 3: branch behind base. Gate on GitHub's actual blocking state
888
+ * (mergeStateStatus=BEHIND — set only when branch protection requires branches be
889
+ * up to date), NOT a raw compareCommits behind-count: a count>0 is often stale or
890
+ * non-blocking (strict-up-to-date not required), so escalating on it falsely
891
+ * labels a mergeable PR `behind` (issue 5291f2d2). The count is still surfaced in
892
+ * `evidence.behind`/blockers as WHY — it just no longer drives the verdict. */
888
893
  function rankBehind(v) {
889
- return (v.mss === 'BEHIND' || v.behind > 0) ? 'BEHIND' : null;
894
+ return (v.mss === 'BEHIND') ? 'BEHIND' : null;
890
895
  }
891
896
 
892
897
  /** Rank 4: checks — failing/missing/skipped/pending REQUIRED checks, a failing
@@ -956,6 +961,38 @@ function computeVerdict(input) {
956
961
  return { verdict: rankClean(v), evidence: v.evidence };
957
962
  }
958
963
 
964
+ /**
965
+ * The canonical verdict enum — every value `computeVerdict` can return, highest
966
+ * priority first (mirrors VERDICT_RANKS + rankClean). This is the SINGLE source
967
+ * for the `pr-verdict:*` label set the pr-monitor workflow reconciles, so the
968
+ * label and `forge shepherd <pr> --pull --json` can never drift: both are this
969
+ * same verdict.
970
+ */
971
+ const MERGE_VERDICTS = [
972
+ 'UNKNOWN', 'BLOCKED-CONFLICT', 'BEHIND', 'BLOCKED-CHECKS', 'BLOCKED-THREADS',
973
+ 'REVIEW-PENDING', 'CLEAN-MERGEABLE',
974
+ ];
975
+
976
+ /** Prefix for the single `pr-verdict:*` label a PR carries at a time. */
977
+ const VERDICT_LABEL_PREFIX = 'pr-verdict:';
978
+
979
+ /**
980
+ * Map a canonical verdict to its lowercased `pr-verdict:*` label
981
+ * (e.g. `BLOCKED-CHECKS` -> `pr-verdict:blocked-checks`). Unknown/empty input
982
+ * fails closed to the `unknown` label.
983
+ *
984
+ * @param {string} verdict
985
+ * @returns {string}
986
+ */
987
+ function verdictLabel(verdict) {
988
+ const v = String(verdict || '').toUpperCase();
989
+ const known = MERGE_VERDICTS.includes(v) ? v : 'UNKNOWN';
990
+ return `${VERDICT_LABEL_PREFIX}${known.toLowerCase()}`;
991
+ }
992
+
993
+ /** The full label reconcile set — one label per canonical verdict. */
994
+ const VERDICT_LABELS = MERGE_VERDICTS.map((v) => `${VERDICT_LABEL_PREFIX}${v.toLowerCase()}`);
995
+
959
996
  /**
960
997
  * Run an optional read and SURFACE any failure instead of swallowing it: on
961
998
  * throw, record `{ source, error }` into `degraded` (and optionally mark `source`
@@ -1262,6 +1299,10 @@ module.exports = {
1262
1299
  renderPullSummary,
1263
1300
  buildPullPayload,
1264
1301
  computeVerdict,
1302
+ MERGE_VERDICTS,
1303
+ VERDICT_LABELS,
1304
+ VERDICT_LABEL_PREFIX,
1305
+ verdictLabel,
1265
1306
  isSkipped,
1266
1307
  isPending,
1267
1308
  buildBotStatusBlockers,
@@ -1754,7 +1754,7 @@ function auditArtifactBlocker(projectRoot, audit) {
1754
1754
  return {
1755
1755
  id: 'd20-audit-artifact-current',
1756
1756
  title: 'D20 bd call-site kill-list artifact is not current',
1757
- detail: `Regenerate ${AUDIT_ARTIFACT}; current status: ${auditArtifact.reason}.`,
1757
+ detail: `Regenerate ${AUDIT_ARTIFACT} in one command: \`forge release regen-audit\` (then commit it). Current status: ${auditArtifact.reason}.`,
1758
1758
  evidence: auditArtifact.evidence,
1759
1759
  };
1760
1760
  }
@@ -2074,6 +2074,21 @@ function renderAuditGroup(audit, group) {
2074
2074
  ];
2075
2075
  }
2076
2076
 
2077
+ // Rewrite the tracked kill-list artifact from a live re-scan. This is the exact
2078
+ // one-liner (`forge release regen-audit`) the d20 staleness blocker points at:
2079
+ // every Beads-removal PR shifts the census, so rather than hand-editing the
2080
+ // artifact (and red-failing CI cross-platform until it matches byte-for-byte),
2081
+ // a developer regenerates it in one command and commits the diff. `options`
2082
+ // forwards to auditBdCallSites so the write uses the same scan roots the gate
2083
+ // compares against.
2084
+ function writeAuditArtifact(projectRoot, options = {}) {
2085
+ const audit = auditBdCallSites(projectRoot, options);
2086
+ const artifactPath = absolutePath(projectRoot, AUDIT_ARTIFACT);
2087
+ fs.mkdirSync(path.dirname(artifactPath), { recursive: true });
2088
+ fs.writeFileSync(artifactPath, renderBdCallSiteAuditMarkdown(audit), 'utf8');
2089
+ return { path: AUDIT_ARTIFACT, audit };
2090
+ }
2091
+
2077
2092
  module.exports = {
2078
2093
  AUDIT_ARTIFACT,
2079
2094
  GROUPS,
@@ -2083,6 +2098,7 @@ module.exports = {
2083
2098
  canonicalizeAuditArtifact,
2084
2099
  renderBdCallSiteAuditMarkdown,
2085
2100
  renderReadinessReport,
2101
+ writeAuditArtifact,
2086
2102
  // Exposed for the premerge-de-stage certification tests.
2087
2103
  premergeEmbeddedGateStatus,
2088
2104
  premergeEmbeddedGateBlocker,
@@ -6,6 +6,8 @@ const path = require('node:path');
6
6
  const { lintRuntimeGraphConfig } = require('./core/runtime-graph');
7
7
  const { resolvePatchIntentRecords } = require('./patch-intent');
8
8
  const { verifyForgeLock, readForgeLock } = require('./forge-lock');
9
+ const { readConfigBackend, resolveIssueBackend } = require('./issue-backend');
10
+ const { detectBeadsJsonlSource } = require('./beads-detect');
9
11
 
10
12
  function checkStatus(ok) {
11
13
  return ok ? 'pass' : 'fail';
@@ -56,7 +58,33 @@ function buildSelfHealCandidates(projectRoot) {
56
58
  return candidates;
57
59
  }
58
60
 
59
- function buildUpgradeDryRunReport(projectRoot = process.cwd()) {
61
+ function safeConfigBackend(projectRoot) {
62
+ try {
63
+ return readConfigBackend(projectRoot);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ // Detect the 0.0.10 -> current breaking boundary that hides a returning user's
70
+ // issues (kernel issue a5399f3d): a `.beads/*.jsonl` store still present while the
71
+ // default backend has flipped to the Kernel. `needsMigration` is true only when
72
+ // the user has NOT explicitly opted back into Beads — via `.forge/config.yaml`
73
+ // OR `FORGE_ISSUE_BACKEND` — so the advisory respects the SAME env+config opt-in
74
+ // the issue-path nudge does (both resolve through resolveIssueBackend). Uses the
75
+ // single shared detector so the two surfaces cannot drift.
76
+ function buildBeadsMigrationSummary(projectRoot, env = process.env) {
77
+ const jsonlPresent = detectBeadsJsonlSource(projectRoot) !== null;
78
+ const configBackend = safeConfigBackend(projectRoot);
79
+ const backend = resolveIssueBackend({ env, projectRoot, warn: () => {} });
80
+ return {
81
+ jsonlPresent,
82
+ configBackend,
83
+ needsMigration: jsonlPresent && backend === 'kernel',
84
+ };
85
+ }
86
+
87
+ function buildUpgradeDryRunReport(projectRoot = process.cwd(), env = process.env) {
60
88
  const root = path.resolve(projectRoot);
61
89
  const runtime = lintRuntimeGraphConfig({ projectRoot: root });
62
90
  const patchIntent = buildPatchIntentSummary(root);
@@ -66,8 +94,12 @@ function buildUpgradeDryRunReport(projectRoot = process.cwd()) {
66
94
  const failedLockEntries = lockReport.results.filter(result => result.status === 'fail');
67
95
  const untrustedOptIns = countUntrustedOptIns(lock);
68
96
  const lockTrustOk = lockReport.ok && untrustedOptIns === 0;
97
+ const beadsMigration = buildBeadsMigrationSummary(root, env);
69
98
 
70
99
  return {
100
+ // A pending beads -> kernel migration is a guided ADVISORY, not an integrity
101
+ // failure — it never flips `ok` (scripts keying on it stay stable); it surfaces
102
+ // as its own prominent "action required" section in the rendered report.
71
103
  ok: runtime.ok && patchIntent.ok && lockTrustOk,
72
104
  projectRoot: root,
73
105
  runtime,
@@ -77,6 +109,7 @@ function buildUpgradeDryRunReport(projectRoot = process.cwd()) {
77
109
  lockTrustOk,
78
110
  selfHealCandidates,
79
111
  failedLockEntries,
112
+ beadsMigration,
80
113
  };
81
114
  }
82
115
 
@@ -139,6 +172,23 @@ function appendSelfHealResult(lines, selfHealResult) {
139
172
  }
140
173
  }
141
174
 
175
+ function appendBeadsMigration(lines, beadsMigration) {
176
+ if (!beadsMigration || !beadsMigration.needsMigration) {
177
+ return;
178
+ }
179
+ lines.push(
180
+ '',
181
+ 'Breaking change since 0.0.10 — action required',
182
+ 'Detected a Beads issue store (.beads/*.jsonl). Forge now defaults to the Kernel',
183
+ 'issue backend, so these issues will NOT appear until migrated (your data is safe',
184
+ 'on disk in the meantime). To migrate:',
185
+ ' forge migrate --from beads # import your Beads issues into the Kernel',
186
+ ' forge setup # (re)wire hooks + provision the Kernel store',
187
+ 'Prefer to stay on Beads? Set `issueBackend: beads` in .forge/config.yaml '
188
+ + '(or FORGE_ISSUE_BACKEND=beads).',
189
+ );
190
+ }
191
+
142
192
  function renderUpgradeDryRunReport(report, selfHealResult = null) {
143
193
  const lines = [
144
194
  'Forge upgrade dry-run',
@@ -149,6 +199,7 @@ function renderUpgradeDryRunReport(report, selfHealResult = null) {
149
199
  ...readinessLines(report),
150
200
  ];
151
201
 
202
+ appendBeadsMigration(lines, report.beadsMigration);
152
203
  appendPlannedSelfHeal(lines, report.selfHealCandidates);
153
204
  appendSelfHealResult(lines, selfHealResult);
154
205
 
@@ -194,6 +245,7 @@ function applySelfHeal(projectRoot, report) {
194
245
  module.exports = {
195
246
  applySelfHeal,
196
247
  buildUpgradeDryRunReport,
248
+ buildBeadsMigrationSummary,
197
249
  buildSelfHealCandidates,
198
250
  renderUpgradeDryRunReport,
199
251
  };
@@ -68,6 +68,27 @@ function findLatestStageRun(driver, issueId, stage) {
68
68
  }
69
69
  }
70
70
 
71
+ // (Re-)entering an earlier stage invalidates the work that followed it: any
72
+ // DOWNSTREAM stage previously recorded 'done' is reopened to 'active' so a gated
73
+ // stage (ship/review) re-requires a fresh completion (R2 — the dev<->validate
74
+ // rework loop must not let a stale validate=done pass ship). Reuses the idempotent
75
+ // 'start' write, which keeps the row's id + original started_at but clears its
76
+ // done status. Stages the entered stage does not precede (and non-'done' rows) are
77
+ // left untouched, so forward progression records nothing extra.
78
+ function invalidateDownstreamStages(driver, issueId, stageId, warn) {
79
+ const index = STAGE_PATH.indexOf(stageId);
80
+ if (index < 0) {
81
+ return;
82
+ }
83
+ for (let next = index + 1; next < STAGE_PATH.length; next += 1) {
84
+ const downstream = STAGE_PATH[next];
85
+ const run = findLatestStageRun(driver, issueId, downstream);
86
+ if (run?.status === 'done') {
87
+ recordStageRunSafe(driver, issueId, downstream, 'start', warn);
88
+ }
89
+ }
90
+ }
91
+
71
92
  // Decide whether entering stageId is allowed given ONLY the kernel's recorded
72
93
  // stage history (used when no inline/file workflow state exists):
73
94
  // - Stateless stages (plan/dev/validate/verify) are always re-entrant, so the
@@ -123,7 +144,12 @@ async function resolveActiveIssueId(driver, branch) {
123
144
  try {
124
145
  if (typeof driver.listWorktrees === 'function') {
125
146
  const rows = driver.listWorktrees() || [];
126
- const match = rows.find(row => row && row.branch === branch && row.issue_id);
147
+ // Match only ACTIVE (live) linkage rows, newest first (listWorktrees orders
148
+ // registered_at DESC). A stale/superseded registration for a reused branch
149
+ // name must not rebind stage state to the OLD issue (be18881c). Tolerate a
150
+ // null state for rows written before the state column was populated.
151
+ const match = rows.find(row => row && row.branch === branch && row.issue_id
152
+ && (row.state === 'active' || row.state == null));
127
153
  if (match) {
128
154
  return match.issue_id;
129
155
  }
@@ -391,6 +417,7 @@ async function enforceStageEntry({
391
417
  const finish = (result) => {
392
418
  if (kernelActive) {
393
419
  recordStageRunSafe(driver, issueId, stageId, 'start', warn);
420
+ invalidateDownstreamStages(driver, issueId, stageId, warn);
394
421
  result.recordCompletion = () => recordStageRunSafe(driver, issueId, stageId, 'complete', warn);
395
422
  }
396
423
  return result;
@@ -401,6 +428,14 @@ async function enforceStageEntry({
401
428
  return enforceWithFileState(currentState, stageId, flags, args, finish);
402
429
  }
403
430
 
431
+ // B1 — strict mode is the explicit opt-in to the legacy fail-closed behavior:
432
+ // require prior stages / authoritative state. The default (unset) degrades to a
433
+ // loud warning and seeds the kernel so future gating gets real data. A recorded
434
+ // history that CONTRADICTS (e.g. validate started-but-not-done) still throws in
435
+ // both modes — that rework protection lives in enforceWithKernelState below and
436
+ // is never weakened by this flag.
437
+ const strict = process.env.FORGE_STAGE_GATE === 'strict';
438
+
404
439
  // No inline/file state: the kernel is authoritative. Gate on recorded stage
405
440
  // completions (tolerant read) so `ship` is reachable from a pure-CLI
406
441
  // plan->dev->validate progression with no .forge-state.json.
@@ -409,15 +444,37 @@ async function enforceStageEntry({
409
444
  if (decided) {
410
445
  return decided;
411
446
  }
447
+ // enforceWithKernelState returned null → the kernel has an issue linked but
448
+ // NO recorded predecessor (a contradiction would have thrown above). Degrade
449
+ // to warn + seed unless strict: allow the stage and let finish() record it so
450
+ // future gating has real history.
451
+ if (!strict) {
452
+ warn(
453
+ `[forge] no recorded workflow history for issue ${issueId} — allowing '${stageId}' ` +
454
+ `and recording it in the kernel. Set FORGE_STAGE_GATE=strict to require prior stages.`
455
+ );
456
+ return finish({ allowed: true, stage: stageId, workflowState: null, degradedGate: 'kernel-empty' });
457
+ }
412
458
  }
413
459
 
414
460
  if (STATELESS_ENTRY_STAGES.has(stageId)) {
415
461
  return finish({ allowed: true, stage: stageId, workflowState: null });
416
462
  }
417
463
 
464
+ // No workflow state AND no kernel-linked issue for this branch. Degrade to warn
465
+ // unless strict so incremental adoption (fresh setup, in-flight branch, manual
466
+ // commit) is not blocked.
467
+ if (!strict) {
468
+ warn(
469
+ `[forge] no workflow state and no kernel-linked issue for this branch — stage gate skipped for '${stageId}'. ` +
470
+ `Link an issue with 'forge worktree create <slug>' to enable stage tracking.`
471
+ );
472
+ return finish({ allowed: true, stage: stageId, workflowState: null, degradedGate: 'no-kernel-context' });
473
+ }
474
+
418
475
  throw new Error(
419
476
  `Stage ${stageId} requires authoritative workflow state. ` +
420
- `Provide --workflow-state or restore ${WORKFLOW_STATE_FILENAME} before continuing.`
477
+ `Provide --workflow-state or restore ${WORKFLOW_STATE_FILENAME} before continuing (or unset FORGE_STAGE_GATE).`
421
478
  );
422
479
  }
423
480
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-workflow",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "Local runtime control plane for AI-assisted engineering workflows, gates, evidence, and all AI agents",
5
5
  "bin": {
6
6
  "forge": "bin/forge.js",
@@ -59,7 +59,7 @@
59
59
  "globals": "^17.3.0",
60
60
  "js-yaml": "^5.1.0",
61
61
  "lefthook": "^2.1.4",
62
- "typescript": "5.4.5"
62
+ "typescript": "7.0.2"
63
63
  },
64
64
  "keywords": [
65
65
  "ai",
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * pr-auto-actions — the pr-monitor workflow's bridge from the `--pull --json`
6
+ * verdict payload to Tier-2 auto-action DECISIONS. Reads the payload file the
7
+ * monitor already wrote (default `pull.json`), asks lib/pr-monitor/auto-actions
8
+ * (the single, unit-tested decision core) what is safe to do, and emits the
9
+ * flags to `$GITHUB_OUTPUT` (or stdout locally) for the workflow's action steps.
10
+ *
11
+ * This script performs NO GitHub writes and takes NO action — it only decides.
12
+ * The workflow owns the visible `gh` calls and the per-head-SHA idempotency
13
+ * markers. Fails CLOSED: an unreadable/malformed payload or any thrown error
14
+ * yields `update_branch=false` / `rerun=false`.
15
+ *
16
+ * Usage: node scripts/pr-auto-actions.js [pull.json] [isFork]
17
+ * isFork: "true" when the PR head is a cross-repository fork (skips update).
18
+ *
19
+ * @module scripts/pr-auto-actions
20
+ */
21
+
22
+ const fs = require('node:fs');
23
+
24
+ const { decideAutoActions } = require('../lib/pr-monitor/auto-actions');
25
+
26
+ /**
27
+ * Append `key=value` lines to `$GITHUB_OUTPUT` when set, else print them. Values
28
+ * are single-line (booleans / csv / short reasons) so no multiline escaping is
29
+ * needed; newlines are stripped defensively.
30
+ *
31
+ * @param {Record<string,string>} outputs
32
+ */
33
+ function emitOutputs(outputs) {
34
+ const target = process.env.GITHUB_OUTPUT;
35
+ const text = Object.entries(outputs)
36
+ .map(([k, val]) => `${k}=${String(val).replace(/[\r\n]+/g, ' ')}`)
37
+ .join('\n') + '\n';
38
+ if (target) fs.appendFileSync(target, text);
39
+ else process.stdout.write(text);
40
+ }
41
+
42
+ /** Read + parse the payload file, returning null (fail-closed) on any error. */
43
+ function readPayload(path) {
44
+ try {
45
+ return JSON.parse(fs.readFileSync(path, 'utf8'));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function main(argv) {
52
+ const path = argv[2] || 'pull.json';
53
+ const isFork = String(argv[3] || '').toLowerCase() === 'true';
54
+
55
+ const payload = readPayload(path);
56
+ if (!payload) {
57
+ // Fail closed: no readable verdict payload → take no action.
58
+ emitOutputs({
59
+ update_branch: 'false', update_reason: `no readable payload at ${path}`,
60
+ rerun: 'false', rerun_run_ids: '', rerun_reason: `no readable payload at ${path}`,
61
+ });
62
+ console.log(`auto-actions: no readable payload at ${path} — no action (fail closed)`);
63
+ return 0;
64
+ }
65
+
66
+ const { updateBranch, rerunFlaky } = decideAutoActions(payload, { isFork });
67
+ emitOutputs({
68
+ update_branch: updateBranch.should ? 'true' : 'false',
69
+ update_reason: updateBranch.reason,
70
+ rerun: rerunFlaky.should ? 'true' : 'false',
71
+ rerun_run_ids: (rerunFlaky.runIds || []).join(','),
72
+ rerun_reason: rerunFlaky.reason,
73
+ });
74
+ console.log(`auto-actions: update_branch=${updateBranch.should} (${updateBranch.reason})`);
75
+ console.log(`auto-actions: rerun=${rerunFlaky.should} (${rerunFlaky.reason})`);
76
+ return 0;
77
+ }
78
+
79
+ if (require.main === module) {
80
+ try {
81
+ process.exit(main(process.argv));
82
+ } catch (err) {
83
+ // Absolute fail-closed backstop: never let this script red-X the monitor.
84
+ emitOutputs({
85
+ update_branch: 'false', update_reason: `error: ${(err && err.message) || err}`,
86
+ rerun: 'false', rerun_run_ids: '', rerun_reason: `error: ${(err && err.message) || err}`,
87
+ });
88
+ console.log(`auto-actions: error — no action (fail closed): ${(err && err.message) || err}`);
89
+ process.exit(0);
90
+ }
91
+ }
92
+
93
+ module.exports = { emitOutputs, readPayload, main };
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * pr-verdict label emitter — maps a canonical merge verdict (from
6
+ * `forge shepherd <pr> --pull --json`, lib/pr-pull.js) to its single
7
+ * `pr-verdict:*` label and emits `label` + `all_labels` to `$GITHUB_OUTPUT`
8
+ * (or stdout locally) for the pr-monitor workflow's reconcile step.
9
+ *
10
+ * SINGLE SOURCE: the verdict value and the label vocabulary both come from
11
+ * lib/pr-pull.js — this script computes NO verdict of its own, so the label can
12
+ * never disagree with `--pull`. Fails closed to `unknown` on empty input.
13
+ * Performs NO GitHub writes (the workflow owns the visible `gh` calls).
14
+ *
15
+ * Usage: node scripts/pr-verdict-label.js <VERDICT>
16
+ *
17
+ * @module scripts/pr-verdict-label
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+
22
+ const { verdictLabel, VERDICT_LABELS } = require('../lib/pr-pull');
23
+
24
+ /**
25
+ * Append `key=value` lines to `$GITHUB_OUTPUT` when set, else print them.
26
+ *
27
+ * @param {Record<string,string>} outputs
28
+ */
29
+ function emitOutputs(outputs) {
30
+ const target = process.env.GITHUB_OUTPUT;
31
+ const text = Object.entries(outputs).map(([k, val]) => `${k}=${val}`).join('\n') + '\n';
32
+ if (target) fs.appendFileSync(target, text);
33
+ else process.stdout.write(text);
34
+ }
35
+
36
+ function main(argv) {
37
+ // Fail closed: a missing/empty verdict arg maps to the `unknown` label rather
38
+ // than erroring — the workflow must still land a label on every pass.
39
+ const verdict = argv[2] || 'UNKNOWN';
40
+ const label = verdictLabel(verdict);
41
+ emitOutputs({ label, all_labels: VERDICT_LABELS.join(',') });
42
+ console.log(`pr-verdict label: ${label} (verdict ${verdict})`);
43
+ return 0;
44
+ }
45
+
46
+ if (require.main === module) {
47
+ process.exit(main(process.argv));
48
+ }
49
+
50
+ module.exports = { emitOutputs, main };