@deftai/directive-core 0.98.1 → 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 (144) 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 +401 -75
  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/consumer-check-contract/evaluate.d.ts +40 -0
  13. package/dist/consumer-check-contract/evaluate.js +188 -3
  14. package/dist/consumer-check-contract/index.d.ts +1 -1
  15. package/dist/consumer-check-contract/index.js +1 -1
  16. package/dist/content-contracts/skills/greptile-detector.d.ts +42 -0
  17. package/dist/content-contracts/skills/greptile-detector.js +202 -4
  18. package/dist/decision/index.d.ts +17 -0
  19. package/dist/decision/index.js +35 -0
  20. package/dist/decision/list.d.ts +47 -0
  21. package/dist/decision/list.js +250 -0
  22. package/dist/decision/schema.d.ts +88 -0
  23. package/dist/decision/schema.js +293 -0
  24. package/dist/decision/write.d.ts +82 -0
  25. package/dist/decision/write.js +427 -0
  26. package/dist/doctor/checks.d.ts +44 -0
  27. package/dist/doctor/checks.js +428 -2
  28. package/dist/doctor/main.d.ts +6 -7
  29. package/dist/doctor/main.js +72 -79
  30. package/dist/doctor/which.d.ts +20 -1
  31. package/dist/doctor/which.js +67 -1
  32. package/dist/eval/report.d.ts +29 -0
  33. package/dist/eval/report.js +69 -0
  34. package/dist/eval/run.d.ts +9 -0
  35. package/dist/eval/run.js +40 -4
  36. package/dist/eval/version-pin.d.ts +99 -0
  37. package/dist/eval/version-pin.js +181 -0
  38. package/dist/finish-loop/pr-finish-loop.d.ts +13 -2
  39. package/dist/finish-loop/pr-finish-loop.js +116 -5
  40. package/dist/finish-loop/types.d.ts +1 -1
  41. package/dist/hooks/dispatcher.d.ts +9 -5
  42. package/dist/hooks/dispatcher.js +23 -15
  43. package/dist/hooks/readonly.d.ts +6 -3
  44. package/dist/hooks/readonly.js +44 -25
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.js +2 -0
  47. package/dist/init-deposit/agent-hooks.d.ts +20 -0
  48. package/dist/init-deposit/agent-hooks.js +51 -31
  49. package/dist/intake/issue-ingest.js +29 -0
  50. package/dist/lifecycle/completed-consistency.d.ts +60 -0
  51. package/dist/lifecycle/completed-consistency.js +443 -0
  52. package/dist/lifecycle/completed-tracked-on-delivery.d.ts +47 -0
  53. package/dist/lifecycle/completed-tracked-on-delivery.js +415 -0
  54. package/dist/lifecycle/index.d.ts +2 -0
  55. package/dist/lifecycle/index.js +2 -0
  56. package/dist/literal-acceptance/capture.d.ts +52 -0
  57. package/dist/literal-acceptance/capture.js +658 -0
  58. package/dist/literal-acceptance/evaluate.d.ts +47 -0
  59. package/dist/literal-acceptance/evaluate.js +193 -0
  60. package/dist/literal-acceptance/index.d.ts +12 -0
  61. package/dist/literal-acceptance/index.js +12 -0
  62. package/dist/literal-acceptance/run.d.ts +38 -0
  63. package/dist/literal-acceptance/run.js +216 -0
  64. package/dist/literal-acceptance/safety.d.ts +24 -0
  65. package/dist/literal-acceptance/safety.js +251 -0
  66. package/dist/literal-acceptance/types.d.ts +82 -0
  67. package/dist/literal-acceptance/types.js +26 -0
  68. package/dist/platform/host-content-surface.d.ts +74 -0
  69. package/dist/platform/host-content-surface.js +214 -0
  70. package/dist/platform/index.d.ts +1 -0
  71. package/dist/platform/index.js +1 -0
  72. package/dist/policy/ceremony-dial.d.ts +289 -0
  73. package/dist/policy/ceremony-dial.js +980 -0
  74. package/dist/policy/deft-directive-disable.js +12 -2
  75. package/dist/policy/index.d.ts +2 -0
  76. package/dist/policy/index.js +16 -1
  77. package/dist/policy/merge-approval-head.d.ts +129 -0
  78. package/dist/policy/merge-approval-head.js +461 -0
  79. package/dist/policy/require-human-merge.d.ts +5 -0
  80. package/dist/policy/require-human-merge.js +5 -0
  81. package/dist/pr-merge-readiness/ci-gate.d.ts +20 -3
  82. package/dist/pr-merge-readiness/ci-gate.js +65 -5
  83. package/dist/pr-merge-readiness/compute.d.ts +13 -0
  84. package/dist/pr-merge-readiness/compute.js +99 -14
  85. package/dist/pr-merge-readiness/evaluate.js +10 -0
  86. package/dist/pr-merge-readiness/gh.d.ts +49 -0
  87. package/dist/pr-merge-readiness/gh.js +268 -0
  88. package/dist/pr-merge-readiness/index.d.ts +2 -2
  89. package/dist/pr-merge-readiness/index.js +2 -2
  90. package/dist/pr-merge-readiness/mergeability.js +5 -0
  91. package/dist/pr-merge-readiness/output.js +2 -0
  92. package/dist/pr-merge-readiness/parse.js +4 -0
  93. package/dist/pr-merge-readiness/test-gh-fixtures.helpers.js +4 -0
  94. package/dist/pr-merge-readiness/types.d.ts +6 -0
  95. package/dist/pr-wait-mergeable/cascade.d.ts +11 -0
  96. package/dist/pr-wait-mergeable/cascade.js +115 -1
  97. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  98. package/dist/pr-wait-mergeable/main.js +4 -0
  99. package/dist/pr-wait-mergeable/types.d.ts +3 -1
  100. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -1
  101. package/dist/pr-wait-mergeable/wrappers.js +7 -1
  102. package/dist/preflight/evaluate.d.ts +15 -1
  103. package/dist/preflight/evaluate.js +33 -2
  104. package/dist/render/framework-commands.js +4 -0
  105. package/dist/scope/acceptance-evidence.d.ts +76 -0
  106. package/dist/scope/acceptance-evidence.js +348 -0
  107. package/dist/scope/coverage-map.d.ts +98 -0
  108. package/dist/scope/coverage-map.js +558 -0
  109. package/dist/scope/decompose.js +125 -11
  110. package/dist/scope/effort-activate-gate.d.ts +28 -0
  111. package/dist/scope/effort-activate-gate.js +64 -0
  112. package/dist/scope/index.d.ts +4 -0
  113. package/dist/scope/index.js +4 -0
  114. package/dist/scope/parent-lineage.d.ts +125 -0
  115. package/dist/scope/parent-lineage.js +626 -0
  116. package/dist/scope/transition.d.ts +8 -0
  117. package/dist/scope/transition.js +69 -2
  118. package/dist/session/active-cli.d.ts +79 -0
  119. package/dist/session/active-cli.js +382 -0
  120. package/dist/session/effort-budget.d.ts +130 -0
  121. package/dist/session/effort-budget.js +334 -0
  122. package/dist/session/index.d.ts +2 -0
  123. package/dist/session/index.js +2 -0
  124. package/dist/session/session-start.d.ts +30 -1
  125. package/dist/session/session-start.js +234 -26
  126. package/dist/session/verify-session-ritual.d.ts +14 -0
  127. package/dist/session/verify-session-ritual.js +33 -0
  128. package/dist/story-ready/evaluate.d.ts +11 -0
  129. package/dist/story-ready/evaluate.js +41 -3
  130. package/dist/swarm/index.d.ts +2 -0
  131. package/dist/swarm/index.js +2 -0
  132. package/dist/swarm/pre-dispatch-cli.d.ts +19 -0
  133. package/dist/swarm/pre-dispatch-cli.js +143 -0
  134. package/dist/swarm/pre-dispatch.d.ts +87 -0
  135. package/dist/swarm/pre-dispatch.js +373 -0
  136. package/dist/triage/actions/candidates-log.d.ts +1 -2
  137. package/dist/triage/actions/candidates-log.js +37 -6
  138. package/dist/vbrief-activate/activate.js +6 -0
  139. package/dist/vbrief-validate/constants.d.ts +2 -0
  140. package/dist/vbrief-validate/constants.js +2 -0
  141. package/dist/vbrief-validate/schema.js +4 -1
  142. package/dist/xbrief-migrate/migrate-project.d.ts +4 -0
  143. package/dist/xbrief-migrate/migrate-project.js +92 -3
  144. package/package.json +19 -3
@@ -19,7 +19,7 @@
19
19
  * - Precedence: active kill-switch first, then `.no-deft-directive`, else normal.
20
20
  */
21
21
  import { execFileSync } from "node:child_process";
22
- import { statSync } from "node:fs";
22
+ import { lstatSync, statSync } from "node:fs";
23
23
  import { join, resolve } from "node:path";
24
24
  import { CANONICAL_INSTALL_ROOT } from "../init-deposit/constants.js";
25
25
  /** Canonical root-only filename (lowercase). Presence = candidate flag. */
@@ -51,9 +51,19 @@ const GIT_TRACKED_PROBE_TIMEOUT_MS = 1500;
51
51
  /** Process-local cache: avoid re-spawning git on every PreToolUse while the flag is present. */
52
52
  const trackedProbeCache = new Map();
53
53
  const TRACKED_PROBE_CACHE_TTL_MS = 30_000;
54
+ /**
55
+ * Regular-file check that does **not** treat a symlink as an intentional kill-switch
56
+ * (#3213 residual after #3206). `statSync().isFile()` follows links, so
57
+ * `ln -sf /etc/hosts .deft-directive-disable` would activate enforcement off.
58
+ * Prefer `lstatSync`: only a non-symlink regular file counts as present.
59
+ */
54
60
  function defaultIsFile(path) {
55
61
  try {
56
- return statSync(path).isFile();
62
+ const lst = lstatSync(path);
63
+ // Attacker-planted symlink → not an intentional operator flag (#3213).
64
+ if (lst.isSymbolicLink())
65
+ return false;
66
+ return lst.isFile();
57
67
  }
58
68
  catch {
59
69
  return false;
@@ -1,6 +1,7 @@
1
1
  export * from "./agents-md-advisory.js";
2
2
  export * from "./autonomy.js";
3
3
  export * from "./capacity.js";
4
+ export * from "./ceremony-dial.js";
4
5
  export * from "./check-resume.js";
5
6
  export * from "./coverage-check-resume-presets.js";
6
7
  export * from "./coverage-debt.js";
@@ -12,6 +13,7 @@ export * from "./host-hooks.js";
12
13
  export * from "./host-slash-commands.js";
13
14
  export * from "./hotfix-criteria.js";
14
15
  export * from "./intent-ceiling.js";
16
+ export * from "./merge-approval-head.js";
15
17
  export * from "./min-greptile-confidence.js";
16
18
  export * from "./no-deft-directive.js";
17
19
  export * from "./org-force-on-migration.js";
@@ -1,5 +1,6 @@
1
1
  import { FIELD_HOST_SKILL_DISCOVERY, FIELD_HOST_SKILL_DISCOVERY_CLI_ALIAS, inspectHostSkillDiscovery, } from "../init-deposit/skill-discovery-hosts.js";
2
2
  import { FIELD_OPENCLAW_PRODUCT_COMMANDS, FIELD_OPENCLAW_PRODUCT_COMMANDS_CLI_ALIAS, inspectOpenClawProductCommands, } from "../slash/openclaw-deposit.js";
3
+ import { FIELD_CEREMONY_DIAL, FIELD_CEREMONY_DIAL_CLI_ALIAS, inspectCeremonyDial, } from "./ceremony-dial.js";
3
4
  import { FIELD_CHECK_RESUME, FIELD_CHECK_RESUME_CLI_ALIAS, inspectCheckResume, } from "./check-resume.js";
4
5
  import { FIELD_COVERAGE_DEBT, FIELD_COVERAGE_DEBT_CLI_ALIAS, inspectCoverageDebt, } from "./coverage-debt.js";
5
6
  import { FIELD_DELIVERY_BRANCH, FIELD_DELIVERY_BRANCH_CLI_ALIAS, inspectDeliveryBranch, } from "./delivery-branch.js";
@@ -18,6 +19,7 @@ import { DEFAULT_WIP_CAP } from "./wip.js";
18
19
  export * from "./agents-md-advisory.js";
19
20
  export * from "./autonomy.js";
20
21
  export * from "./capacity.js";
22
+ export * from "./ceremony-dial.js";
21
23
  export * from "./check-resume.js";
22
24
  export * from "./coverage-check-resume-presets.js";
23
25
  export * from "./coverage-debt.js";
@@ -29,6 +31,7 @@ export * from "./host-hooks.js";
29
31
  export * from "./host-slash-commands.js";
30
32
  export * from "./hotfix-criteria.js";
31
33
  export * from "./intent-ceiling.js";
34
+ export * from "./merge-approval-head.js";
32
35
  export * from "./min-greptile-confidence.js";
33
36
  export * from "./no-deft-directive.js";
34
37
  export * from "./org-force-on-migration.js";
@@ -408,6 +411,15 @@ function inspectMinGreptileConfidenceField(data, projectRoot) {
408
411
  source: field.source,
409
412
  };
410
413
  }
414
+ function inspectCeremonyDialField(data, projectRoot) {
415
+ const field = inspectCeremonyDial(data, projectRoot);
416
+ return {
417
+ name: field.name,
418
+ current: field.current,
419
+ default: field.default,
420
+ source: field.source,
421
+ };
422
+ }
411
423
  const REGISTERED_POLICIES = [
412
424
  inspectAllowDirectCommits,
413
425
  inspectWipCap,
@@ -435,6 +447,7 @@ const REGISTERED_POLICIES = [
435
447
  inspectCheckResumeField,
436
448
  inspectRequireHumanMergeField,
437
449
  inspectHotfixCriteriaField,
450
+ inspectCeremonyDialField,
438
451
  ];
439
452
  /** Walk registered inspectors and return one row per field (#1148). */
440
453
  export function inspectAllPolicies(projectRoot) {
@@ -471,7 +484,9 @@ export function inspectOnePolicy(name, projectRoot) {
471
484
  ? FIELD_DELIVERY_BRANCH
472
485
  : name === FIELD_MIN_GREPTILE_CONFIDENCE_CLI_ALIAS
473
486
  ? FIELD_MIN_GREPTILE_CONFIDENCE
474
- : name;
487
+ : name === FIELD_CEREMONY_DIAL_CLI_ALIAS
488
+ ? FIELD_CEREMONY_DIAL
489
+ : name;
475
490
  for (const field of inspectAllPolicies(projectRoot)) {
476
491
  if (field.name === normalized)
477
492
  return field;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Head-bound human merge approval (#3235).
3
+ *
4
+ * Phase 5 → 6 `plan:approved` events authorize one immutable PR head.
5
+ * When PR HEAD changes after approval (and GitHub auto-merge may still be
6
+ * enabled), Directive fails closed: treats the approval as stale, attempts to
7
+ * disable auto-merge, and emits recovery text requiring a fresh approval bound
8
+ * to the current head.
9
+ *
10
+ * Prefer this path from finish-loop / pr-wait-mergeable / require-human-merge
11
+ * consumers. Does not change consumer repository rulesets.
12
+ */
13
+ import { type BehavioralEventRecord } from "../lifecycle/events.js";
14
+ /** Event name recorded at Phase 5 → 6 approval. */
15
+ export declare const PLAN_APPROVED_EVENT = "plan:approved";
16
+ export type MergeApprovalHeadStatus =
17
+ /** approved_head_sha matches current_head_sha. */
18
+ "ok"
19
+ /** Approval bound to a different head than current. */
20
+ | "stale"
21
+ /** Approval exists but was not bound to a head SHA. */
22
+ | "missing_binding"
23
+ /** No plan:approved record for this PR. */
24
+ | "no_approval"
25
+ /** Gate intentionally skipped (e.g. no PR / no current head). */
26
+ | "skipped";
27
+ export interface PlanApprovedRecord {
28
+ readonly head_sha: string | null;
29
+ readonly pr_number: number | null;
30
+ readonly approver: string | null;
31
+ readonly plan_ref: string | null;
32
+ readonly detected_at: string | null;
33
+ readonly payload: Readonly<Record<string, unknown>>;
34
+ }
35
+ export interface MergeApprovalHeadResult {
36
+ readonly status: MergeApprovalHeadStatus;
37
+ /** False when merge must not proceed under head-bound contract. */
38
+ readonly allowed: boolean;
39
+ readonly approved_head_sha: string | null;
40
+ readonly current_head_sha: string | null;
41
+ readonly pr_number: number;
42
+ readonly require_human_merge: boolean;
43
+ readonly auto_merge_disabled: boolean | null;
44
+ readonly message: string;
45
+ readonly recovery: string | null;
46
+ }
47
+ export type DisableAutoMergeFn = (prNumber: number, repo: string | null) => {
48
+ readonly ok: boolean;
49
+ readonly stderr: string;
50
+ };
51
+ export type FetchPrHeadShaFn = (prNumber: number, repo: string | null) => string | null;
52
+ export type ReadEventsFn = (logPath?: string | null) => BehavioralEventRecord[];
53
+ /** Full Git object id length — approvals must bind to exact heads (#3235 / Greptile P1). */
54
+ export declare const FULL_HEAD_SHA_LEN = 40;
55
+ /**
56
+ * Exact full-SHA equality only (#3235).
57
+ * Prefix matching is forbidden: abbreviated approval SHAs must not authorize a later head.
58
+ */
59
+ export declare function headShaMatches(a: string | null, b: string | null): boolean;
60
+ /**
61
+ * Latest plan:approved for a PR (by log order; last write wins).
62
+ * When `repo` is set, prefer records whose repository/plan_ref matches (P2 #3235).
63
+ */
64
+ export declare function findLatestPlanApprovalForPr(prNumber: number, options?: {
65
+ readonly logPath?: string | null;
66
+ readonly readEventsFn?: ReadEventsFn;
67
+ readonly records?: readonly BehavioralEventRecord[];
68
+ /** owner/repo — scopes lookup when multiple remotes share a PR number. */
69
+ readonly repo?: string | null;
70
+ }): PlanApprovedRecord | null;
71
+ /** Recovery instructions when approval is stale or unbound. */
72
+ export declare function buildMergeApprovalRecovery(input: {
73
+ readonly prNumber: number;
74
+ readonly approvedHeadSha: string | null;
75
+ readonly currentHeadSha: string | null;
76
+ readonly status: MergeApprovalHeadStatus;
77
+ readonly autoMergeDisabled: boolean | null;
78
+ }): string;
79
+ export interface EvaluateMergeApprovalHeadInput {
80
+ readonly prNumber: number;
81
+ readonly currentHeadSha: string | null;
82
+ readonly projectRoot?: string;
83
+ readonly logPath?: string | null;
84
+ readonly requireHumanMerge?: boolean;
85
+ /** owner/repo for scoped approval lookup. */
86
+ readonly repo?: string | null;
87
+ /**
88
+ * When true (default), approval without head_sha fails closed under
89
+ * requireHumanMerge. When false, missing binding is advisory only.
90
+ */
91
+ readonly enforceStrictBinding?: boolean;
92
+ /**
93
+ * When true, run the gate whenever a plan:approved exists for the PR
94
+ * (default). When false, only under requireHumanMerge.
95
+ */
96
+ readonly enforceWhenApprovalPresent?: boolean;
97
+ readonly readEventsFn?: ReadEventsFn;
98
+ readonly records?: readonly BehavioralEventRecord[];
99
+ readonly approval?: PlanApprovedRecord | null;
100
+ }
101
+ /**
102
+ * Pure evaluation: compare recorded approval head to current PR HEAD.
103
+ */
104
+ export declare function evaluateMergeApprovalHead(input: EvaluateMergeApprovalHeadInput): MergeApprovalHeadResult;
105
+ /** Live `gh pr merge <N> --disable-auto` (best-effort). */
106
+ export declare function disablePullRequestAutoMerge(prNumber: number, repo: string | null, options?: {
107
+ readonly timeoutSec?: number;
108
+ }): {
109
+ readonly ok: boolean;
110
+ readonly stderr: string;
111
+ };
112
+ /** REST HEAD read via `gh api repos/.../pulls/N --jq .head.sha`. */
113
+ export declare function fetchPrHeadShaRest(prNumber: number, repo: string | null, options?: {
114
+ readonly timeoutSec?: number;
115
+ }): string | null;
116
+ export interface EnforceMergeApprovalHeadInput extends EvaluateMergeApprovalHeadInput {
117
+ readonly repo?: string | null;
118
+ /** When true (default on deny), attempt gh --disable-auto. */
119
+ readonly disableAutoMergeOnDeny?: boolean;
120
+ readonly disableAutoMergeFn?: DisableAutoMergeFn;
121
+ readonly fetchHeadShaFn?: FetchPrHeadShaFn;
122
+ }
123
+ /**
124
+ * Evaluate + on deny attempt to disable GitHub auto-merge and attach recovery.
125
+ */
126
+ export declare function enforceMergeApprovalHead(input: EnforceMergeApprovalHeadInput): MergeApprovalHeadResult;
127
+ /** Default event log path relative to project root. */
128
+ export declare function defaultMergeApprovalEventLog(projectRoot: string): string;
129
+ //# sourceMappingURL=merge-approval-head.d.ts.map
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Head-bound human merge approval (#3235).
3
+ *
4
+ * Phase 5 → 6 `plan:approved` events authorize one immutable PR head.
5
+ * When PR HEAD changes after approval (and GitHub auto-merge may still be
6
+ * enabled), Directive fails closed: treats the approval as stale, attempts to
7
+ * disable auto-merge, and emits recovery text requiring a fresh approval bound
8
+ * to the current head.
9
+ *
10
+ * Prefer this path from finish-loop / pr-wait-mergeable / require-human-merge
11
+ * consumers. Does not change consumer repository rulesets.
12
+ */
13
+ import { join } from "node:path";
14
+ import { DEFAULT_EVENT_LOG, readEvents } from "../lifecycle/events.js";
15
+ import { captureExec } from "../pr-wait-mergeable/wrappers.js";
16
+ import { resolveBinary } from "../scm/binary.js";
17
+ import { resolveHumanMergePolicy } from "./require-human-merge.js";
18
+ /** Event name recorded at Phase 5 → 6 approval. */
19
+ export const PLAN_APPROVED_EVENT = "plan:approved";
20
+ /** Full Git object id length — approvals must bind to exact heads (#3235 / Greptile P1). */
21
+ export const FULL_HEAD_SHA_LEN = 40;
22
+ function isHexSha(value) {
23
+ for (let i = 0; i < value.length; i += 1) {
24
+ const c = value.charCodeAt(i);
25
+ const isDigit = c >= 48 && c <= 57;
26
+ const isAf = c >= 97 && c <= 102;
27
+ if (!isDigit && !isAf)
28
+ return false;
29
+ }
30
+ return value.length > 0;
31
+ }
32
+ function normalizeSha(value) {
33
+ if (typeof value !== "string")
34
+ return null;
35
+ const trimmed = value.trim().toLowerCase();
36
+ // Exact full-object-id only — reject short and overlong tokens (#3235 Greptile).
37
+ if (trimmed.length !== FULL_HEAD_SHA_LEN)
38
+ return null;
39
+ if (!isHexSha(trimmed))
40
+ return null;
41
+ return trimmed;
42
+ }
43
+ /**
44
+ * Exact full-SHA equality only (#3235).
45
+ * Prefix matching is forbidden: abbreviated approval SHAs must not authorize a later head.
46
+ */
47
+ export function headShaMatches(a, b) {
48
+ if (a === null || b === null)
49
+ return false;
50
+ const left = a.trim().toLowerCase();
51
+ const right = b.trim().toLowerCase();
52
+ if (left.length !== FULL_HEAD_SHA_LEN || right.length !== FULL_HEAD_SHA_LEN)
53
+ return false;
54
+ if (!isHexSha(left) || !isHexSha(right))
55
+ return false;
56
+ return left === right;
57
+ }
58
+ /** Extract trailing decimal digits starting at index (no regex — CodeQL safe). */
59
+ function parseLeadingDigits(text, start) {
60
+ let digits = "";
61
+ for (let i = start; i < text.length; i += 1) {
62
+ const c = text.charCodeAt(i);
63
+ if (c < 48 || c > 57)
64
+ break;
65
+ digits += text[i];
66
+ }
67
+ if (digits.length === 0)
68
+ return null;
69
+ const n = Number.parseInt(digits, 10);
70
+ return Number.isInteger(n) && n > 0 ? n : null;
71
+ }
72
+ function prNumberFromPayload(payload) {
73
+ const raw = payload.pr_number;
74
+ if (typeof raw === "number" && Number.isInteger(raw) && raw > 0)
75
+ return raw;
76
+ if (typeof raw === "string") {
77
+ const trimmed = raw.trim();
78
+ let allDigits = trimmed.length > 0;
79
+ for (let i = 0; i < trimmed.length; i += 1) {
80
+ const c = trimmed.charCodeAt(i);
81
+ if (c < 48 || c > 57) {
82
+ allDigits = false;
83
+ break;
84
+ }
85
+ }
86
+ if (allDigits)
87
+ return Number.parseInt(trimmed, 10);
88
+ }
89
+ const planRef = payload.plan_ref;
90
+ if (typeof planRef === "string") {
91
+ const lower = planRef.toLowerCase();
92
+ const marker = "/pull/";
93
+ const idx = lower.indexOf(marker);
94
+ if (idx >= 0) {
95
+ return parseLeadingDigits(planRef, idx + marker.length);
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+ function repositoryFromPayload(payload) {
101
+ if (typeof payload.repository === "string" && payload.repository.includes("/")) {
102
+ return payload.repository.toLowerCase();
103
+ }
104
+ if (typeof payload.plan_ref === "string") {
105
+ const lower = payload.plan_ref.toLowerCase();
106
+ const marker = "github.com/";
107
+ const idx = lower.indexOf(marker);
108
+ if (idx >= 0) {
109
+ const rest = payload.plan_ref.slice(idx + marker.length);
110
+ const parts = rest.split("/");
111
+ if (parts.length >= 2 && parts[0] && parts[1]) {
112
+ return `${parts[0]}/${parts[1]}`.toLowerCase();
113
+ }
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ function toPlanApproved(record) {
119
+ const payload = record.payload;
120
+ return {
121
+ head_sha: normalizeSha(payload.head_sha),
122
+ pr_number: prNumberFromPayload(payload),
123
+ approver: typeof payload.approver === "string" ? payload.approver : null,
124
+ plan_ref: typeof payload.plan_ref === "string" ? payload.plan_ref : null,
125
+ detected_at: typeof record.detected_at === "string" ? record.detected_at : null,
126
+ payload,
127
+ };
128
+ }
129
+ /**
130
+ * Latest plan:approved for a PR (by log order; last write wins).
131
+ * When `repo` is set, prefer records whose repository/plan_ref matches (P2 #3235).
132
+ */
133
+ export function findLatestPlanApprovalForPr(prNumber, options = {}) {
134
+ const stream = options.records !== undefined
135
+ ? options.records
136
+ : (options.readEventsFn ?? readEvents)(options.logPath ?? null);
137
+ const wantRepo = typeof options.repo === "string" && options.repo.includes("/")
138
+ ? options.repo.toLowerCase()
139
+ : null;
140
+ let latest = null;
141
+ for (const record of stream) {
142
+ if (record.event !== PLAN_APPROVED_EVENT)
143
+ continue;
144
+ const approved = toPlanApproved(record);
145
+ if (approved.pr_number !== prNumber)
146
+ continue;
147
+ if (wantRepo !== null) {
148
+ // When repo is known, never fall back to an unscoped same-number event
149
+ // (cross-repo shadow / Greptile conf floor on #3235).
150
+ const recRepo = repositoryFromPayload(record.payload);
151
+ if (recRepo !== wantRepo)
152
+ continue;
153
+ }
154
+ latest = approved;
155
+ }
156
+ return latest;
157
+ }
158
+ /** Recovery instructions when approval is stale or unbound. */
159
+ export function buildMergeApprovalRecovery(input) {
160
+ const approved = input.approvedHeadSha ?? "<none>";
161
+ const current = input.currentHeadSha ?? "<unknown>";
162
+ const lines = [
163
+ "Recovery (#3235 head-bound merge approval):",
164
+ ` • approved_head_sha=${approved}`,
165
+ ` • current_head_sha=${current}`,
166
+ ];
167
+ if (input.autoMergeDisabled === true) {
168
+ lines.push(" • GitHub auto-merge was disabled for this PR (stale authorization revoked).");
169
+ }
170
+ else if (input.autoMergeDisabled === false) {
171
+ lines.push(" • Could not confirm auto-merge disable — run:", ` gh pr merge ${input.prNumber} --disable-auto`);
172
+ }
173
+ else {
174
+ lines.push(" • If auto-merge is still enabled, disable it:", ` gh pr merge ${input.prNumber} --disable-auto`);
175
+ }
176
+ lines.push(" • Re-run Phase 5 → 6 approval for the CURRENT head:", " task lifecycle:event -- emit plan:approved \\", " --plan-ref <pr-url> --approver <login> --approval-phrase yes \\", ` --pr-number ${input.prNumber} --head-sha ${current}`, " • Only after a fresh head-bound approval may auto-merge be re-enabled", " or the human merge proceed for this head.");
177
+ if (input.status === "missing_binding") {
178
+ lines.push(" • Prior approval lacked --head-sha; re-emit with --head-sha bound to current HEAD.");
179
+ }
180
+ return lines.join("\n");
181
+ }
182
+ /**
183
+ * Pure evaluation: compare recorded approval head to current PR HEAD.
184
+ */
185
+ export function evaluateMergeApprovalHead(input) {
186
+ const prNumber = input.prNumber;
187
+ const currentHeadSha = normalizeSha(input.currentHeadSha);
188
+ const requireHumanMerge = input.requireHumanMerge !== undefined
189
+ ? input.requireHumanMerge
190
+ : input.projectRoot !== undefined
191
+ ? resolveHumanMergePolicy(input.projectRoot).requireHumanMerge
192
+ : false;
193
+ const enforceWhenApprovalPresent = input.enforceWhenApprovalPresent !== false;
194
+ const enforceStrictBinding = input.enforceStrictBinding !== false;
195
+ if (prNumber <= 0) {
196
+ return {
197
+ status: "skipped",
198
+ allowed: true,
199
+ approved_head_sha: null,
200
+ current_head_sha: currentHeadSha,
201
+ pr_number: prNumber,
202
+ require_human_merge: requireHumanMerge,
203
+ auto_merge_disabled: null,
204
+ message: "merge-approval-head: skipped (invalid pr number)",
205
+ recovery: null,
206
+ };
207
+ }
208
+ const resolvedLogPath = input.logPath !== undefined
209
+ ? input.logPath
210
+ : input.projectRoot !== undefined
211
+ ? join(input.projectRoot, DEFAULT_EVENT_LOG)
212
+ : null;
213
+ const wantRepo = typeof input.repo === "string" && input.repo.includes("/") ? input.repo : null;
214
+ // Fail closed when approvals exist for this PR number but callers omitted repo
215
+ // (cross-repo same-number collision / Greptile #3235).
216
+ if (input.approval === undefined && wantRepo === null) {
217
+ const unscoped = findLatestPlanApprovalForPr(prNumber, {
218
+ logPath: resolvedLogPath,
219
+ readEventsFn: input.readEventsFn,
220
+ records: input.records,
221
+ repo: null,
222
+ });
223
+ if (unscoped !== null && (requireHumanMerge || enforceWhenApprovalPresent)) {
224
+ const recovery = buildMergeApprovalRecovery({
225
+ prNumber,
226
+ approvedHeadSha: unscoped.head_sha,
227
+ currentHeadSha,
228
+ status: "stale",
229
+ autoMergeDisabled: null,
230
+ });
231
+ return {
232
+ status: "stale",
233
+ allowed: false,
234
+ approved_head_sha: unscoped.head_sha,
235
+ current_head_sha: currentHeadSha,
236
+ pr_number: prNumber,
237
+ require_human_merge: requireHumanMerge,
238
+ auto_merge_disabled: null,
239
+ message: `❌ merge-approval-head (#3235): plan:approved exists for PR #${prNumber} but ` +
240
+ "repository is unknown — pass --repo owner/repo (or GH_REPO) so approval " +
241
+ "lookup cannot apply a foreign same-number event.",
242
+ recovery,
243
+ };
244
+ }
245
+ }
246
+ const approval = input.approval !== undefined
247
+ ? input.approval
248
+ : findLatestPlanApprovalForPr(prNumber, {
249
+ logPath: resolvedLogPath,
250
+ readEventsFn: input.readEventsFn,
251
+ records: input.records,
252
+ repo: wantRepo,
253
+ });
254
+ if (approval === null) {
255
+ // No Directive-owned approval — gate is a no-op (human-merge policy is separate).
256
+ return {
257
+ status: "no_approval",
258
+ allowed: true,
259
+ approved_head_sha: null,
260
+ current_head_sha: currentHeadSha,
261
+ pr_number: prNumber,
262
+ require_human_merge: requireHumanMerge,
263
+ auto_merge_disabled: null,
264
+ message: `merge-approval-head: no plan:approved for PR #${prNumber}`,
265
+ recovery: null,
266
+ };
267
+ }
268
+ if (!enforceWhenApprovalPresent && !requireHumanMerge) {
269
+ return {
270
+ status: "skipped",
271
+ allowed: true,
272
+ approved_head_sha: approval.head_sha,
273
+ current_head_sha: currentHeadSha,
274
+ pr_number: prNumber,
275
+ require_human_merge: requireHumanMerge,
276
+ auto_merge_disabled: null,
277
+ message: "merge-approval-head: skipped (no requireHumanMerge / not enforcing)",
278
+ recovery: null,
279
+ };
280
+ }
281
+ if (approval.head_sha === null) {
282
+ const recovery = buildMergeApprovalRecovery({
283
+ prNumber,
284
+ approvedHeadSha: null,
285
+ currentHeadSha,
286
+ status: "missing_binding",
287
+ autoMergeDisabled: null,
288
+ });
289
+ const fail = enforceStrictBinding && (requireHumanMerge || enforceWhenApprovalPresent);
290
+ return {
291
+ status: "missing_binding",
292
+ allowed: !fail,
293
+ approved_head_sha: null,
294
+ current_head_sha: currentHeadSha,
295
+ pr_number: prNumber,
296
+ require_human_merge: requireHumanMerge,
297
+ auto_merge_disabled: null,
298
+ message: `❌ merge-approval-head (#3235): plan:approved for PR #${prNumber} has no head_sha. ` +
299
+ "Approval must bind to an exact PR head under requireHumanMerge / auto-merge.",
300
+ recovery: fail ? recovery : null,
301
+ };
302
+ }
303
+ if (currentHeadSha === null) {
304
+ const recovery = buildMergeApprovalRecovery({
305
+ prNumber,
306
+ approvedHeadSha: approval.head_sha,
307
+ currentHeadSha: null,
308
+ status: "stale",
309
+ autoMergeDisabled: null,
310
+ });
311
+ return {
312
+ status: "stale",
313
+ allowed: false,
314
+ approved_head_sha: approval.head_sha,
315
+ current_head_sha: null,
316
+ pr_number: prNumber,
317
+ require_human_merge: requireHumanMerge,
318
+ auto_merge_disabled: null,
319
+ message: `❌ merge-approval-head (#3235): cannot verify approval for PR #${prNumber} ` +
320
+ `(approved_head_sha=${approval.head_sha}) — current HEAD unknown; fail closed.`,
321
+ recovery,
322
+ };
323
+ }
324
+ if (headShaMatches(approval.head_sha, currentHeadSha)) {
325
+ return {
326
+ status: "ok",
327
+ allowed: true,
328
+ approved_head_sha: approval.head_sha,
329
+ current_head_sha: currentHeadSha,
330
+ pr_number: prNumber,
331
+ require_human_merge: requireHumanMerge,
332
+ auto_merge_disabled: null,
333
+ message: `✓ merge-approval-head: plan:approved for PR #${prNumber} bound to ` +
334
+ `current head ${currentHeadSha.slice(0, 12)}`,
335
+ recovery: null,
336
+ };
337
+ }
338
+ const recovery = buildMergeApprovalRecovery({
339
+ prNumber,
340
+ approvedHeadSha: approval.head_sha,
341
+ currentHeadSha,
342
+ status: "stale",
343
+ autoMergeDisabled: null,
344
+ });
345
+ return {
346
+ status: "stale",
347
+ allowed: false,
348
+ approved_head_sha: approval.head_sha,
349
+ current_head_sha: currentHeadSha,
350
+ pr_number: prNumber,
351
+ require_human_merge: requireHumanMerge,
352
+ auto_merge_disabled: null,
353
+ message: `❌ merge-approval-head (#3235): stale approval for PR #${prNumber}. ` +
354
+ `approved_head_sha=${approval.head_sha} != current_head_sha=${currentHeadSha}. ` +
355
+ "Human merge approval authorizes one immutable head; HEAD changed after approval.",
356
+ recovery,
357
+ };
358
+ }
359
+ /** Live `gh pr merge <N> --disable-auto` (best-effort). */
360
+ export function disablePullRequestAutoMerge(prNumber, repo, options = {}) {
361
+ let binary;
362
+ try {
363
+ binary = resolveBinary();
364
+ }
365
+ catch {
366
+ return { ok: false, stderr: "gh CLI not found" };
367
+ }
368
+ const args = ["pr", "merge", String(prNumber), "--disable-auto"];
369
+ if (repo !== null && repo.length > 0) {
370
+ args.push("--repo", repo);
371
+ }
372
+ const result = captureExec(binary, args, (options.timeoutSec ?? 60) * 1000);
373
+ // gh returns 0 when disabled, and may return non-zero if auto-merge was not enabled
374
+ // ("auto-merge is not enabled") — treat that as ok (already off).
375
+ const stderr = result.stderr.trim();
376
+ if (result.returncode === 0) {
377
+ return { ok: true, stderr };
378
+ }
379
+ const low = stderr.toLowerCase();
380
+ if (low.includes("not enabled") ||
381
+ low.includes("auto-merge is disabled") ||
382
+ low.includes("is not set to auto-merge")) {
383
+ return { ok: true, stderr };
384
+ }
385
+ return { ok: false, stderr };
386
+ }
387
+ /** REST HEAD read via `gh api repos/.../pulls/N --jq .head.sha`. */
388
+ export function fetchPrHeadShaRest(prNumber, repo, options = {}) {
389
+ if (repo === null || repo.length === 0)
390
+ return null;
391
+ let binary;
392
+ try {
393
+ binary = resolveBinary();
394
+ }
395
+ catch {
396
+ return null;
397
+ }
398
+ const endpoint = `repos/${repo}/pulls/${prNumber}`;
399
+ const result = captureExec(binary, ["api", endpoint, "--jq", ".head.sha"], (options.timeoutSec ?? 60) * 1000);
400
+ if (result.returncode !== 0)
401
+ return null;
402
+ return normalizeSha(result.stdout.trim());
403
+ }
404
+ /**
405
+ * Evaluate + on deny attempt to disable GitHub auto-merge and attach recovery.
406
+ */
407
+ export function enforceMergeApprovalHead(input) {
408
+ let currentHeadSha = input.currentHeadSha;
409
+ if ((currentHeadSha === null || currentHeadSha === undefined || currentHeadSha.trim() === "") &&
410
+ input.fetchHeadShaFn !== undefined) {
411
+ currentHeadSha = input.fetchHeadShaFn(input.prNumber, input.repo ?? null);
412
+ }
413
+ const evaluated = evaluateMergeApprovalHead({
414
+ ...input,
415
+ currentHeadSha: currentHeadSha ?? null,
416
+ });
417
+ if (evaluated.allowed) {
418
+ return evaluated;
419
+ }
420
+ const shouldDisable = input.disableAutoMergeOnDeny !== false;
421
+ let autoMergeDisabled = null;
422
+ if (shouldDisable) {
423
+ const disableFn = input.disableAutoMergeFn ?? disablePullRequestAutoMerge;
424
+ const disabled = disableFn(input.prNumber, input.repo ?? null);
425
+ autoMergeDisabled = disabled.ok;
426
+ }
427
+ const recovery = buildMergeApprovalRecovery({
428
+ prNumber: input.prNumber,
429
+ approvedHeadSha: evaluated.approved_head_sha,
430
+ currentHeadSha: evaluated.current_head_sha,
431
+ status: evaluated.status,
432
+ autoMergeDisabled,
433
+ });
434
+ return {
435
+ ...evaluated,
436
+ auto_merge_disabled: autoMergeDisabled,
437
+ message: evaluated.message,
438
+ recovery,
439
+ };
440
+ }
441
+ /** Strip trailing path separators without regex (CodeQL-safe). */
442
+ function trimTrailingSeparators(path) {
443
+ let end = path.length;
444
+ while (end > 0) {
445
+ const ch = path.charCodeAt(end - 1);
446
+ if (ch === 47 || ch === 92) {
447
+ // / or \
448
+ end -= 1;
449
+ continue;
450
+ }
451
+ break;
452
+ }
453
+ return path.slice(0, end);
454
+ }
455
+ /** Default event log path relative to project root. */
456
+ export function defaultMergeApprovalEventLog(projectRoot) {
457
+ const root = trimTrailingSeparators(projectRoot);
458
+ const log = DEFAULT_EVENT_LOG.split("\\").join("/");
459
+ return `${root}/${log}`;
460
+ }
461
+ //# sourceMappingURL=merge-approval-head.js.map