@deftai/directive-core 0.99.0 → 0.100.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 (102) hide show
  1. package/dist/authz/actions.d.ts +6 -0
  2. package/dist/authz/actions.js +3 -0
  3. package/dist/authz/classify.js +157 -23
  4. package/dist/authz/decompose-apply.d.ts +67 -0
  5. package/dist/authz/decompose-apply.js +302 -0
  6. package/dist/authz/index.d.ts +3 -1
  7. package/dist/authz/index.js +3 -1
  8. package/dist/authz/store.d.ts +53 -0
  9. package/dist/authz/store.js +219 -1
  10. package/dist/authz/types.d.ts +19 -2
  11. package/dist/authz/types.js +5 -1
  12. package/dist/doctor/checks.d.ts +44 -0
  13. package/dist/doctor/checks.js +428 -2
  14. package/dist/doctor/main.d.ts +6 -7
  15. package/dist/doctor/main.js +72 -79
  16. package/dist/doctor/which.d.ts +20 -1
  17. package/dist/doctor/which.js +67 -1
  18. package/dist/finish-loop/pr-finish-loop.d.ts +13 -2
  19. package/dist/finish-loop/pr-finish-loop.js +116 -5
  20. package/dist/finish-loop/types.d.ts +1 -1
  21. package/dist/hooks/dispatcher.d.ts +9 -5
  22. package/dist/hooks/dispatcher.js +23 -15
  23. package/dist/hooks/readonly.d.ts +6 -3
  24. package/dist/hooks/readonly.js +44 -25
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/init-deposit/agent-hooks.d.ts +20 -0
  28. package/dist/init-deposit/agent-hooks.js +51 -31
  29. package/dist/intake/issue-ingest.js +29 -0
  30. package/dist/lifecycle/completed-consistency.d.ts +60 -0
  31. package/dist/lifecycle/completed-consistency.js +443 -0
  32. package/dist/lifecycle/completed-tracked-on-delivery.d.ts +47 -0
  33. package/dist/lifecycle/completed-tracked-on-delivery.js +415 -0
  34. package/dist/lifecycle/index.d.ts +2 -0
  35. package/dist/lifecycle/index.js +2 -0
  36. package/dist/literal-acceptance/capture.d.ts +52 -0
  37. package/dist/literal-acceptance/capture.js +658 -0
  38. package/dist/literal-acceptance/evaluate.d.ts +47 -0
  39. package/dist/literal-acceptance/evaluate.js +193 -0
  40. package/dist/literal-acceptance/index.d.ts +12 -0
  41. package/dist/literal-acceptance/index.js +12 -0
  42. package/dist/literal-acceptance/run.d.ts +38 -0
  43. package/dist/literal-acceptance/run.js +216 -0
  44. package/dist/literal-acceptance/safety.d.ts +24 -0
  45. package/dist/literal-acceptance/safety.js +251 -0
  46. package/dist/literal-acceptance/types.d.ts +82 -0
  47. package/dist/literal-acceptance/types.js +26 -0
  48. package/dist/policy/ceremony-dial.d.ts +67 -11
  49. package/dist/policy/ceremony-dial.js +174 -23
  50. package/dist/policy/index.d.ts +1 -0
  51. package/dist/policy/index.js +1 -0
  52. package/dist/policy/merge-approval-head.d.ts +129 -0
  53. package/dist/policy/merge-approval-head.js +461 -0
  54. package/dist/policy/require-human-merge.d.ts +5 -0
  55. package/dist/policy/require-human-merge.js +5 -0
  56. package/dist/pr-merge-readiness/ci-gate.d.ts +20 -3
  57. package/dist/pr-merge-readiness/ci-gate.js +65 -5
  58. package/dist/pr-merge-readiness/compute.d.ts +13 -0
  59. package/dist/pr-merge-readiness/compute.js +99 -14
  60. package/dist/pr-merge-readiness/gh.d.ts +49 -0
  61. package/dist/pr-merge-readiness/gh.js +268 -0
  62. package/dist/pr-merge-readiness/index.d.ts +2 -2
  63. package/dist/pr-merge-readiness/index.js +2 -2
  64. package/dist/pr-merge-readiness/test-gh-fixtures.helpers.js +4 -0
  65. package/dist/pr-wait-mergeable/cascade.d.ts +11 -0
  66. package/dist/pr-wait-mergeable/cascade.js +115 -1
  67. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  68. package/dist/pr-wait-mergeable/main.js +4 -0
  69. package/dist/pr-wait-mergeable/types.d.ts +3 -1
  70. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -1
  71. package/dist/pr-wait-mergeable/wrappers.js +7 -1
  72. package/dist/preflight/evaluate.d.ts +15 -1
  73. package/dist/preflight/evaluate.js +33 -2
  74. package/dist/render/framework-commands.js +4 -0
  75. package/dist/scope/acceptance-evidence.d.ts +76 -0
  76. package/dist/scope/acceptance-evidence.js +348 -0
  77. package/dist/scope/coverage-map.d.ts +98 -0
  78. package/dist/scope/coverage-map.js +558 -0
  79. package/dist/scope/decompose.js +125 -11
  80. package/dist/scope/index.d.ts +3 -0
  81. package/dist/scope/index.js +3 -0
  82. package/dist/scope/parent-lineage.d.ts +125 -0
  83. package/dist/scope/parent-lineage.js +626 -0
  84. package/dist/scope/transition.d.ts +8 -0
  85. package/dist/scope/transition.js +61 -2
  86. package/dist/session/active-cli.d.ts +79 -0
  87. package/dist/session/active-cli.js +382 -0
  88. package/dist/session/effort-budget.d.ts +130 -0
  89. package/dist/session/effort-budget.js +334 -0
  90. package/dist/session/index.d.ts +2 -0
  91. package/dist/session/index.js +2 -0
  92. package/dist/session/session-start.d.ts +6 -0
  93. package/dist/session/session-start.js +53 -2
  94. package/dist/session/verify-session-ritual.d.ts +14 -0
  95. package/dist/session/verify-session-ritual.js +33 -0
  96. package/dist/story-ready/evaluate.d.ts +11 -0
  97. package/dist/story-ready/evaluate.js +41 -3
  98. package/dist/triage/actions/candidates-log.d.ts +1 -2
  99. package/dist/triage/actions/candidates-log.js +37 -6
  100. package/dist/xbrief-migrate/migrate-project.d.ts +4 -0
  101. package/dist/xbrief-migrate/migrate-project.js +92 -3
  102. package/package.json +7 -3
@@ -2,6 +2,46 @@ import { execFileSync } from "node:child_process";
2
2
  import { resolveBinary } from "../scm/binary.js";
3
3
  import { SUBPROCESS_MAX_BUFFER } from "../subprocess/max-buffer.js";
4
4
  import { GH_TIMEOUT_S, GREPTILE_LOGIN } from "./constants.js";
5
+ /** Display / inventory key for a required context (includes app binding when set). */
6
+ export function requiredContextLabel(ctx) {
7
+ if (ctx.appId != null) {
8
+ return `${ctx.name} (app:${ctx.appId})`;
9
+ }
10
+ return ctx.name;
11
+ }
12
+ /** Normalize injected string names or full specs into RequiredStatusContext (#3234). */
13
+ export function normalizeRequiredContexts(input) {
14
+ if (input === undefined) {
15
+ return [];
16
+ }
17
+ const out = [];
18
+ for (const item of input) {
19
+ if (typeof item === "string") {
20
+ if (item.length > 0) {
21
+ out.push({ name: item });
22
+ }
23
+ continue;
24
+ }
25
+ if (item !== null &&
26
+ typeof item === "object" &&
27
+ typeof item.name === "string" &&
28
+ item.name.length > 0) {
29
+ const appId = typeof item.appId === "number" && Number.isFinite(item.appId) ? item.appId : null;
30
+ out.push(appId === null ? { name: item.name } : { name: item.name, appId });
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+ /** True when an observed check-run satisfies a required context (name + optional app id). */
36
+ export function checkRunMatchesRequiredContext(run, ctx) {
37
+ if (run.name !== ctx.name) {
38
+ return false;
39
+ }
40
+ if (ctx.appId == null) {
41
+ return true;
42
+ }
43
+ return run.appId === ctx.appId;
44
+ }
5
45
  /** UTF-8-safe gh capture via execFile (no shell) — mirrors _safe_subprocess.run_text (#1366). */
6
46
  export function defaultRunGh(cmd) {
7
47
  if (cmd.length === 0 || cmd[0] !== "gh") {
@@ -298,12 +338,21 @@ export function fetchCheckRunsRest(sha, repo, runGh) {
298
338
  const runSummary = extractCheckRunSummary(r);
299
339
  const createdAt = typeof r.created_at === "string" ? r.created_at : null;
300
340
  const startedAt = typeof r.started_at === "string" ? r.started_at : null;
341
+ let appId = null;
342
+ const appBlock = r.app;
343
+ if (appBlock !== null && typeof appBlock === "object" && !Array.isArray(appBlock)) {
344
+ const id = appBlock.id;
345
+ if (typeof id === "number" && Number.isFinite(id)) {
346
+ appId = id;
347
+ }
348
+ }
301
349
  const record = {
302
350
  name,
303
351
  status,
304
352
  conclusion,
305
353
  created_at: createdAt,
306
354
  started_at: startedAt,
355
+ appId,
307
356
  ...(runSummary === null ? {} : { summary: runSummary }),
308
357
  };
309
358
  checkRuns.push(record);
@@ -318,4 +367,223 @@ export function fetchCheckRunsRest(sha, repo, runGh) {
318
367
  }
319
368
  return { summary, checkRuns, error: "" };
320
369
  }
370
+ /** PR base branch ref (rulesets / branch protection apply to the merge target) (#3234). */
371
+ export function fetchPrBaseRef(prNumber, repo, runGh) {
372
+ const rc = runGh(["gh", "api", `repos/${repo}/pulls/${prNumber}`]);
373
+ if (rc.returncode !== 0) {
374
+ return { baseRef: null, error: `gh api /pulls/${prNumber} failed: ${rc.stderr.trim()}` };
375
+ }
376
+ if (!rc.stdout.trim()) {
377
+ return { baseRef: null, error: "empty body from gh api /pulls/<N>" };
378
+ }
379
+ let payload;
380
+ try {
381
+ payload = JSON.parse(rc.stdout);
382
+ }
383
+ catch (exc) {
384
+ const message = exc instanceof Error ? exc.message : String(exc);
385
+ return { baseRef: null, error: `could not parse PR JSON: ${message}` };
386
+ }
387
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
388
+ return { baseRef: null, error: "unexpected PR JSON shape (not a dict)" };
389
+ }
390
+ const base = payload.base;
391
+ if (base !== null && typeof base === "object" && !Array.isArray(base)) {
392
+ const ref = base.ref;
393
+ if (typeof ref === "string" && ref.length > 0) {
394
+ return { baseRef: ref, error: "" };
395
+ }
396
+ }
397
+ return { baseRef: null, error: "PR JSON missing base.ref" };
398
+ }
399
+ function parseOptionalAppId(raw) {
400
+ if (typeof raw === "number" && Number.isFinite(raw)) {
401
+ return raw;
402
+ }
403
+ return null;
404
+ }
405
+ /** Extract required contexts from `GET .../rules/branches/{branch}` payload (#3234). */
406
+ export function contextsFromBranchRules(payload) {
407
+ if (!Array.isArray(payload)) {
408
+ return [];
409
+ }
410
+ const out = [];
411
+ for (const rule of payload) {
412
+ if (rule === null || typeof rule !== "object" || Array.isArray(rule)) {
413
+ continue;
414
+ }
415
+ const r = rule;
416
+ if (r.type !== "required_status_checks") {
417
+ continue;
418
+ }
419
+ const params = r.parameters;
420
+ if (params === null || typeof params !== "object" || Array.isArray(params)) {
421
+ continue;
422
+ }
423
+ const checks = params.required_status_checks;
424
+ if (!Array.isArray(checks)) {
425
+ continue;
426
+ }
427
+ for (const check of checks) {
428
+ if (check === null || typeof check !== "object" || Array.isArray(check)) {
429
+ continue;
430
+ }
431
+ const c = check;
432
+ const context = c.context;
433
+ if (typeof context !== "string" || context.length === 0) {
434
+ continue;
435
+ }
436
+ // Rulesets use integration_id; treat as GitHub App id for check-run matching.
437
+ const appId = parseOptionalAppId(c.integration_id);
438
+ out.push(appId === null ? { name: context } : { name: context, appId });
439
+ }
440
+ }
441
+ return out;
442
+ }
443
+ /** Extract required contexts from classic branch-protection payload (#3234). */
444
+ export function contextsFromBranchProtection(payload) {
445
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
446
+ return [];
447
+ }
448
+ const rsc = payload.required_status_checks;
449
+ if (rsc === null || typeof rsc !== "object" || Array.isArray(rsc)) {
450
+ return [];
451
+ }
452
+ const block = rsc;
453
+ const out = [];
454
+ const contexts = block.contexts;
455
+ if (Array.isArray(contexts)) {
456
+ for (const c of contexts) {
457
+ if (typeof c === "string" && c.length > 0) {
458
+ out.push({ name: c });
459
+ }
460
+ }
461
+ }
462
+ const checks = block.checks;
463
+ if (Array.isArray(checks)) {
464
+ for (const check of checks) {
465
+ if (check === null || typeof check !== "object" || Array.isArray(check)) {
466
+ continue;
467
+ }
468
+ const c = check;
469
+ const context = c.context;
470
+ if (typeof context !== "string" || context.length === 0) {
471
+ continue;
472
+ }
473
+ const appId = parseOptionalAppId(c.app_id);
474
+ out.push(appId === null ? { name: context } : { name: context, appId });
475
+ }
476
+ }
477
+ return out;
478
+ }
479
+ function contextDedupeKey(ctx) {
480
+ return ctx.appId == null ? ctx.name : `${ctx.name}\0${ctx.appId}`;
481
+ }
482
+ /**
483
+ * Resolve required status-check contexts for a branch from rulesets and/or
484
+ * classic branch protection (REST only) (#3234).
485
+ *
486
+ * Absent or 404 sources are soft-skipped (empty contribution). Parse failures
487
+ * and non-404 REST errors with no successful source set `resolutionFailed`.
488
+ * Callers compare returned contexts against exact-HEAD check runs (name +
489
+ * optional app id) and fail closed on absent required contexts.
490
+ */
491
+ export function fetchRequiredStatusContexts(repo, branch, runGh) {
492
+ const found = new Map();
493
+ const sources = [];
494
+ const notes = [];
495
+ let hardFailure = false;
496
+ const encoded = encodeURIComponent(branch);
497
+ const rulesRc = runGh(["gh", "api", `repos/${repo}/rules/branches/${encoded}`]);
498
+ if (rulesRc.returncode === 0) {
499
+ if (!rulesRc.stdout.trim()) {
500
+ // Exit-zero empty body is not a trusted empty inventory (#3234 conf residual).
501
+ notes.push("rules/branches: empty body");
502
+ hardFailure = true;
503
+ }
504
+ else {
505
+ try {
506
+ const payload = JSON.parse(rulesRc.stdout);
507
+ const fromRules = contextsFromBranchRules(payload);
508
+ // Successful parse (including `[]`) is trusted; only non-empty contributes.
509
+ if (fromRules.length > 0) {
510
+ for (const c of fromRules) {
511
+ found.set(contextDedupeKey(c), c);
512
+ }
513
+ sources.push("rulesets");
514
+ }
515
+ else {
516
+ sources.push("rulesets");
517
+ }
518
+ }
519
+ catch (exc) {
520
+ const message = exc instanceof Error ? exc.message : String(exc);
521
+ notes.push(`rules/branches parse: ${message}`);
522
+ hardFailure = true;
523
+ }
524
+ }
525
+ }
526
+ else {
527
+ // Soft-skip only when the error text clearly indicates no rulesets.
528
+ // Nonzero with empty stderr (or other errors) is fail-closed (#3234).
529
+ const err = rulesRc.stderr.trim();
530
+ if (/404|Not Found/i.test(err)) {
531
+ // no rulesets — soft empty contribution
532
+ }
533
+ else {
534
+ notes.push(`rules/branches: ${err || `exit ${rulesRc.returncode}`}`);
535
+ hardFailure = true;
536
+ }
537
+ }
538
+ const protRc = runGh(["gh", "api", `repos/${repo}/branches/${encoded}/protection`]);
539
+ if (protRc.returncode === 0) {
540
+ if (!protRc.stdout.trim()) {
541
+ notes.push("branches/protection: empty body");
542
+ hardFailure = true;
543
+ }
544
+ else {
545
+ try {
546
+ const payload = JSON.parse(protRc.stdout);
547
+ const fromProt = contextsFromBranchProtection(payload);
548
+ if (fromProt.length > 0) {
549
+ for (const c of fromProt) {
550
+ found.set(contextDedupeKey(c), c);
551
+ }
552
+ sources.push("branch_protection");
553
+ }
554
+ else {
555
+ // Successful protection payload with no required checks still counts
556
+ // as a trusted source (inventory empty intentionally).
557
+ sources.push("branch_protection");
558
+ }
559
+ }
560
+ catch (exc) {
561
+ const message = exc instanceof Error ? exc.message : String(exc);
562
+ notes.push(`branches/protection parse: ${message}`);
563
+ hardFailure = true;
564
+ }
565
+ }
566
+ }
567
+ else {
568
+ const err = protRc.stderr.trim();
569
+ if (/404|Not Found|Branch not protected/i.test(err)) {
570
+ // no classic protection — soft empty contribution
571
+ }
572
+ else {
573
+ notes.push(`branches/protection: ${err || `exit ${protRc.returncode}`}`);
574
+ hardFailure = true;
575
+ }
576
+ }
577
+ const contexts = [...found.values()].sort((a, b) => requiredContextLabel(a).localeCompare(requiredContextLabel(b)));
578
+ // Fail closed on any hard error (parse / non-404), even when another source
579
+ // contributed contexts. Partial inventory can omit unique required checks
580
+ // from the failed source (#3234 Greptile conf residual).
581
+ const resolutionFailed = hardFailure;
582
+ return {
583
+ contexts,
584
+ sources,
585
+ error: notes.join("; "),
586
+ resolutionFailed,
587
+ };
588
+ }
321
589
  //# sourceMappingURL=gh.js.map
@@ -1,8 +1,8 @@
1
1
  export { buildCiSummaryLine, type CiCheckConclusion, type CiGateOptions, type CiGateResult, type CiGateSummary, type CiReadyState, evaluateCiGate, isAuthoritativeSuiteAggregator, isBotReviewCheck, suiteFamilyOf, } from "./ci-gate.js";
2
- export { type ComputeGateOptions, computeGateResult, type FetchMergeabilityFn } from "./compute.js";
2
+ export { type ComputeGateOptions, computeGateResult, type FetchMergeabilityFn, type FetchRequiredContextsFn, } from "./compute.js";
3
3
  export * from "./constants.js";
4
4
  export { evaluateGates, isMergeReady } from "./evaluate.js";
5
- export { defaultRunGh } from "./gh.js";
5
+ export { checkRunMatchesRequiredContext, contextsFromBranchProtection, contextsFromBranchRules, defaultRunGh, fetchPrBaseRef, fetchRequiredStatusContexts, normalizeRequiredContexts, type RequiredStatusContext, type RequiredStatusContextsResult, requiredContextLabel, } from "./gh.js";
6
6
  export { evaluateInlineReviewThreads, fetchUnresolvedGreptileInlineFindings, headShaMatches, type InlineGreptileFindings, type InlineReviewComment, type InlineReviewThread, inlineFindingsToDict, } from "./greptile-inline.js";
7
7
  export { cmdPrMergeReadiness, parseArgs, run } from "./main.js";
8
8
  export { fetchMergeability, isGithubMergeableClean, MERGE_STATE_CLEAN, type MergeabilitySignal, mergeabilityToDict, verdictBlockIsSoftOnly, verdictShaIsStale, } from "./mergeability.js";
@@ -1,8 +1,8 @@
1
1
  export { buildCiSummaryLine, evaluateCiGate, isAuthoritativeSuiteAggregator, isBotReviewCheck, suiteFamilyOf, } from "./ci-gate.js";
2
- export { computeGateResult } from "./compute.js";
2
+ export { computeGateResult, } from "./compute.js";
3
3
  export * from "./constants.js";
4
4
  export { evaluateGates, isMergeReady } from "./evaluate.js";
5
- export { defaultRunGh } from "./gh.js";
5
+ export { checkRunMatchesRequiredContext, contextsFromBranchProtection, contextsFromBranchRules, defaultRunGh, fetchPrBaseRef, fetchRequiredStatusContexts, normalizeRequiredContexts, requiredContextLabel, } from "./gh.js";
6
6
  export { evaluateInlineReviewThreads, fetchUnresolvedGreptileInlineFindings, headShaMatches, inlineFindingsToDict, } from "./greptile-inline.js";
7
7
  export { cmdPrMergeReadiness, parseArgs, run } from "./main.js";
8
8
  export { fetchMergeability, isGithubMergeableClean, MERGE_STATE_CLEAN, mergeabilityToDict, verdictBlockIsSoftOnly, verdictShaIsStale, } from "./mergeability.js";
@@ -57,6 +57,9 @@ export function fakeRunGhForMonitor(options = {}) {
57
57
  stderr: "",
58
58
  };
59
59
  }
60
+ if (joined.includes("/rules/branches/") || joined.includes("/protection")) {
61
+ return { returncode: 1, stdout: "", stderr: "HTTP 404: Not Found" };
62
+ }
60
63
  if (joined.includes("/pulls/")) {
61
64
  return {
62
65
  returncode: 0,
@@ -66,6 +69,7 @@ export function fakeRunGhForMonitor(options = {}) {
66
69
  mergeable: true,
67
70
  mergeable_state: "clean",
68
71
  head: { sha: headSha },
72
+ base: { ref: "master" },
69
73
  }),
70
74
  stderr: "",
71
75
  };
@@ -1,3 +1,4 @@
1
+ import { type EnforceMergeApprovalHeadInput, type MergeApprovalHeadResult } from "../policy/merge-approval-head.js";
1
2
  import { type AgentMergeEvaluateResult } from "../policy/require-human-merge.js";
2
3
  import { type SemanticGreenFn } from "./semantic-green.js";
3
4
  import type { MergeFn, MonitorFn, ProtectedCheckFn, WaitMergeableResult } from "./types.js";
@@ -23,6 +24,16 @@ export interface WaitMergeableOptions {
23
24
  readonly agentMergeFn?: (projectRoot: string) => AgentMergeEvaluateResult;
24
25
  /** When true, skip human-merge / intent preflight (tests only). */
25
26
  readonly skipHumanMergeGate?: boolean;
27
+ /**
28
+ * When true, skip head-bound plan:approved gate (#3235).
29
+ * Default: same as skipHumanMergeGate (test harnesses that skip human-merge
30
+ * also skip head-binding unless they opt in with skipMergeApprovalHeadGate=false).
31
+ */
32
+ readonly skipMergeApprovalHeadGate?: boolean;
33
+ /** Inject head-bound approval enforcer (unit tests). */
34
+ readonly mergeApprovalHeadFn?: (input: EnforceMergeApprovalHeadInput) => MergeApprovalHeadResult;
35
+ /** Inject PR HEAD fetch (unit tests; defaults to REST `pulls/<N>`). */
36
+ readonly fetchPrHeadShaFn?: (prNumber: number, repo: string | null) => string | null;
26
37
  /**
27
38
  * Post-merge umbrella reconcile (#1649). Defaults to live reconcileUmbrellas.
28
39
  * Pass `null` to skip (unit tests that inject mergeFn must pass null so they
@@ -1,4 +1,5 @@
1
1
  import { evaluateIntentCeilingFromEnv } from "../policy/intent-ceiling.js";
2
+ import { disablePullRequestAutoMerge, enforceMergeApprovalHead, fetchPrHeadShaRest, } from "../policy/merge-approval-head.js";
2
3
  import { evaluateAgentMerge, } from "../policy/require-human-merge.js";
3
4
  import { reconcileUmbrellas, renderUmbrellasReport } from "../vbrief-reconcile/umbrellas.js";
4
5
  import { classifyMonitorOutcome, parseMonitorPayload } from "./classify.js";
@@ -40,6 +41,32 @@ export function waitMergeableAndMerge(prNumber, repo, options) {
40
41
  error: intent.reason,
41
42
  });
42
43
  }
44
+ // #3235: revoke stale head-bound approval / auto-merge even when human-merge
45
+ // will refuse the bot merge — otherwise auto-merge can land a later HEAD.
46
+ if (options.skipMergeApprovalHeadGate !== true) {
47
+ const headGateFn = options.mergeApprovalHeadFn ?? enforceMergeApprovalHead;
48
+ const earlyHead = headGateFn({
49
+ prNumber,
50
+ repo,
51
+ projectRoot,
52
+ currentHeadSha: null,
53
+ fetchHeadShaFn: fetchPrHeadShaRest,
54
+ disableAutoMergeOnDeny: true,
55
+ });
56
+ if (!earlyHead.allowed) {
57
+ const parts = [earlyHead.message];
58
+ if (earlyHead.recovery !== null) {
59
+ parts.push("", earlyHead.recovery);
60
+ }
61
+ return makeResult({
62
+ prNumber,
63
+ repo,
64
+ outcome: "stale-merge-approval",
65
+ exitCode: EXIT_CONFIG_ERROR,
66
+ error: parts.join("\n"),
67
+ });
68
+ }
69
+ }
43
70
  const agentMergeFn = options.agentMergeFn ?? evaluateAgentMerge;
44
71
  const hm = agentMergeFn(projectRoot);
45
72
  if (!hm.allowed) {
@@ -121,7 +148,94 @@ export function waitMergeableAndMerge(prNumber, repo, options) {
121
148
  error: errorPayload,
122
149
  });
123
150
  }
124
- const [mergeRc, mergeStdout, mergeStderr] = mergeFn(prNumber, repo);
151
+ // #3235: head-bound plan:approved refuse merge when approval is stale vs
152
+ // current HEAD; best-effort disable auto-merge + recovery instructions.
153
+ // Re-read live HEAD immediately before merge (TOCTOU / Greptile P1) and pin
154
+ // `gh pr merge --match-head-commit` to that exact SHA.
155
+ // Default skip when human-merge gate is skipped (unit-test harness convention).
156
+ const skipHeadGate = options.skipMergeApprovalHeadGate === true ||
157
+ (options.skipMergeApprovalHeadGate === undefined && options.skipHumanMergeGate === true);
158
+ let matchHeadCommit = null;
159
+ if (!skipHeadGate) {
160
+ const readiness = typeof monitorPayload.readiness === "object" &&
161
+ monitorPayload.readiness !== null &&
162
+ !Array.isArray(monitorPayload.readiness)
163
+ ? monitorPayload.readiness
164
+ : {};
165
+ const headFromMonitor = typeof readiness.head_sha === "string"
166
+ ? readiness.head_sha
167
+ : typeof monitorPayload.head_sha === "string"
168
+ ? monitorPayload.head_sha
169
+ : null;
170
+ const headGateFn = options.mergeApprovalHeadFn ?? enforceMergeApprovalHead;
171
+ const fetchHead = options.fetchPrHeadShaFn ?? fetchPrHeadShaRest;
172
+ // Live HEAD only — never authorize against a stale monitor snapshot when
173
+ // the REST read fails (would pin A while GitHub head is B).
174
+ const liveHead = fetchHead(prNumber, repo);
175
+ if (liveHead === null || liveHead.trim() === "") {
176
+ disablePullRequestAutoMerge(prNumber, repo);
177
+ return makeResult({
178
+ prNumber,
179
+ repo,
180
+ outcome: "stale-merge-approval",
181
+ exitCode: EXIT_CONFIG_ERROR,
182
+ monitorResult: monitorPayload,
183
+ protectedCheck: protectedCheckPayload,
184
+ error: `❌ merge-approval-head (#3235): cannot read live PR HEAD for #${prNumber}` +
185
+ (headFromMonitor !== null ? ` (monitor had ${headFromMonitor.slice(0, 12)})` : "") +
186
+ "; refusing merge and disabling auto-merge (fail closed).",
187
+ });
188
+ }
189
+ const headGate = headGateFn({
190
+ prNumber,
191
+ repo,
192
+ projectRoot,
193
+ currentHeadSha: liveHead,
194
+ disableAutoMergeOnDeny: true,
195
+ });
196
+ if (!headGate.allowed) {
197
+ const parts = [headGate.message];
198
+ if (headGate.recovery !== null) {
199
+ parts.push("", headGate.recovery);
200
+ }
201
+ return makeResult({
202
+ prNumber,
203
+ repo,
204
+ outcome: "stale-merge-approval",
205
+ exitCode: EXIT_CONFIG_ERROR,
206
+ monitorResult: monitorPayload,
207
+ protectedCheck: protectedCheckPayload,
208
+ error: parts.join("\n"),
209
+ });
210
+ }
211
+ matchHeadCommit = liveHead;
212
+ }
213
+ else {
214
+ // Gate skipped: still prefer a live HEAD pin when available.
215
+ const fetchHead = options.fetchPrHeadShaFn ?? fetchPrHeadShaRest;
216
+ matchHeadCommit = fetchHead(prNumber, repo);
217
+ if (matchHeadCommit === null) {
218
+ const readiness = typeof monitorPayload.readiness === "object" &&
219
+ monitorPayload.readiness !== null &&
220
+ !Array.isArray(monitorPayload.readiness)
221
+ ? monitorPayload.readiness
222
+ : {};
223
+ if (typeof readiness.head_sha === "string") {
224
+ matchHeadCommit = readiness.head_sha;
225
+ }
226
+ else if (typeof monitorPayload.head_sha === "string") {
227
+ matchHeadCommit = monitorPayload.head_sha;
228
+ }
229
+ }
230
+ }
231
+ const [mergeRc, mergeStdout, mergeStderr] = mergeFn(prNumber, repo, {
232
+ matchHeadCommit,
233
+ });
234
+ // Pinned merge rejected (head advanced mid-call): revoke auto-merge so the
235
+ // unapproved head cannot land later under retained GitHub auto-merge.
236
+ if (mergeRc !== 0 && options.skipMergeApprovalHeadGate !== true) {
237
+ disablePullRequestAutoMerge(prNumber, repo);
238
+ }
125
239
  if (mergeRc === 0) {
126
240
  // Best-effort umbrella checklist + current-shape refresh after child merge (#1649).
127
241
  // Failures must not flip a successful merge into an error exit.
@@ -18,6 +18,10 @@ export interface RunWaitMergeableOptions {
18
18
  readonly monitorFn?: Parameters<typeof waitMergeableAndMerge>[2]["monitorFn"];
19
19
  readonly mergeFn?: Parameters<typeof waitMergeableAndMerge>[2]["mergeFn"];
20
20
  readonly semanticGreenFn?: Parameters<typeof waitMergeableAndMerge>[2]["semanticGreenFn"];
21
+ readonly skipHumanMergeGate?: boolean;
22
+ readonly skipMergeApprovalHeadGate?: boolean;
23
+ readonly fetchPrHeadShaFn?: Parameters<typeof waitMergeableAndMerge>[2]["fetchPrHeadShaFn"];
24
+ readonly mergeApprovalHeadFn?: Parameters<typeof waitMergeableAndMerge>[2]["mergeApprovalHeadFn"];
21
25
  }
22
26
  export declare function runWaitMergeable(argv: readonly string[], options?: RunWaitMergeableOptions): number;
23
27
  export declare function cmdPrWaitMergeable(argv: readonly string[], options?: RunWaitMergeableOptions): number;
@@ -170,6 +170,10 @@ export function runWaitMergeable(argv, options = {}) {
170
170
  // Explicit CLI root (or cwd) so remote-target cascade does not resolve
171
171
  // minGreptileConfidence from an unrelated directory (#3102).
172
172
  projectRoot: args.projectRoot ?? process.cwd(),
173
+ skipHumanMergeGate: options.skipHumanMergeGate,
174
+ skipMergeApprovalHeadGate: options.skipMergeApprovalHeadGate,
175
+ fetchPrHeadShaFn: options.fetchPrHeadShaFn,
176
+ mergeApprovalHeadFn: options.mergeApprovalHeadFn,
173
177
  });
174
178
  const summaryLabel = summaryLabelForExit(result.exitCode);
175
179
  process.stderr.write(`[pr_wait_mergeable] PR #${result.prNumber} repo=${result.repo} ` +
@@ -15,5 +15,7 @@ export type ProtectedCheckFn = (prNumber: number, repo: string | null, protected
15
15
  export type MonitorFn = (prNumber: number, repo: string, capMinutes: number, options?: {
16
16
  readonly projectRoot?: string;
17
17
  }) => SubprocessTriple;
18
- export type MergeFn = (prNumber: number, repo: string | null) => SubprocessTriple;
18
+ export type MergeFn = (prNumber: number, repo: string | null, options?: {
19
+ readonly matchHeadCommit?: string | null;
20
+ }) => SubprocessTriple;
19
21
  //# sourceMappingURL=types.d.ts.map
@@ -46,7 +46,12 @@ export interface RunMonitorOptions {
46
46
  export declare function runMonitor(prNumber: number, repo: string, capMinutes: number, options?: RunMonitorOptions): SubprocessTriple;
47
47
  export interface RunGhMergeOptions {
48
48
  readonly timeout?: number;
49
+ /**
50
+ * Pin merge to this exact head SHA via `gh pr merge --match-head-commit`
51
+ * (#3235 TOCTOU: refuse merge if PR head advanced after approval gate).
52
+ */
53
+ readonly matchHeadCommit?: string | null;
49
54
  }
50
- /** Invoke ``gh pr merge --squash --delete-branch --admin``. */
55
+ /** Invoke ``gh pr merge --squash --delete-branch --admin`` [``--match-head-commit``]. */
51
56
  export declare function runGhMerge(prNumber: number, repo: string | null, options?: RunGhMergeOptions): SubprocessTriple;
52
57
  //# sourceMappingURL=wrappers.d.ts.map
@@ -126,7 +126,7 @@ export function runMonitor(prNumber, repo, capMinutes, options = {}) {
126
126
  });
127
127
  return [result.returncode, result.stdout, result.stderr];
128
128
  }
129
- /** Invoke ``gh pr merge --squash --delete-branch --admin``. */
129
+ /** Invoke ``gh pr merge --squash --delete-branch --admin`` [``--match-head-commit``]. */
130
130
  export function runGhMerge(prNumber, repo, options = {}) {
131
131
  const timeoutSec = options.timeout ?? 120;
132
132
  let binary;
@@ -140,6 +140,12 @@ export function runGhMerge(prNumber, repo, options = {}) {
140
140
  if (repo) {
141
141
  args.push("--repo", repo);
142
142
  }
143
+ const matchHead = typeof options.matchHeadCommit === "string" && options.matchHeadCommit.trim().length > 0
144
+ ? options.matchHeadCommit.trim()
145
+ : null;
146
+ if (matchHead !== null) {
147
+ args.push("--match-head-commit", matchHead);
148
+ }
143
149
  const result = captureExec(binary, args, timeoutSec * 1000);
144
150
  if (result.returncode === -1) {
145
151
  if (result.stderr.includes("timed out after")) {
@@ -1,3 +1,4 @@
1
+ import { type ParentLineageResult } from "../scope/parent-lineage.js";
1
2
  /** Canonical eligibility folder — only vbrief/active/ may spawn implementation. */
2
3
  export declare const ACTIVE_FOLDER = "active";
3
4
  /** Canonical eligibility status — only `running` signals an active handoff. */
@@ -11,6 +12,14 @@ export declare const PREFLIGHT_USAGE_HINT = "Expected: `task xbrief:preflight --
11
12
  export interface EvaluateResult {
12
13
  readonly exitCode: 0 | 1;
13
14
  readonly message: string;
15
+ /** #3241 parent-lineage probe when structural checks passed far enough to load the payload. */
16
+ readonly parentLineage?: ParentLineageResult;
17
+ }
18
+ export interface EvaluateOptions {
19
+ /** Project root for resolving child planRef → parent (#3241). */
20
+ readonly projectRoot?: string;
21
+ /** Skip parent-lineage check (tests / opt-out). Default false. */
22
+ readonly skipParentLineage?: boolean;
14
23
  }
15
24
  /** Substitute `{path}` without `$`-pattern expansion in user paths (#1721). */
16
25
  export declare function formatActivateHint(path: string): string;
@@ -18,8 +27,13 @@ export declare function formatActivateHint(path: string): string;
18
27
  * Pure evaluator — returns `{ exitCode, message }`. Never throws; every error
19
28
  * path collapses to exit 1 with an actionable message. Faithful to
20
29
  * `scripts/preflight_implementation.py::evaluate`.
30
+ *
31
+ * #3241: after active+running + intent ceiling, re-check parent requirement
32
+ * lineage (coverage + approved behavioral deltas). Fail closed on missing
33
+ * coverage or undeclared deltas when the parent authors requirement IDs.
21
34
  */
22
- export declare function evaluate(vbriefPath: string): EvaluateResult;
35
+ export declare function evaluate(vbriefPath: string, options?: EvaluateOptions): EvaluateResult;
36
+ export { evaluateParentLineage, formatParentLineageLine, type ParentLineageResult, } from "../scope/parent-lineage.js";
23
37
  /** Structured `--json` payload (sorted keys), mirroring Python `_emit_json`. */
24
38
  export declare function emitJson(vbriefPath: string, exitCode: number, message: string): string;
25
39
  //# sourceMappingURL=evaluate.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname } from "node:path";
3
3
  import { evaluateIntentCeilingFromEnv } from "../policy/intent-ceiling.js";
4
+ import { evaluateParentLineage, formatParentLineageLine, } from "../scope/parent-lineage.js";
4
5
  /** Canonical eligibility folder — only vbrief/active/ may spawn implementation. */
5
6
  export const ACTIVE_FOLDER = "active";
6
7
  /** Canonical eligibility status — only `running` signals an active handoff. */
@@ -37,8 +38,12 @@ function nodeJsonErrorToPythonMsg(nodeMessage) {
37
38
  * Pure evaluator — returns `{ exitCode, message }`. Never throws; every error
38
39
  * path collapses to exit 1 with an actionable message. Faithful to
39
40
  * `scripts/preflight_implementation.py::evaluate`.
41
+ *
42
+ * #3241: after active+running + intent ceiling, re-check parent requirement
43
+ * lineage (coverage + approved behavioral deltas). Fail closed on missing
44
+ * coverage or undeclared deltas when the parent authors requirement IDs.
40
45
  */
41
- export function evaluate(vbriefPath) {
46
+ export function evaluate(vbriefPath, options = {}) {
42
47
  const path = vbriefPath;
43
48
  let st;
44
49
  try {
@@ -134,11 +139,37 @@ export function evaluate(vbriefPath) {
134
139
  message: buildReject(path, intent.reason),
135
140
  };
136
141
  }
142
+ // #3241 pre-PR / implementation preflight: parent lineage fail-closed.
143
+ const lineage = evaluateParentLineage({
144
+ child: record,
145
+ childPath: path,
146
+ projectRoot: options.projectRoot,
147
+ skip: options.skipParentLineage === true,
148
+ });
149
+ if (!lineage.ok) {
150
+ const defect = lineage.defect_class !== null ? ` [defect_class=${lineage.defect_class}]` : "";
151
+ return {
152
+ exitCode: 1,
153
+ parentLineage: lineage,
154
+ message: buildReject(path, `${lineage.message}${defect}\n ${formatParentLineageLine(lineage)}`),
155
+ };
156
+ }
157
+ // Keep the historical OK line when lineage is N/A (backward-compatible tests / agents).
158
+ const message = lineage.applicable
159
+ ? `OK ${path} -- ready for implementation. parent lineage OK ` +
160
+ `(${lineage.parent_requirement_ids.length} req IDs` +
161
+ (lineage.negative_invariant_ids.length > 0
162
+ ? `, ${lineage.negative_invariant_ids.length} negative invariants`
163
+ : "") +
164
+ `).`
165
+ : `OK ${path} -- ready for implementation.`;
137
166
  return {
138
167
  exitCode: 0,
139
- message: `OK ${path} -- ready for implementation.`,
168
+ parentLineage: lineage,
169
+ message,
140
170
  };
141
171
  }
172
+ export { evaluateParentLineage, formatParentLineageLine, } from "../scope/parent-lineage.js";
142
173
  /** Structured `--json` payload (sorted keys), mirroring Python `_emit_json`. */
143
174
  export function emitJson(vbriefPath, exitCode, message) {
144
175
  const payload = {