mandrel 1.89.0 → 1.91.0

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 (26) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/schemas/agentrc.schema.json +4 -0
  3. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  4. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  5. package/.agents/scripts/coverage-capture.js +17 -0
  6. package/.agents/scripts/epic-deliver-preflight.js +37 -1
  7. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  8. package/.agents/scripts/lib/config/ci.js +12 -1
  9. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  10. package/.agents/scripts/lib/npm-scripts.js +55 -0
  11. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +10 -5
  12. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +179 -5
  13. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +98 -10
  14. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
  15. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +7 -1
  16. package/.agents/scripts/lib/orchestration/merge-block-class.js +32 -4
  17. package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
  18. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  19. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
  20. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  21. package/.agents/scripts/single-story-init.js +22 -0
  22. package/.agents/workflows/deliver.md +8 -0
  23. package/.agents/workflows/helpers/deliver-epic.md +11 -0
  24. package/.agents/workflows/helpers/single-story-deliver.md +8 -0
  25. package/docs/CHANGELOG.md +14 -0
  26. package/package.json +1 -1
@@ -300,6 +300,7 @@ top-level keys are validation errors.
300
300
  | `ci.watch.maxPolls` | No | `integer` | — | — |
301
301
  | `ci.watch.maxResumes` | No | `integer` | — | — |
302
302
  | `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | — | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
303
+ | `ci.requireChecks` | No | `boolean` | — | Story #4472. Fail-closed-without-checks policy. When true, the AutomergePredicate refuses to arm merge in a repo that reports zero required checks ('no checks reported'), treating the absent CI gate as a hard block. Defaults to false so a checks-less repo with green close-validation gates lands headlessly instead of parking on the operator-merges path. |
303
304
  | `preflight` | No | `object` | — | Story #2899 (Epic #2880, F13). Thresholds consumed by `.agents/scripts/epic-deliver-preflight.js`. When any value is exceeded the preflight envelope flags a breach and /deliver Phase 1 surfaces it via agent::blocked. |
304
305
  | `preflight.maxStories` | No | `integer` | — | — |
305
306
  | `preflight.maxWaves` | No | `integer` | — | — |
@@ -1456,6 +1456,10 @@
1456
1456
  "type": "string",
1457
1457
  "enum": ["trust-ci", "strict"],
1458
1458
  "description": "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate."
1459
+ },
1460
+ "requireChecks": {
1461
+ "type": "boolean",
1462
+ "description": "Story #4472. Fail-closed-without-checks policy. When true, the AutomergePredicate refuses to arm merge in a repo that reports zero required checks ('no checks reported'), treating the absent CI gate as a hard block. Defaults to false so a checks-less repo with green close-validation gates lands headlessly instead of parking on the operator-merges path."
1459
1463
  }
1460
1464
  },
1461
1465
  "additionalProperties": false
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/lifecycle/epic.blocked.schema.json",
4
4
  "title": "epic.blocked",
5
- "description": "Emitted by AcceptanceReconciler (on a failed acceptance reconciliation) and MergeWatcher (on a merge-watch timeout) when the Epic transitions to agent::blocked. Subscribed by NotifyDispatcher, which fans the blocker out to the curated webhook channel. The reason field carries either a typed marker (timeout:<event>, waiver, …) or a free-form summary; sourceStoryId scopes the blocker to a child Story when applicable.",
5
+ "description": "Emitted by AcceptanceReconciler (on a failed acceptance reconciliation), MergeWatcher (on a merge-watch timeout), and — in headless runs (Story #4472) — AutomergePredicate (on a predicate refusal that would otherwise silently park) and AutomergeArmer (on a genuine arm failure) when the Epic transitions to agent::blocked. Subscribed by NotifyDispatcher, which fans the blocker out to the curated webhook channel. The reason field carries either a typed marker (timeout:<event>, merge-predicate:refused, merge-arm:failed, waiver, …) or a free-form summary; sourceStoryId scopes the blocker to a child Story when applicable.",
6
6
  "type": "object",
7
7
  "required": ["reason"],
8
8
  "properties": {
@@ -27,7 +27,8 @@
27
27
  "checks-pending-timeout",
28
28
  "branch-protection-human-required",
29
29
  "arm-failure",
30
- "api-race-other"
30
+ "api-race-other",
31
+ "predicate-refused"
31
32
  ]
32
33
  },
33
34
  "reason": { "type": "string", "minLength": 1 },
@@ -33,6 +33,7 @@ import {
33
33
  } from './lib/coverage-capture.js';
34
34
 
35
35
  import { Logger } from './lib/Logger.js';
36
+ import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
36
37
 
37
38
  function parseArgs(argv) {
38
39
  const out = {
@@ -59,6 +60,22 @@ function main() {
59
60
  return 0;
60
61
  }
61
62
 
63
+ // Story #4473 — detect the "missing npm script" misconfiguration
64
+ // distinctly. `close-validation/gates.js` already declines to register
65
+ // this gate when `test:coverage` is absent, so reaching here without the
66
+ // script means a direct/pre-push invocation in a consumer that never
67
+ // defined it. Surface a one-line, fix-naming diagnostic instead of
68
+ // spawning `npm run test:coverage` only to propagate npm's opaque
69
+ // "Missing script" exit code.
70
+ if (!hasNpmScript(readPackageScripts(args.cwd), 'test:coverage')) {
71
+ Logger.error(
72
+ '[coverage-capture] ✖ No "test:coverage" script in package.json. ' +
73
+ 'Add one (e.g. "test:coverage": "node --test --experimental-test-coverage") ' +
74
+ 'or disable the CRAP gate via delivery.quality.gates.crap.enabled=false.',
75
+ );
76
+ return 1;
77
+ }
78
+
62
79
  if (args.skipWhenNoCrapFiles) {
63
80
  let changed;
64
81
  try {
@@ -11,7 +11,12 @@
11
11
  * `storyCount`, `installCostSeconds`, `dependencyDepth`,
12
12
  * `githubApiRequests`, `claudeQuotaTokens`, plus `breaches`
13
13
  * (the non-empty subset of `delivery.preflight.max*` thresholds the
14
- * estimate exceeds).
14
+ * estimate exceeds), plus `remoteVerified` / `remoteProbe` — the
15
+ * issue #4483 deterministic remote evidence (`git remote get-url
16
+ * origin` + bounded `git ls-remote origin HEAD`). On
17
+ * `remoteVerified: false` the workflow MUST flip the Epic to
18
+ * `agent::blocked` quoting `remoteProbe.detail` — inline delivery to
19
+ * local `main` is never a sanctioned fallback.
15
20
  * 2. When `--post` is set (and `--dry-run` is not), an upserted
16
21
  * `delivery-preflight` structured comment on the Epic ticket so
17
22
  * reviewers reading the Epic discover the same numbers without
@@ -66,6 +71,7 @@ import {
66
71
  computeBaseSha,
67
72
  writePreflightCache,
68
73
  } from './lib/orchestration/preflight-cache.js';
74
+ import { verifyRemote } from './lib/orchestration/remote-verifier.js';
69
75
  import { upsertStructuredComment } from './lib/orchestration/ticketing.js';
70
76
  import { createProvider } from './lib/provider-factory.js';
71
77
 
@@ -218,10 +224,23 @@ export function renderPreflightBody({
218
224
  estimate,
219
225
  breaches,
220
226
  thresholds,
227
+ remote,
221
228
  }) {
222
229
  const lines = [];
223
230
  lines.push(`### 🛫 Delivery preflight — Epic #${epicId}`);
224
231
  lines.push('');
232
+ // Issue #4483 — verified remote evidence at entry. Rendered before the
233
+ // metric table so a reviewer (and the orchestrating agent) sees the
234
+ // land-or-block fact first. Omitted when the caller has no probe result
235
+ // (legacy callers / tests that only exercise the estimate math).
236
+ if (remote) {
237
+ lines.push(
238
+ remote.remoteVerified
239
+ ? `✅ **remoteVerified: true** — ${remote.detail}`
240
+ : `⛔ **remoteVerified: false** — ${remote.detail} — \`/deliver\` MUST transition the Epic to \`agent::blocked\` quoting this probe; inline delivery to local \`main\` is forbidden.`,
241
+ );
242
+ lines.push('');
243
+ }
225
244
  lines.push('| Metric | Estimate | Threshold |');
226
245
  lines.push('| --- | ---: | ---: |');
227
246
  const rows = [
@@ -276,6 +295,7 @@ export function renderPreflightBody({
276
295
  * perStoryClaudeTokens?: number,
277
296
  * injectedProvider?: object,
278
297
  * injectedConfig?: object,
298
+ * verifyRemoteFn?: typeof verifyRemote,
279
299
  * }} args
280
300
  */
281
301
  export async function runPreflight({
@@ -288,6 +308,7 @@ export async function runPreflight({
288
308
  perStoryClaudeTokens,
289
309
  injectedProvider,
290
310
  injectedConfig,
311
+ verifyRemoteFn = verifyRemote,
291
312
  }) {
292
313
  if (!Number.isInteger(epicId) || epicId <= 0) {
293
314
  throw new TypeError('runPreflight: --epic must be a positive integer');
@@ -297,6 +318,13 @@ export async function runPreflight({
297
318
  const provider = injectedProvider ?? createProvider(config);
298
319
  const thresholds = getPreflight(config);
299
320
 
321
+ // Issue #4483 — deterministic remote evidence at entry. Probe BEFORE any
322
+ // provider work so the envelope always carries verified fact (not the
323
+ // agent's perception) about whether a live, pushable `origin` exists.
324
+ // The CLI records; the `/deliver` workflow owns the `agent::blocked`
325
+ // transition on `remoteVerified: false` (same split as breach handling).
326
+ const remote = verifyRemoteFn({ cwd });
327
+
300
328
  // Compose the same two phases /deliver Phase 1 runs so the
301
329
  // preflight numbers match the actual dispatch plan.
302
330
  const ctx = { epicId, provider };
@@ -348,6 +376,13 @@ export async function runPreflight({
348
376
  thresholds,
349
377
  baseSha,
350
378
  cacheWritten,
379
+ // Issue #4483 — verified remote evidence. `remoteVerified: false`
380
+ // REQUIRES the workflow to block explicitly (never build inline).
381
+ remoteVerified: remote.remoteVerified,
382
+ remoteProbe: {
383
+ remoteUrl: remote.remoteUrl,
384
+ detail: remote.detail,
385
+ },
351
386
  };
352
387
 
353
388
  if (post && !dryRun) {
@@ -356,6 +391,7 @@ export async function runPreflight({
356
391
  estimate,
357
392
  breaches,
358
393
  thresholds,
394
+ remote,
359
395
  });
360
396
  await upsertStructuredComment(provider, epicId, 'delivery-preflight', body);
361
397
  envelope.commentUpserted = true;
@@ -6,6 +6,7 @@
6
6
  * runner (`INDEPENDENT_GATE_NAMES` / `partitionGates`).
7
7
  */
8
8
 
9
+ import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
9
10
  import {
10
11
  buildFormatHint,
11
12
  FORMAT_CHECK_FALLBACK,
@@ -87,17 +88,30 @@ function isCrapGateEnabled(config) {
87
88
  }
88
89
 
89
90
  /**
90
- * Conditionally produce the standalone `test` gate entry. Returns an empty
91
- * array when the CRAP gate is enabled (Story #1798: coverage-capture is the
92
- * canonical test runner in that mode); returns the legacy single-entry
93
- * gate otherwise. Splitting this out keeps `buildDefaultGates` flat for
94
- * the CRAP-cyclomatic gate.
91
+ * The gates run in the Story worktree, whose `package.json` is the committed
92
+ * one the consumer ships the presence of a `test:coverage` script is a
93
+ * committed fact, so probing at the gate cwd is authoritative. See
94
+ * `lib/npm-scripts.js` for the shared reader.
95
+ */
96
+
97
+ /**
98
+ * Conditionally produce the standalone `test` gate entry.
95
99
  *
96
- * @param {object|undefined|null} config - Canonical resolved config.
100
+ * The plain `test` gate is the canonical test runner UNLESS the
101
+ * coverage-capture gate is taking that role — which happens only when the
102
+ * CRAP gate is enabled (Story #1798) AND the consumer actually ships a
103
+ * `test:coverage` script for coverage-capture to run (#4473). When CRAP is
104
+ * enabled but `test:coverage` is absent, coverage-capture is dropped from
105
+ * the gate list, so the `test` gate MUST come back — otherwise the consumer
106
+ * has NO working test gate at all. Splitting this out keeps
107
+ * `buildDefaultGates` flat for the CRAP-cyclomatic gate.
108
+ *
109
+ * @param {boolean} coverageCaptureActive - Whether the coverage-capture gate
110
+ * is registered as the test runner for this build.
97
111
  * @returns {Gate[]}
98
112
  */
99
- function buildTestGateEntry(config) {
100
- if (isCrapGateEnabled(config)) return [];
113
+ function buildTestGateEntry(coverageCaptureActive) {
114
+ if (coverageCaptureActive) return [];
101
115
  return [{ name: 'test', cmd: 'npm', args: ['test'] }];
102
116
  }
103
117
 
@@ -105,10 +119,13 @@ function buildTestGateEntry(config) {
105
119
  * Build the canonical close-validation gate list.
106
120
  *
107
121
  * Ordering (cheapest fast-fail first): typecheck → lint → [test] →
108
- * format → coverage-capture → check-baselines. The standalone `test`
109
- * gate is dropped when `crap.enabled === true` (Story #1798) because
110
- * coverage-capture carries test-failure signalling under c8 in that
111
- * mode.
122
+ * format → [coverage-capture] → check-baselines. The standalone `test`
123
+ * gate is dropped when coverage-capture is the active test runner — i.e.
124
+ * `crap.enabled === true` (Story #1798) AND a `test:coverage` script
125
+ * exists (Story #4473) — because coverage-capture then carries
126
+ * test-failure signalling under c8. When CRAP is on but `test:coverage` is
127
+ * absent, coverage-capture is dropped and the `test` gate is restored so
128
+ * there is always a working test gate.
112
129
  *
113
130
  * `typecheck` is mandatory; consumers may customise the command via
114
131
  * `project.commands.typecheck` (default `npm run typecheck`).
@@ -127,15 +144,34 @@ function buildTestGateEntry(config) {
127
144
  * re-discovered inherited main-vs-epic drift in untouched files as phantom
128
145
  * regressions and worked around it by hand-setting `BASELINE_REF`.
129
146
  *
130
- * @param {{ config?: object, epicBranch?: string }} [opts] - `config` is the
131
- * canonical resolved config (`{ project, delivery, ... }`); gate commands
132
- * resolve from `project.commands` and the CRAP toggle from
147
+ * Story #4473 the coverage-capture gate spawns `npm run test:coverage`,
148
+ * so it is registered ONLY when the consumer actually ships that script.
149
+ * When CRAP is enabled but `test:coverage` is absent, coverage-capture is
150
+ * dropped and the plain `test` gate is restored (see `buildTestGateEntry`),
151
+ * so a consumer without a coverage script gets a working degraded test gate
152
+ * instead of a deterministic close failure with no test gate at all. The
153
+ * probe reads `package.json` at `cwd` (the gate execution directory).
154
+ *
155
+ * @param {{ config?: object, epicBranch?: string, cwd?: string, packageScripts?: Record<string, string> }} [opts]
156
+ * `config` is the canonical resolved config (`{ project, delivery, ... }`);
157
+ * gate commands resolve from `project.commands` and the CRAP toggle from
133
158
  * `delivery.quality.gates.crap.enabled`. `epicBranch` is the close run's
134
159
  * integration branch (`epic/<id>` for Epic-attached Stories, the base
135
- * branch for standalone Stories).
160
+ * branch for standalone Stories). `cwd` is where the `package.json`
161
+ * coverage-script probe reads from (defaults to `process.cwd()`);
162
+ * `packageScripts` injects the scripts map directly (tests) and short-
163
+ * circuits the disk read.
136
164
  * @returns {Gate[]}
137
165
  */
138
- export function buildDefaultGates({ config, epicBranch } = {}) {
166
+ export function buildDefaultGates({
167
+ config,
168
+ epicBranch,
169
+ cwd,
170
+ packageScripts,
171
+ } = {}) {
172
+ const scripts = packageScripts ?? readPackageScripts(cwd);
173
+ const coverageCaptureActive =
174
+ isCrapGateEnabled(config) && hasNpmScript(scripts, 'test:coverage');
139
175
  const typecheckCmdString = resolveTypecheckCommand(config);
140
176
  const [typecheckCmd, ...typecheckArgs] = typecheckCmdString
141
177
  .split(/\s+/)
@@ -158,7 +194,7 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
158
194
  hint: TYPECHECK_HINT,
159
195
  },
160
196
  { name: 'lint', cmd: 'npm', args: ['run', 'lint'] },
161
- ...buildTestGateEntry(config),
197
+ ...buildTestGateEntry(coverageCaptureActive),
162
198
  {
163
199
  // Gate name kept generic ("format") so the close-orchestrator log line
164
200
  // and the per-gate phase-timer key don't shift when a repo swaps biome
@@ -172,12 +208,16 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
172
208
  ? { changedFileScope: formatChangedFileScope }
173
209
  : {}),
174
210
  },
175
- {
176
- name: 'coverage-capture',
177
- cmd: 'node',
178
- args: ['.agents/scripts/coverage-capture.js'],
179
- hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
180
- },
211
+ ...(coverageCaptureActive
212
+ ? [
213
+ {
214
+ name: 'coverage-capture',
215
+ cmd: 'node',
216
+ args: ['.agents/scripts/coverage-capture.js'],
217
+ hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
218
+ },
219
+ ]
220
+ : []),
181
221
  {
182
222
  // Story #2210 — unified `check-baselines` gate is the only path for
183
223
  // per-kind regression enforcement. The legacy per-kind in-process
@@ -12,12 +12,19 @@
12
12
  * waves run; `watch` tunes the merge/CI watch poll loop; and `autoMerge`
13
13
  * (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
14
14
  * required checks pass, `"strict"` additionally requires a clean review gate.
15
+ *
16
+ * Story #4472 adds `requireChecks` (default `false`): when `true` the
17
+ * AutomergePredicate treats a checks-less repo ("no checks reported") as a
18
+ * hard block rather than green, so a consumer that wants fail-closed-without-
19
+ * checks as policy opts into it explicitly instead of the framework blocking
20
+ * implicitly.
15
21
  */
16
22
 
17
23
  export const CI_DELIVERY_DEFAULTS = Object.freeze({
18
24
  skipForStoryPushes: true,
19
25
  earlyPr: true,
20
26
  autoMerge: 'trust-ci',
27
+ requireChecks: false,
21
28
  });
22
29
 
23
30
  /**
@@ -28,7 +35,7 @@ export const CI_DELIVERY_DEFAULTS = Object.freeze({
28
35
  * defaults; only the scalar knobs carry framework defaults here.
29
36
  *
30
37
  * @param {object | null | undefined} config
31
- * @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', watch: object | undefined }}
38
+ * @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', requireChecks: boolean, watch: object | undefined }}
32
39
  */
33
40
  export function getCiDelivery(config) {
34
41
  const ci = config?.delivery?.ci ?? config?.ci ?? config ?? {};
@@ -45,6 +52,10 @@ export function getCiDelivery(config) {
45
52
  ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
46
53
  ? ci.autoMerge
47
54
  : CI_DELIVERY_DEFAULTS.autoMerge,
55
+ requireChecks:
56
+ typeof ci.requireChecks === 'boolean'
57
+ ? ci.requireChecks
58
+ : CI_DELIVERY_DEFAULTS.requireChecks,
48
59
  watch:
49
60
  ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
50
61
  };
@@ -264,6 +264,13 @@ const CI_DELIVERY_SCHEMA = {
264
264
  earlyPr: { type: 'boolean' },
265
265
  watch: CI_WATCH_SCHEMA,
266
266
  autoMerge: { type: 'string', enum: ['trust-ci', 'strict'] },
267
+ // Story #4472 — fail-closed-without-checks policy. When `true`, the
268
+ // AutomergePredicate refuses to arm merge in a repo that reports zero
269
+ // required checks ("no checks reported"), treating the absence of a CI
270
+ // gate as a hard block instead of green. Defaults to `false` so a
271
+ // checks-less repo with green close-validation gates lands headlessly
272
+ // rather than parking on the operator-merges path.
273
+ requireChecks: { type: 'boolean' },
267
274
  },
268
275
  additionalProperties: false,
269
276
  };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * npm-scripts.js — shared `package.json` scripts probe (Story #4473).
3
+ *
4
+ * A single, dependency-free reader used wherever the framework must decide
5
+ * whether a consumer actually ships a given npm script before spawning
6
+ * `npm run <name>`. Two call sites depend on it:
7
+ * - `close-validation/gates.js` — only registers the coverage-capture gate
8
+ * when a `test:coverage` script exists (otherwise a consumer without it
9
+ * turns the gate into a guaranteed first-try close failure).
10
+ * - `coverage-capture.js` — fails fast with a one-line, fix-naming
11
+ * diagnostic instead of surfacing npm's opaque "Missing script" exit when
12
+ * invoked without the script.
13
+ *
14
+ * The reader is deliberately forgiving: any failure (missing file,
15
+ * unreadable, unparseable, or no `scripts` object) resolves to an empty map
16
+ * so callers treat "cannot prove the script exists" as "absent" without
17
+ * throwing.
18
+ */
19
+
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ /**
24
+ * Read the `scripts` map from the `package.json` at `cwd`.
25
+ *
26
+ * @param {string|undefined|null} cwd - Directory containing `package.json`.
27
+ * Defaults to `process.cwd()`.
28
+ * @returns {Record<string, string>} The scripts map, or `{}` on any failure.
29
+ */
30
+ export function readPackageScripts(cwd) {
31
+ try {
32
+ const pkgPath = path.join(cwd || process.cwd(), 'package.json');
33
+ if (!existsSync(pkgPath)) return {};
34
+ const parsed = JSON.parse(readFileSync(pkgPath, 'utf8'));
35
+ return parsed && typeof parsed.scripts === 'object' && parsed.scripts
36
+ ? parsed.scripts
37
+ : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Does the consumer define a runnable npm script by this name? A script is
45
+ * "runnable" when it is a present, non-empty string.
46
+ *
47
+ * @param {Record<string, string>} scripts - A scripts map (from
48
+ * `readPackageScripts`).
49
+ * @param {string} name - The script name to check (e.g. `test:coverage`).
50
+ * @returns {boolean}
51
+ */
52
+ export function hasNpmScript(scripts, name) {
53
+ const s = scripts?.[name];
54
+ return typeof s === 'string' && s.trim().length > 0;
55
+ }
@@ -48,9 +48,13 @@
48
48
  *
49
49
  * The schema declares `additionalProperties: false`, so this emitter's
50
50
  * signature is deliberately narrow: only the schema-allowed fields are
51
- * accepted. `blockClass` MUST be one of the four classes named in
52
- * `merge-block-class.js` — pass the classifier's verdict straight
53
- * through (`classifyMergeBlock(...)` returns `{ blockClass, reason }`).
51
+ * accepted. `blockClass` MUST be a valid `merge.unlanded` attribution from
52
+ * `merge-block-class.js` (`MERGE_UNLANDED_BLOCK_CLASSES` — the four
53
+ * `classifyMergeBlock` outputs plus the directly-emitted `predicate-refused`,
54
+ * Story #4472). For a post-arm poll-exhaustion block, pass the classifier's
55
+ * verdict straight through (`classifyMergeBlock(...)` returns
56
+ * `{ blockClass, reason }`); the predicate/armer refusal paths pass
57
+ * `predicate-refused` / a classified arm failure directly.
54
58
  */
55
59
 
56
60
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
@@ -98,8 +102,9 @@ function getValidator() {
98
102
  * @param {number} opts.ticketId epicId when `scope === 'epic'`,
99
103
  * storyId when `scope === 'story'`.
100
104
  * @param {number} opts.prNumber The PR number that did not land.
101
- * @param {string} opts.blockClass One of the four
102
- * `merge-block-class.js` classes.
105
+ * @param {string} opts.blockClass A valid `merge.unlanded` attribution
106
+ * (`MERGE_UNLANDED_BLOCK_CLASSES` in
107
+ * `merge-block-class.js`).
103
108
  * @param {string} opts.reason Free-form diagnosis detail — pass
104
109
  * the classifier's `reason`.
105
110
  * @param {number} opts.elapsedSeconds Elapsed watch/poll time when the