@deftai/directive-core 0.67.0 → 0.68.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 (90) hide show
  1. package/dist/check/index.d.ts +1 -1
  2. package/dist/check/index.js +1 -1
  3. package/dist/check/orchestrator.d.ts +14 -4
  4. package/dist/check/orchestrator.js +30 -6
  5. package/dist/doctor/checks.js +11 -11
  6. package/dist/doctor/constants.d.ts +2 -0
  7. package/dist/doctor/constants.js +2 -0
  8. package/dist/doctor/payload-staleness.js +15 -6
  9. package/dist/forward-coverage/evaluate.d.ts +55 -0
  10. package/dist/forward-coverage/evaluate.js +254 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/init-deposit/constants.d.ts +14 -0
  14. package/dist/init-deposit/constants.js +10 -0
  15. package/dist/init-deposit/hygiene.d.ts +50 -0
  16. package/dist/init-deposit/hygiene.js +176 -0
  17. package/dist/init-deposit/init-deposit.d.ts +1 -0
  18. package/dist/init-deposit/init-deposit.js +7 -1
  19. package/dist/init-deposit/refresh.d.ts +2 -0
  20. package/dist/init-deposit/refresh.js +48 -30
  21. package/dist/init-deposit/scaffold.d.ts +3 -4
  22. package/dist/init-deposit/scaffold.js +3 -24
  23. package/dist/integration-e2e/helpers.d.ts +0 -1
  24. package/dist/integration-e2e/helpers.js +3 -7
  25. package/dist/layout/lifecycle-ref.d.ts +15 -0
  26. package/dist/layout/lifecycle-ref.js +55 -0
  27. package/dist/platform/agents-md.d.ts +28 -0
  28. package/dist/platform/agents-md.js +34 -0
  29. package/dist/policy/resolve.js +56 -46
  30. package/dist/pr-merge-readiness/compute.d.ts +10 -0
  31. package/dist/pr-merge-readiness/compute.js +53 -17
  32. package/dist/pr-merge-readiness/index.d.ts +2 -1
  33. package/dist/pr-merge-readiness/index.js +1 -0
  34. package/dist/pr-merge-readiness/mergeability.d.ts +43 -0
  35. package/dist/pr-merge-readiness/mergeability.js +105 -0
  36. package/dist/pr-monitor/index.d.ts +1 -1
  37. package/dist/pr-monitor/index.js +1 -1
  38. package/dist/pr-monitor/monitor.d.ts +8 -2
  39. package/dist/pr-monitor/monitor.js +35 -6
  40. package/dist/pr-wait-mergeable/wrappers.d.ts +12 -1
  41. package/dist/pr-wait-mergeable/wrappers.js +13 -5
  42. package/dist/pr-watch/constants.d.ts +33 -0
  43. package/dist/pr-watch/constants.js +33 -0
  44. package/dist/pr-watch/index.d.ts +6 -0
  45. package/dist/pr-watch/index.js +6 -0
  46. package/dist/pr-watch/main.d.ts +21 -0
  47. package/dist/pr-watch/main.js +206 -0
  48. package/dist/pr-watch/probe.d.ts +13 -0
  49. package/dist/pr-watch/probe.js +100 -0
  50. package/dist/pr-watch/types.d.ts +47 -0
  51. package/dist/pr-watch/types.js +2 -0
  52. package/dist/pr-watch/watch.d.ts +12 -0
  53. package/dist/pr-watch/watch.js +122 -0
  54. package/dist/render/project-render.js +114 -89
  55. package/dist/scope/capacity-stamp.d.ts +24 -2
  56. package/dist/scope/capacity-stamp.js +80 -28
  57. package/dist/scope/decomposed-refs.js +2 -2
  58. package/dist/scope/project-definition-sync.js +3 -3
  59. package/dist/scope/vbrief-ref.d.ts +2 -1
  60. package/dist/scope/vbrief-ref.js +3 -2
  61. package/dist/spec-authority/constants.d.ts +6 -0
  62. package/dist/spec-authority/constants.js +11 -0
  63. package/dist/spec-authority/resolver.js +3 -3
  64. package/dist/swarm/finalize-cohort-cli.d.ts +5 -0
  65. package/dist/swarm/finalize-cohort-cli.js +100 -0
  66. package/dist/swarm/finalize-cohort.d.ts +39 -0
  67. package/dist/swarm/finalize-cohort.js +589 -0
  68. package/dist/swarm/index.d.ts +2 -0
  69. package/dist/swarm/index.js +2 -0
  70. package/dist/swarm/launch.d.ts +7 -0
  71. package/dist/swarm/launch.js +17 -5
  72. package/dist/triage/queue/index.d.ts +1 -0
  73. package/dist/triage/queue/index.js +1 -0
  74. package/dist/triage/queue/project.d.ts +6 -1
  75. package/dist/triage/queue/project.js +9 -4
  76. package/dist/triage/queue/reconcile-live-state.d.ts +42 -0
  77. package/dist/triage/queue/reconcile-live-state.js +54 -0
  78. package/dist/triage/scope/mutations-core.js +91 -81
  79. package/dist/triage/scope/resolve.js +4 -3
  80. package/dist/triage/scope-drift/add-ignore.js +33 -32
  81. package/dist/triage/scope-drift/scope-rules.js +3 -3
  82. package/dist/triage/subscribe/index.js +51 -45
  83. package/dist/triage/welcome/writers.js +79 -64
  84. package/dist/vbrief-build/project-definition-io.js +8 -3
  85. package/dist/vbrief-validate/paths.js +8 -4
  86. package/dist/vbrief-validate/precutover.d.ts +1 -1
  87. package/dist/vbrief-validate/precutover.js +12 -10
  88. package/package.json +7 -7
  89. package/dist/install-upgrade/index.d.ts +0 -13
  90. package/dist/install-upgrade/index.js +0 -251
@@ -3,7 +3,9 @@ import { randomUUID } from "node:crypto";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { atomicWriteText } from "../cache/io.js";
6
7
  import { contentRoot } from "../content-root.js";
8
+ import { withAppendLock } from "../slice/lock.js";
7
9
  import { composeGreenfieldAgentsMd } from "./agents-consumer-header.js";
8
10
  import { AGENTS_MANAGED_CLOSE, AGENTS_MANAGED_OPEN_V3_LITERAL } from "./constants.js";
9
11
  import { findManagedOpenMarker } from "./linear-scan.js";
@@ -294,6 +296,38 @@ export function agentsRefreshPlan(projectRoot, seams = {}) {
294
296
  new_content: newContent,
295
297
  };
296
298
  }
299
+ /** Managed-section states whose plan yields a new AGENTS.md body to write. */
300
+ const AGENTS_REFRESH_WRITABLE_STATES = new Set(["absent", "missing", "stale"]);
301
+ /**
302
+ * Serialize the AGENTS.md managed-section read->compute->write behind an advisory
303
+ * file lock and write atomically (temp file + rename), so concurrent
304
+ * install/upgrade/migrate/refresh writers cannot clobber one another's `session=`
305
+ * write or leave a partially-written file (#1329).
306
+ *
307
+ * Reuses the existing cross-process append lock (`withAppendLock`, keyed here on
308
+ * the AGENTS.md path -> `AGENTS.md.lock`) and atomic writer (`atomicWriteText`)
309
+ * rather than introducing a competing lock utility. The parallel
310
+ * `projectDefinitionMutationLock` remains owned by #1260; a general lock helper is
311
+ * deliberately not added here to avoid two competing mutation-lock utilities.
312
+ */
313
+ export function applyAgentsRefresh(projectRoot, options = {}, seams = {}, lockDeps = {}) {
314
+ const agentsMd = join(projectRoot, "AGENTS.md");
315
+ return withAppendLock(agentsMd, () => {
316
+ const plan = agentsRefreshPlan(projectRoot, seams);
317
+ const state = String(plan.state ?? "unknown");
318
+ const path = String(plan.path ?? agentsMd);
319
+ const newContent = plan.new_content;
320
+ if (options.check === true ||
321
+ options.dryRun === true ||
322
+ !AGENTS_REFRESH_WRITABLE_STATES.has(state) ||
323
+ typeof newContent !== "string") {
324
+ const writable = AGENTS_REFRESH_WRITABLE_STATES.has(state) && typeof newContent === "string";
325
+ return { state, path, wrote: false, writable };
326
+ }
327
+ atomicWriteText(path, newContent);
328
+ return { state, path, wrote: true, writable: true };
329
+ }, lockDeps);
330
+ }
297
331
  export function hasV3ManagedMarker(projectRoot, readText = defaultReadAgents) {
298
332
  const agentsMd = join(projectRoot, "AGENTS.md");
299
333
  const text = readText(agentsMd);
@@ -1,6 +1,7 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve as pathResolve } from "node:path";
3
3
  import { resolveProjectDefinitionPath } from "../layout/resolve.js";
4
+ import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
4
5
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
5
6
  /** Filesystem-relative location of the project-definition vBRIEF (display/back-compat). */
6
7
  export const PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json";
@@ -184,55 +185,64 @@ export function setPolicy(projectRoot, options) {
184
185
  if (!existsSync(path)) {
185
186
  throw new Error(`PROJECT-DEFINITION not found at ${path}`);
186
187
  }
187
- const data = JSON.parse(readFileSync(path, { encoding: "utf8" }));
188
- if (typeof data.plan !== "object" || data.plan === null || Array.isArray(data.plan)) {
189
- if (data.plan === undefined) {
190
- data.plan = {};
188
+ // Serialise the read-modify-write + audit-log append behind the shared
189
+ // PROJECT-DEFINITION mutation lock so a concurrent policy/ritual mutator
190
+ // cannot lose this update or desync the typed flag from the audit row (#1260).
191
+ return projectDefinitionMutationLock(projectRoot, () => {
192
+ const parsed = JSON.parse(readFileSync(path, { encoding: "utf8" }));
193
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
194
+ throw new Error(`PROJECT-DEFINITION at ${path} top-level value is not a JSON object`);
191
195
  }
192
- else {
193
- throw new Error("PROJECT-DEFINITION 'plan' is not an object");
196
+ const data = parsed;
197
+ if (typeof data.plan !== "object" || data.plan === null || Array.isArray(data.plan)) {
198
+ if (data.plan === undefined) {
199
+ data.plan = {};
200
+ }
201
+ else {
202
+ throw new Error("PROJECT-DEFINITION 'plan' is not an object");
203
+ }
194
204
  }
195
- }
196
- const plan = data.plan;
197
- migrateLegacyPolicyKey(plan);
198
- const existingPolicy = plan[PLAN_POLICY_KEY];
199
- if (typeof existingPolicy !== "object" ||
200
- existingPolicy === null ||
201
- Array.isArray(existingPolicy)) {
202
- if (existingPolicy === undefined) {
203
- plan[PLAN_POLICY_KEY] = {};
205
+ const plan = data.plan;
206
+ migrateLegacyPolicyKey(plan);
207
+ const existingPolicy = plan[PLAN_POLICY_KEY];
208
+ if (typeof existingPolicy !== "object" ||
209
+ existingPolicy === null ||
210
+ Array.isArray(existingPolicy)) {
211
+ if (existingPolicy === undefined) {
212
+ plan[PLAN_POLICY_KEY] = {};
213
+ }
214
+ else {
215
+ throw new Error("plan.policy is not an object");
216
+ }
204
217
  }
205
- else {
206
- throw new Error("plan.policy is not an object");
218
+ const policyBlock = plan[PLAN_POLICY_KEY];
219
+ const previous = policyBlock.allowDirectCommitsToMaster;
220
+ policyBlock.allowDirectCommitsToMaster = Boolean(allowDirectCommits);
221
+ let legacyDropped = false;
222
+ const narratives = plan.narratives;
223
+ if (typeof narratives === "object" &&
224
+ narratives !== null &&
225
+ !Array.isArray(narratives) &&
226
+ LEGACY_NARRATIVE_KEY in narratives) {
227
+ delete narratives[LEGACY_NARRATIVE_KEY];
228
+ legacyDropped = true;
207
229
  }
208
- }
209
- const policyBlock = plan[PLAN_POLICY_KEY];
210
- const previous = policyBlock.allowDirectCommitsToMaster;
211
- policyBlock.allowDirectCommitsToMaster = Boolean(allowDirectCommits);
212
- let legacyDropped = false;
213
- const narratives = plan.narratives;
214
- if (typeof narratives === "object" &&
215
- narratives !== null &&
216
- !Array.isArray(narratives) &&
217
- LEGACY_NARRATIVE_KEY in narratives) {
218
- delete narratives[LEGACY_NARRATIVE_KEY];
219
- legacyDropped = true;
220
- }
221
- writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8" });
222
- const changed = previous !== Boolean(allowDirectCommits) || legacyDropped;
223
- const parts = [
224
- `actor=${actor}`,
225
- `allowDirectCommitsToMaster=${allowDirectCommits ? "true" : "false"}`,
226
- `previous=${pythonRepr(previous)}`,
227
- ];
228
- if (legacyDropped) {
229
- parts.push("legacy-narrative-migrated=true");
230
- }
231
- if (note) {
232
- parts.push(`note=${note.replace(/\n/g, " ").replace(/\r/g, " ")}`);
233
- }
234
- const auditEntry = parts.join(" ");
235
- appendAuditLog(projectRoot, auditEntry);
236
- return { changed, auditEntry };
230
+ atomicWriteProjectDefinition(path, data);
231
+ const changed = previous !== Boolean(allowDirectCommits) || legacyDropped;
232
+ const parts = [
233
+ `actor=${actor}`,
234
+ `allowDirectCommitsToMaster=${allowDirectCommits ? "true" : "false"}`,
235
+ `previous=${pythonRepr(previous)}`,
236
+ ];
237
+ if (legacyDropped) {
238
+ parts.push("legacy-narrative-migrated=true");
239
+ }
240
+ if (note) {
241
+ parts.push(`note=${note.replace(/\n/g, " ").replace(/\r/g, " ")}`);
242
+ }
243
+ const auditEntry = parts.join(" ");
244
+ appendAuditLog(projectRoot, auditEntry);
245
+ return { changed, auditEntry };
246
+ });
237
247
  }
238
248
  //# sourceMappingURL=resolve.js.map
@@ -1,9 +1,19 @@
1
1
  import type { CiGateOptions } from "./ci-gate.js";
2
2
  import { isMergeReady } from "./evaluate.js";
3
+ import { type MergeabilitySignal } from "./mergeability.js";
3
4
  import type { SlizardGateOptions } from "./slizard-gate.js";
4
5
  import type { GateResult, RunGhFn } from "./types.js";
5
6
  declare function buildGateResult(prNumber: number, repo: string | null, headSha: string | null, body: string, via: string, partialData?: Record<string, unknown>, error?: string | null): GateResult;
7
+ /** Injectable GitHub-mergeability read seam (#2260) — keeps unit tests hermetic. */
8
+ export type FetchMergeabilityFn = (prNumber: number, repo: string, runGh: RunGhFn) => MergeabilitySignal;
6
9
  export interface ComputeGateOptions extends CiGateOptions, SlizardGateOptions {
10
+ /** Override the GitHub-mergeability read (defaults to the REST reader). */
11
+ readonly fetchMergeabilityFn?: FetchMergeabilityFn;
12
+ /**
13
+ * Disable the #2260 mergeability reconciliation (defaults to enabled). When
14
+ * off, an absent/stale review verdict blocks even on a GitHub-CLEAN PR.
15
+ */
16
+ readonly disableMergeabilityReconcile?: boolean;
7
17
  }
8
18
  /** Run the primary->fallback1->fallback2 cascade and return a result. */
9
19
  export declare function computeGateResult(prNumber: number, repo: string | null, runGh: RunGhFn, options?: ComputeGateOptions): GateResult;
@@ -2,6 +2,7 @@ import { buildCiSummaryLine, evaluateCiGate } from "./ci-gate.js";
2
2
  import { FALLBACK2_NOT_CLEAN_MSG, VIA_ERROR, VIA_FALLBACK1, VIA_FALLBACK2, VIA_PRIMARY, } from "./constants.js";
3
3
  import { evaluateGates, isMergeReady } from "./evaluate.js";
4
4
  import { fetchCheckRunsRest, fetchGreptileBodyRest, fetchGreptileCommentBody, fetchPrHeadSha, fetchPrHeadShaRest, resolveRepo, } from "./gh.js";
5
+ import { fetchMergeability, isGithubMergeableClean, mergeabilityToDict, verdictBlockIsSoftOnly, verdictShaIsStale, } from "./mergeability.js";
5
6
  import { emptyVerdict, parseGreptileBody } from "./parse.js";
6
7
  import { evaluateSlizardGate, isSlizardCheck } from "./slizard-gate.js";
7
8
  function buildGateResult(prNumber, repo, headSha, body, via, partialData = {}, error = null) {
@@ -86,6 +87,54 @@ function applyCiGateForHead(repo, headSha, runGh, options) {
86
87
  const outcome = evaluateCiAndSlizard(check.checkRuns, options);
87
88
  return { failures: outcome.failures, ci: outcome.ci, slizard: outcome.slizard };
88
89
  }
90
+ /**
91
+ * Score the verdict gate for a resolved head SHA, then (when the review verdict
92
+ * is only soft-blocked -- absent or stale for the current head) reconcile
93
+ * against GitHub's own mergeability (#2260). Shared by primary + fallback1.
94
+ */
95
+ function finalizeVerdictGate(prNumber, repo, headSha, verdict, runGh, options) {
96
+ const failures = evaluateGates(prNumber, headSha, verdict);
97
+ const partialData = {};
98
+ const resolved = resolveRepo(repo, runGh);
99
+ if (failures.length === 0) {
100
+ const ci = applyCiGateForHead(resolved.repo, headSha, runGh, options);
101
+ failures.push(...ci.failures);
102
+ partialData.ci = ci.ci;
103
+ partialData.slizard = ci.slizard;
104
+ return { failures, partialData };
105
+ }
106
+ // #2260 reconciliation: the verdict gate failed. If the block is a HARD
107
+ // finding (genuine P0/P1, ERRORED, low confidence on the current head), keep
108
+ // blocking. Only reconcile a SOFT block (verdict absent / stale head SHA).
109
+ if (options.disableMergeabilityReconcile === true ||
110
+ resolved.repo === null ||
111
+ !verdictBlockIsSoftOnly(verdict, headSha)) {
112
+ return { failures, partialData };
113
+ }
114
+ const ci = applyCiGateForHead(resolved.repo, headSha, runGh, options);
115
+ partialData.ci = ci.ci;
116
+ partialData.slizard = ci.slizard;
117
+ const fetchMergeabilityFn = options.fetchMergeabilityFn ?? fetchMergeability;
118
+ const signal = fetchMergeabilityFn(prNumber, resolved.repo, runGh);
119
+ partialData.mergeability = mergeabilityToDict(signal);
120
+ if (isGithubMergeableClean(signal)) {
121
+ // GitHub itself reports CLEAN + MERGEABLE: required checks are green and the
122
+ // branch is mergeable. Proceed even though the OPTIONAL review verdict is
123
+ // absent/stale for the head SHA -- exactly the manual `gh pr merge --admin`
124
+ // case this fix reproduces automatically (#2260).
125
+ partialData.verdict_override = {
126
+ reason: verdictShaIsStale(verdict, headSha) ? "verdict-stale-head-sha" : "verdict-absent",
127
+ basis: "github mergeable_state=clean + mergeable=true (mergeStateStatus:CLEAN + " +
128
+ "mergeable:MERGEABLE); required checks green per GitHub branch protection (#2260)",
129
+ overridden_failures: [...failures],
130
+ };
131
+ return { failures: [], partialData };
132
+ }
133
+ // Not overridable (GitHub not CLEAN/MERGEABLE). Preserve the soft verdict
134
+ // failures and surface any CI failures so the heartbeat's blocked-on is clear.
135
+ failures.push(...ci.failures);
136
+ return { failures, partialData };
137
+ }
89
138
  function computePrimary(prNumber, repo, runGh, options) {
90
139
  const partial = {};
91
140
  const headSha = fetchPrHeadSha(prNumber, repo, runGh);
@@ -100,15 +149,7 @@ function computePrimary(prNumber, repo, runGh, options) {
100
149
  return { result: null, partial };
101
150
  }
102
151
  const verdict = parseGreptileBody(body);
103
- const failures = evaluateGates(prNumber, headSha, verdict);
104
- const partialData = {};
105
- if (failures.length === 0) {
106
- const resolved = resolveRepo(repo, runGh);
107
- const ci = applyCiGateForHead(resolved.repo, headSha, runGh, options);
108
- failures.push(...ci.failures);
109
- partialData.ci = ci.ci;
110
- partialData.slizard = ci.slizard;
111
- }
152
+ const { failures, partialData } = finalizeVerdictGate(prNumber, repo, headSha, verdict, runGh, options);
112
153
  return {
113
154
  result: {
114
155
  prNumber,
@@ -146,26 +187,21 @@ function computeFallback1(prNumber, repo, primaryPartial, runGh, options) {
146
187
  return { result: null, partial };
147
188
  }
148
189
  const verdict = parseGreptileBody(bodyResult.body);
149
- const failures = evaluateGates(prNumber, headSha, verdict);
190
+ const gate = finalizeVerdictGate(prNumber, resolved.repo, headSha, verdict, runGh, options);
150
191
  const partialData = {};
151
192
  for (const [key, value] of Object.entries(partial)) {
152
193
  if (key !== "head_sha") {
153
194
  partialData[key] = value;
154
195
  }
155
196
  }
156
- if (failures.length === 0) {
157
- const ci = applyCiGateForHead(resolved.repo, headSha, runGh, options);
158
- failures.push(...ci.failures);
159
- partialData.ci = ci.ci;
160
- partialData.slizard = ci.slizard;
161
- }
197
+ Object.assign(partialData, gate.partialData);
162
198
  return {
163
199
  result: {
164
200
  prNumber,
165
201
  repo: resolved.repo,
166
202
  headSha,
167
203
  verdict,
168
- failures,
204
+ failures: gate.failures,
169
205
  via: VIA_FALLBACK1,
170
206
  partialData,
171
207
  error: null,
@@ -1,9 +1,10 @@
1
1
  export { buildCiSummaryLine, evaluateCiGate } from "./ci-gate.js";
2
- export { computeGateResult } from "./compute.js";
2
+ export { type ComputeGateOptions, computeGateResult, type FetchMergeabilityFn } from "./compute.js";
3
3
  export * from "./constants.js";
4
4
  export { evaluateGates, isMergeReady } from "./evaluate.js";
5
5
  export { defaultRunGh } from "./gh.js";
6
6
  export { cmdPrMergeReadiness, parseArgs, run } from "./main.js";
7
+ export { fetchMergeability, isGithubMergeableClean, MERGE_STATE_CLEAN, type MergeabilitySignal, mergeabilityToDict, verdictBlockIsSoftOnly, verdictShaIsStale, } from "./mergeability.js";
7
8
  export { emitJson, exitCodeFor, gateResultToDict, printHuman } from "./output.js";
8
9
  export { emptyVerdict, isInformalCleanMissingCanonicalFields, parseGreptileBody } from "./parse.js";
9
10
  export type { SlizardGateOptions, SlizardGateResult, SlizardGateSummary, SlizardVerdict, } from "./slizard-gate.js";
@@ -4,6 +4,7 @@ export * from "./constants.js";
4
4
  export { evaluateGates, isMergeReady } from "./evaluate.js";
5
5
  export { defaultRunGh } from "./gh.js";
6
6
  export { cmdPrMergeReadiness, parseArgs, run } from "./main.js";
7
+ export { fetchMergeability, isGithubMergeableClean, MERGE_STATE_CLEAN, mergeabilityToDict, verdictBlockIsSoftOnly, verdictShaIsStale, } from "./mergeability.js";
7
8
  export { emitJson, exitCodeFor, gateResultToDict, printHuman } from "./output.js";
8
9
  export { emptyVerdict, isInformalCleanMissingCanonicalFields, parseGreptileBody } from "./parse.js";
9
10
  export { evaluateSlizardGate, isSlizardCheck, parseSlizardVerdict, SLIZARD_CHECK_NAME, } from "./slizard-gate.js";
@@ -0,0 +1,43 @@
1
+ import type { GreptileVerdict, RunGhFn } from "./types.js";
2
+ /**
3
+ * GitHub's authoritative mergeability signal, read over REST (#2260).
4
+ *
5
+ * REST `mergeable_state` maps 1:1 to GraphQL `mergeStateStatus`
6
+ * (e.g. `"clean"` == `CLEAN`); `mergeable` (boolean|null) maps to
7
+ * `mergeable` (`MERGEABLE`/`CONFLICTING`/`UNKNOWN`). We read REST to honor
8
+ * the "prefer REST over GraphQL" rule (#954) and to reuse the same
9
+ * `gh api repos/<owner>/<repo>/pulls/<N>` surface fallback2 already uses.
10
+ */
11
+ export interface MergeabilitySignal {
12
+ readonly mergeableState: string | null;
13
+ readonly mergeable: boolean | null;
14
+ readonly error: string | null;
15
+ }
16
+ /** REST `mergeable_state` value that mirrors GraphQL `mergeStateStatus: CLEAN`. */
17
+ export declare const MERGE_STATE_CLEAN = "clean";
18
+ /**
19
+ * Read GitHub's own mergeability verdict for a PR over REST.
20
+ *
21
+ * Injectable via `runGh` so unit tests stay hermetic (no live network).
22
+ */
23
+ export declare function fetchMergeability(prNumber: number, repo: string, runGh: RunGhFn): MergeabilitySignal;
24
+ /** True when GitHub itself reports the PR as CLEAN + MERGEABLE (#2260). */
25
+ export declare function isGithubMergeableClean(signal: MergeabilitySignal): boolean;
26
+ /** Serialise the mergeability signal into the partial_data envelope shape. */
27
+ export declare function mergeabilityToDict(signal: MergeabilitySignal): Record<string, unknown>;
28
+ /** True when a parsed verdict SHA does not match the current PR head SHA. */
29
+ export declare function verdictShaIsStale(verdict: GreptileVerdict, headSha: string | null): boolean;
30
+ /**
31
+ * Classify whether the verdict-based merge block is ONLY "soft" (#2260).
32
+ *
33
+ * A soft block means the review verdict is absent or pinned to a prior head
34
+ * SHA (rebased staleness) -- i.e. the review has not spoken about the CURRENT
35
+ * head. It is safe to reconcile a soft block against GitHub mergeability.
36
+ *
37
+ * A HARD block -- a genuine P0/P1 finding, an ERRORED review, or a low
38
+ * confidence score on the current head -- is NEVER soft; those must keep
39
+ * blocking regardless of GitHub mergeability (guardrail: do not merge a PR
40
+ * with a real P0/P1 review finding).
41
+ */
42
+ export declare function verdictBlockIsSoftOnly(verdict: GreptileVerdict, headSha: string | null): boolean;
43
+ //# sourceMappingURL=mergeability.d.ts.map
@@ -0,0 +1,105 @@
1
+ /** REST `mergeable_state` value that mirrors GraphQL `mergeStateStatus: CLEAN`. */
2
+ export const MERGE_STATE_CLEAN = "clean";
3
+ /**
4
+ * Read GitHub's own mergeability verdict for a PR over REST.
5
+ *
6
+ * Injectable via `runGh` so unit tests stay hermetic (no live network).
7
+ */
8
+ export function fetchMergeability(prNumber, repo, runGh) {
9
+ const rc = runGh(["gh", "api", `repos/${repo}/pulls/${prNumber}`]);
10
+ if (rc.returncode !== 0) {
11
+ return {
12
+ mergeableState: null,
13
+ mergeable: null,
14
+ error: `gh api /pulls/${prNumber} failed: ${rc.stderr.trim()}`,
15
+ };
16
+ }
17
+ if (!rc.stdout.trim()) {
18
+ return { mergeableState: null, mergeable: null, error: "empty body from gh api /pulls/<N>" };
19
+ }
20
+ let payload;
21
+ try {
22
+ payload = JSON.parse(rc.stdout);
23
+ }
24
+ catch (exc) {
25
+ const message = exc instanceof Error ? exc.message : String(exc);
26
+ return { mergeableState: null, mergeable: null, error: `could not parse PR JSON: ${message}` };
27
+ }
28
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
29
+ return {
30
+ mergeableState: null,
31
+ mergeable: null,
32
+ error: "unexpected PR JSON shape (not a dict)",
33
+ };
34
+ }
35
+ const pr = payload;
36
+ const rawState = pr.mergeable_state;
37
+ const rawMergeable = pr.mergeable;
38
+ return {
39
+ mergeableState: typeof rawState === "string" ? rawState : null,
40
+ mergeable: typeof rawMergeable === "boolean" ? rawMergeable : null,
41
+ error: null,
42
+ };
43
+ }
44
+ /** True when GitHub itself reports the PR as CLEAN + MERGEABLE (#2260). */
45
+ export function isGithubMergeableClean(signal) {
46
+ return signal.mergeable === true && signal.mergeableState === MERGE_STATE_CLEAN;
47
+ }
48
+ /** Serialise the mergeability signal into the partial_data envelope shape. */
49
+ export function mergeabilityToDict(signal) {
50
+ return {
51
+ mergeable_state: signal.mergeableState,
52
+ mergeable: signal.mergeable,
53
+ error: signal.error,
54
+ };
55
+ }
56
+ /** True when a parsed verdict SHA does not match the current PR head SHA. */
57
+ export function verdictShaIsStale(verdict, headSha) {
58
+ if (!verdict.found || verdict.lastReviewedSha === null || !headSha) {
59
+ return false;
60
+ }
61
+ const reviewed = verdict.lastReviewedSha;
62
+ return !(headSha.startsWith(reviewed) || reviewed.startsWith(headSha));
63
+ }
64
+ /**
65
+ * Classify whether the verdict-based merge block is ONLY "soft" (#2260).
66
+ *
67
+ * A soft block means the review verdict is absent or pinned to a prior head
68
+ * SHA (rebased staleness) -- i.e. the review has not spoken about the CURRENT
69
+ * head. It is safe to reconcile a soft block against GitHub mergeability.
70
+ *
71
+ * A HARD block -- a genuine P0/P1 finding, an ERRORED review, or a low
72
+ * confidence score on the current head -- is NEVER soft; those must keep
73
+ * blocking regardless of GitHub mergeability (guardrail: do not merge a PR
74
+ * with a real P0/P1 review finding).
75
+ */
76
+ export function verdictBlockIsSoftOnly(verdict, headSha) {
77
+ // Absent: no Greptile rolling-summary comment at all.
78
+ if (!verdict.found) {
79
+ return true;
80
+ }
81
+ // informal-clean (#1543) is a present-but-noncanonical verdict with its own
82
+ // remediation path; it is out of scope for the #2260 mergeability override.
83
+ if (verdict.informalClean) {
84
+ return false;
85
+ }
86
+ // Stale: verdict pinned to a prior head SHA -> its findings pertain to old code.
87
+ if (verdictShaIsStale(verdict, headSha)) {
88
+ return true;
89
+ }
90
+ // Verdict is current (or its SHA is unparseable). A genuine current-head
91
+ // finding is a hard block and must not be overridden.
92
+ if (verdict.errored) {
93
+ return false;
94
+ }
95
+ if (verdict.confidence !== null && verdict.confidence <= 3) {
96
+ return false;
97
+ }
98
+ if (verdict.p0Count > 0 || verdict.p1Count > 0) {
99
+ return false;
100
+ }
101
+ // No genuine finding: the block is a missing-canonical-field / not-yet-posted
102
+ // wait, which GitHub mergeability may resolve.
103
+ return true;
104
+ }
105
+ //# sourceMappingURL=mergeability.js.map
@@ -1,7 +1,7 @@
1
1
  export { cadenceIntervals } from "./cadence.js";
2
2
  export { DEFAULT_CADENCE, EXIT_CAP_REACHED, EXIT_CLEAN, EXIT_CONFIG_ERROR, EXIT_PR_TERMINAL, } from "./constants.js";
3
3
  export { cmdPrMonitor, parseMonitorArgs, runMonitor } from "./main.js";
4
- export { formatPollStatus, isTerminalPrState, monitor, summaryLabelForExit } from "./monitor.js";
4
+ export { formatPollStatus, isTerminalPrState, mergeStateFromPayload, monitor, summaryLabelForExit, } from "./monitor.js";
5
5
  export { callReadiness } from "./readiness.js";
6
6
  export type { CallReadinessFn, CallReadinessOptions, MonitorOptions, MonitorRunResult, MonotonicClock, PollResult, SleepFn, } from "./types.js";
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -1,6 +1,6 @@
1
1
  export { cadenceIntervals } from "./cadence.js";
2
2
  export { DEFAULT_CADENCE, EXIT_CAP_REACHED, EXIT_CLEAN, EXIT_CONFIG_ERROR, EXIT_PR_TERMINAL, } from "./constants.js";
3
3
  export { cmdPrMonitor, parseMonitorArgs, runMonitor } from "./main.js";
4
- export { formatPollStatus, isTerminalPrState, monitor, summaryLabelForExit } from "./monitor.js";
4
+ export { formatPollStatus, isTerminalPrState, mergeStateFromPayload, monitor, summaryLabelForExit, } from "./monitor.js";
5
5
  export { callReadiness } from "./readiness.js";
6
6
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,12 @@
1
1
  import type { MonitorOptions, MonitorRunResult, PollResult } from "./types.js";
2
- /** One-line stderr status mirror per poll. */
3
- export declare function formatPollStatus(pollIndex: number, pollResult: PollResult): string;
2
+ /** Pull GitHub's merge-state signal out of a readiness payload, if present. */
3
+ export declare function mergeStateFromPayload(payload: Record<string, unknown>): string;
4
+ /**
5
+ * One-line stderr heartbeat per poll (#2260): elapsed, poll#, via, head,
6
+ * GitHub merge-state, CLEAN/BLOCKED, and what the poll is blocked on. Emitting
7
+ * this every poll makes a live poll visibly distinct from a hung process.
8
+ */
9
+ export declare function formatPollStatus(pollIndex: number, pollResult: PollResult, elapsedSeconds?: number): string;
4
10
  /** Detect merged / closed PR via fallback2 partial_data. */
5
11
  export declare function isTerminalPrState(payload: Record<string, unknown>): boolean;
6
12
  /** Loop readiness with adaptive cadence until CLEAN / cap / terminal. */
@@ -13,8 +13,34 @@ function defaultSleep(seconds) {
13
13
  // busy-wait fallback when no injectable sleep in production CLI path
14
14
  }
15
15
  }
16
- /** One-line stderr status mirror per poll. */
17
- export function formatPollStatus(pollIndex, pollResult) {
16
+ /** Pull GitHub's merge-state signal out of a readiness payload, if present. */
17
+ export function mergeStateFromPayload(payload) {
18
+ const partialRaw = payload.partial_data;
19
+ const partial = partialRaw !== null && typeof partialRaw === "object" && !Array.isArray(partialRaw)
20
+ ? partialRaw
21
+ : {};
22
+ const mergeabilityRaw = partial.mergeability;
23
+ if (mergeabilityRaw !== null &&
24
+ typeof mergeabilityRaw === "object" &&
25
+ !Array.isArray(mergeabilityRaw)) {
26
+ const state = mergeabilityRaw.mergeable_state;
27
+ if (typeof state === "string" && state.length > 0) {
28
+ return state;
29
+ }
30
+ }
31
+ // fallback2 surfaces mergeable_state directly on partial_data.
32
+ const flatState = partial.mergeable_state;
33
+ if (typeof flatState === "string" && flatState.length > 0) {
34
+ return flatState;
35
+ }
36
+ return "?";
37
+ }
38
+ /**
39
+ * One-line stderr heartbeat per poll (#2260): elapsed, poll#, via, head,
40
+ * GitHub merge-state, CLEAN/BLOCKED, and what the poll is blocked on. Emitting
41
+ * this every poll makes a live poll visibly distinct from a hung process.
42
+ */
43
+ export function formatPollStatus(pollIndex, pollResult, elapsedSeconds = 0) {
18
44
  const payload = pollResult.payload;
19
45
  const via = typeof payload.via === "string" ? payload.via : "?";
20
46
  const mergeReady = payload.merge_ready === true;
@@ -30,10 +56,12 @@ export function formatPollStatus(pollIndex, pollResult) {
30
56
  const failures = Array.isArray(failuresRaw) ? failuresRaw.map(String) : [];
31
57
  const firstFailure = failures[0] ?? "";
32
58
  const label = mergeReady ? "CLEAN" : "BLOCKED";
33
- let line = `[monitor_pr] poll #${pollIndex} via=${via} head=${headDisplay} ` +
34
- `${label} (${failures.length} failures)`;
59
+ const elapsed = Math.max(0, Math.round(elapsedSeconds));
60
+ const mergeState = mergeStateFromPayload(payload);
61
+ let line = `[monitor_pr] poll #${pollIndex} t=${elapsed}s via=${via} head=${headDisplay} ` +
62
+ `mergeState=${mergeState} ${label} (${failures.length} failures)`;
35
63
  if (firstFailure.length > 0) {
36
- line += ` -- ${firstFailure.slice(0, 80)}`;
64
+ line += ` -- blocked-on: ${firstFailure.slice(0, 80)}`;
37
65
  }
38
66
  return line;
39
67
  }
@@ -65,7 +93,8 @@ export function monitor(prNumber, repo, options = {}) {
65
93
  const pollResult = callReadinessFn(prNumber, repo);
66
94
  lastPayload = pollResult.payload;
67
95
  lastExit = pollResult.exitCode;
68
- process.stderr.write(`${formatPollStatus(pollIndex, pollResult)}\n`);
96
+ const elapsedAtPoll = clockFn.now() - startedAt;
97
+ process.stderr.write(`${formatPollStatus(pollIndex, pollResult, elapsedAtPoll)}\n`);
69
98
  if (pollResult.rawStderr.trim().length > 0) {
70
99
  process.stderr.write(pollResult.rawStderr);
71
100
  }
@@ -4,8 +4,19 @@ export interface CaptureExecResult {
4
4
  readonly stdout: string;
5
5
  readonly stderr: string;
6
6
  }
7
+ export interface CaptureExecOptions {
8
+ /**
9
+ * Stream the child's stderr straight to the parent's stderr (live) instead of
10
+ * capturing it. Used for long polls so the per-poll heartbeat is visible in
11
+ * real time rather than buffered until the subprocess exits (#2260). When set,
12
+ * the returned `stderr` is empty because it was not captured.
13
+ */
14
+ readonly inheritStderr?: boolean;
15
+ /** Environment for the child (defaults to `process.env`). */
16
+ readonly env?: NodeJS.ProcessEnv;
17
+ }
7
18
  /** UTF-8-safe subprocess capture via spawnSync (no shell) — mirrors #1366. */
8
- export declare function captureExec(executable: string, args: readonly string[], timeoutMs: number): CaptureExecResult;
19
+ export declare function captureExec(executable: string, args: readonly string[], timeoutMs: number, options?: CaptureExecOptions): CaptureExecResult;
9
20
  export interface RunProtectedCheckOptions {
10
21
  readonly nodeExecutable?: string;
11
22
  readonly timeout?: number;
@@ -4,13 +4,13 @@ import { fileURLToPath } from "node:url";
4
4
  import { resolveBinary } from "../scm/binary.js";
5
5
  import { SUBPROCESS_MAX_BUFFER } from "../subprocess/max-buffer.js";
6
6
  /** UTF-8-safe subprocess capture via spawnSync (no shell) — mirrors #1366. */
7
- export function captureExec(executable, args, timeoutMs) {
7
+ export function captureExec(executable, args, timeoutMs, options = {}) {
8
8
  const result = spawnSync(executable, args, {
9
9
  encoding: "utf8",
10
10
  timeout: timeoutMs,
11
- stdio: ["ignore", "pipe", "pipe"],
11
+ stdio: ["ignore", "pipe", options.inheritStderr === true ? "inherit" : "pipe"],
12
12
  maxBuffer: SUBPROCESS_MAX_BUFFER,
13
- env: process.env,
13
+ env: options.env ?? process.env,
14
14
  });
15
15
  if (result.error !== undefined) {
16
16
  const code = result.error.code;
@@ -56,7 +56,9 @@ export function runProtectedCheck(prNumber, repo, protectedIssues, options = {})
56
56
  if (repo) {
57
57
  cmd.push("--repo", repo);
58
58
  }
59
- const result = captureExec(node, cmd, (options.timeout ?? 60) * 1000);
59
+ const result = captureExec(node, cmd, (options.timeout ?? 60) * 1000, {
60
+ env: { ...process.env, NODE_NO_WARNINGS: "1" },
61
+ });
60
62
  return [result.returncode, result.stdout, result.stderr];
61
63
  }
62
64
  /** Invoke pr-monitor CLI with --json and return (returncode, stdout, stderr). */
@@ -72,7 +74,13 @@ export function runMonitor(prNumber, repo, capMinutes, options = {}) {
72
74
  String(capMinutes),
73
75
  "--json",
74
76
  ];
75
- const result = captureExec(node, cmd, timeoutSec * 1000);
77
+ // Stream the monitor's per-poll heartbeat live (a buffered poll looks like a
78
+ // hang, #2260) and suppress Node deprecation/engine warning noise so it never
79
+ // leaks onto the captured JSON stream or the final result line (#2240-class).
80
+ const result = captureExec(node, cmd, timeoutSec * 1000, {
81
+ inheritStderr: true,
82
+ env: { ...process.env, NODE_NO_WARNINGS: "1" },
83
+ });
76
84
  return [result.returncode, result.stdout, result.stderr];
77
85
  }
78
86
  /** Invoke ``gh pr merge --squash --delete-branch --admin``. */