@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
@@ -0,0 +1,99 @@
1
+ /**
2
+ * #3215 — pin and record framework version per eval cell; refuse mixed-version aggregation.
3
+ * Extends #3081 empiricism; wires into the #1584 shared-benchmark manifest shape when present.
4
+ */
5
+ /** Repo-relative path for the #1584 shared-benchmark manifest. */
6
+ export declare const SHARED_BENCHMARK_MANIFEST_REL = "evals/shared-benchmark.json";
7
+ /** How the framework version was resolved at eval run start. */
8
+ export type FrameworkVersionSource = "package.json" | "override";
9
+ /** Policy when a treatment cell contains disagreeing framework versions. */
10
+ export type MixedVersionPolicy = "refuse" | "flag";
11
+ /** Resolved framework version pin captured once at eval run start. */
12
+ export interface FrameworkVersionPin {
13
+ readonly frameworkVersion: string;
14
+ readonly source: FrameworkVersionSource;
15
+ readonly resolvedAt: string;
16
+ }
17
+ /** Minimal run identity for cell purity checks (version + treatment grouping). */
18
+ export interface VersionedEvalRun {
19
+ readonly frameworkVersion: string;
20
+ readonly treatment?: string;
21
+ readonly model?: string;
22
+ readonly harness?: string;
23
+ readonly runId?: string;
24
+ }
25
+ /** Result of checking whether one treatment cell is version-pure. */
26
+ export interface CellVersionPurity {
27
+ readonly pure: boolean;
28
+ readonly frameworkVersion: string | null;
29
+ readonly versions: readonly string[];
30
+ readonly runCount: number;
31
+ readonly treatment: string;
32
+ readonly message: string;
33
+ }
34
+ export interface AggregateCellOptions {
35
+ readonly runs: readonly VersionedEvalRun[];
36
+ /** Treatment key when runs omit `treatment` (e.g. model or with_skill). */
37
+ readonly treatment?: string;
38
+ /** Default `refuse`: mixed cells block aggregation. `flag` allows with purity evidence. */
39
+ readonly policy?: MixedVersionPolicy;
40
+ }
41
+ export interface AggregateCellResult {
42
+ readonly purity: CellVersionPurity;
43
+ readonly allowed: boolean;
44
+ readonly policy: MixedVersionPolicy;
45
+ readonly frameworkVersion: string | null;
46
+ }
47
+ /** Version block stamped onto a #1584-shaped shared-benchmark manifest. */
48
+ export interface SharedBenchmarkVersionBlock {
49
+ readonly frameworkVersion: string;
50
+ readonly frameworkVersionSource: FrameworkVersionSource;
51
+ readonly frameworkVersionResolvedAt: string;
52
+ readonly versionPurityGate: "#3215";
53
+ }
54
+ /**
55
+ * Resolve the directive framework version once at eval run start.
56
+ * Prefers an explicit override (tests / pinned doctor output); else package.json pin.
57
+ */
58
+ export declare function resolveFrameworkVersionPin(options?: {
59
+ readonly override?: string;
60
+ readonly now?: () => Date;
61
+ }): FrameworkVersionPin;
62
+ /**
63
+ * Cell-level purity: all runs in one treatment must share a single framework version.
64
+ * Empty run sets are vacuously pure.
65
+ */
66
+ export declare function evaluateCellVersionPurity(runs: readonly VersionedEvalRun[], treatment?: string): CellVersionPurity;
67
+ /**
68
+ * Aggregate (or refuse) runs for one treatment under the mixed-version policy.
69
+ * `refuse` (default) sets `allowed=false` when versions disagree; `flag` allows with evidence.
70
+ */
71
+ export declare function aggregateCellWithVersionPurity(options: AggregateCellOptions): AggregateCellResult;
72
+ /**
73
+ * Group runs by treatment (or model×harness fallback) and evaluate purity per cell.
74
+ */
75
+ export declare function evaluateLedgerVersionPurity(runs: readonly VersionedEvalRun[]): {
76
+ readonly pure: boolean;
77
+ readonly cells: readonly CellVersionPurity[];
78
+ readonly summary: string;
79
+ };
80
+ /**
81
+ * Merge a framework version pin into a #1584-shaped shared-benchmark manifest object.
82
+ * Stamps top-level `frameworkVersion` plus metadata for external consumers.
83
+ */
84
+ export declare function wireFrameworkVersionIntoManifest(manifest: Record<string, unknown>, pin: FrameworkVersionPin): Record<string, unknown>;
85
+ /** Load `evals/shared-benchmark.json` when present; otherwise null. */
86
+ export declare function loadSharedBenchmarkManifest(projectRoot: string): Record<string, unknown> | null;
87
+ /**
88
+ * When a #1584 shared-benchmark manifest exists under the project root, return it
89
+ * with the framework version pin wired in. When `persist` is true, write the
90
+ * wired manifest back to disk so subsequent consumers see the pin (#3215 Greptile).
91
+ */
92
+ export declare function applyVersionPinToSharedBenchmark(projectRoot: string, pin: FrameworkVersionPin, options?: {
93
+ readonly persist?: boolean;
94
+ }): {
95
+ readonly applied: boolean;
96
+ readonly persisted: boolean;
97
+ readonly manifest: Record<string, unknown> | null;
98
+ };
99
+ //# sourceMappingURL=version-pin.d.ts.map
@@ -0,0 +1,181 @@
1
+ /**
2
+ * #3215 — pin and record framework version per eval cell; refuse mixed-version aggregation.
3
+ * Extends #3081 empiricism; wires into the #1584 shared-benchmark manifest shape when present.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { readCorePackageVersion } from "../engine-version.js";
8
+ import { containedWrite } from "../fs/contained-write.js";
9
+ /** Repo-relative path for the #1584 shared-benchmark manifest. */
10
+ export const SHARED_BENCHMARK_MANIFEST_REL = "evals/shared-benchmark.json";
11
+ function toIsoZ(date) {
12
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
13
+ }
14
+ /**
15
+ * Resolve the directive framework version once at eval run start.
16
+ * Prefers an explicit override (tests / pinned doctor output); else package.json pin.
17
+ */
18
+ export function resolveFrameworkVersionPin(options) {
19
+ const now = options?.now ?? (() => new Date());
20
+ const override = options?.override?.trim();
21
+ if (override !== undefined && override.length > 0) {
22
+ return {
23
+ frameworkVersion: override,
24
+ source: "override",
25
+ resolvedAt: toIsoZ(now()),
26
+ };
27
+ }
28
+ return {
29
+ frameworkVersion: readCorePackageVersion(),
30
+ source: "package.json",
31
+ resolvedAt: toIsoZ(now()),
32
+ };
33
+ }
34
+ /**
35
+ * Cell-level purity: all runs in one treatment must share a single framework version.
36
+ * Empty run sets are vacuously pure.
37
+ */
38
+ export function evaluateCellVersionPurity(runs, treatment) {
39
+ const label = treatment?.trim() ||
40
+ runs.find((r) => typeof r.treatment === "string" && r.treatment.length > 0)?.treatment ||
41
+ "default";
42
+ const versions = [
43
+ ...new Set(runs.map((r) => r.frameworkVersion.trim()).filter((v) => v.length > 0)),
44
+ ].sort();
45
+ if (runs.length === 0) {
46
+ return {
47
+ pure: true,
48
+ frameworkVersion: null,
49
+ versions: [],
50
+ runCount: 0,
51
+ treatment: label,
52
+ message: `Cell "${label}": no runs (vacuously pure).`,
53
+ };
54
+ }
55
+ if (versions.length <= 1) {
56
+ const version = versions[0] ?? null;
57
+ return {
58
+ pure: true,
59
+ frameworkVersion: version,
60
+ versions,
61
+ runCount: runs.length,
62
+ treatment: label,
63
+ message: `Cell "${label}": version-pure (v${version ?? "unknown"}, ${runs.length} run(s)).`,
64
+ };
65
+ }
66
+ return {
67
+ pure: false,
68
+ frameworkVersion: null,
69
+ versions,
70
+ runCount: runs.length,
71
+ treatment: label,
72
+ message: `Cell "${label}": mixed framework versions [${versions.join(", ")}] across ${runs.length} run(s) — mixed-version aggregation blocked (#3215).`,
73
+ };
74
+ }
75
+ /**
76
+ * Aggregate (or refuse) runs for one treatment under the mixed-version policy.
77
+ * `refuse` (default) sets `allowed=false` when versions disagree; `flag` allows with evidence.
78
+ */
79
+ export function aggregateCellWithVersionPurity(options) {
80
+ const policy = options.policy ?? "refuse";
81
+ const purity = evaluateCellVersionPurity(options.runs, options.treatment);
82
+ const allowed = purity.pure || policy === "flag";
83
+ return {
84
+ purity,
85
+ allowed,
86
+ policy,
87
+ frameworkVersion: purity.frameworkVersion,
88
+ };
89
+ }
90
+ /**
91
+ * Group runs by treatment (or model×harness fallback) and evaluate purity per cell.
92
+ */
93
+ export function evaluateLedgerVersionPurity(runs) {
94
+ const groups = new Map();
95
+ for (const run of runs) {
96
+ const key = run.treatment?.trim() ||
97
+ [run.model ?? "", run.harness ?? ""].filter((p) => p.length > 0).join("@") ||
98
+ "default";
99
+ const list = groups.get(key) ?? [];
100
+ list.push(run);
101
+ groups.set(key, list);
102
+ }
103
+ const cells = [...groups.entries()]
104
+ .sort(([a], [b]) => a.localeCompare(b))
105
+ .map(([key, group]) => evaluateCellVersionPurity(group, key));
106
+ const pure = cells.every((c) => c.pure);
107
+ const mixed = cells.filter((c) => !c.pure);
108
+ const summary = cells.length === 0
109
+ ? "Version purity: no cells."
110
+ : pure
111
+ ? `Version purity: all ${cells.length} cell(s) pure.`
112
+ : `Version purity: ${mixed.length}/${cells.length} cell(s) mixed — ${mixed.map((c) => c.treatment).join(", ")}.`;
113
+ return { pure, cells, summary };
114
+ }
115
+ /**
116
+ * Merge a framework version pin into a #1584-shaped shared-benchmark manifest object.
117
+ * Stamps top-level `frameworkVersion` plus metadata for external consumers.
118
+ */
119
+ export function wireFrameworkVersionIntoManifest(manifest, pin) {
120
+ const priorMeta = typeof manifest.metadata === "object" &&
121
+ manifest.metadata !== null &&
122
+ !Array.isArray(manifest.metadata)
123
+ ? { ...manifest.metadata }
124
+ : {};
125
+ const versionBlock = {
126
+ frameworkVersion: pin.frameworkVersion,
127
+ frameworkVersionSource: pin.source,
128
+ frameworkVersionResolvedAt: pin.resolvedAt,
129
+ versionPurityGate: "#3215",
130
+ };
131
+ return {
132
+ ...manifest,
133
+ frameworkVersion: pin.frameworkVersion,
134
+ metadata: {
135
+ ...priorMeta,
136
+ ...versionBlock,
137
+ },
138
+ };
139
+ }
140
+ /** Load `evals/shared-benchmark.json` when present; otherwise null. */
141
+ export function loadSharedBenchmarkManifest(projectRoot) {
142
+ const path = resolve(projectRoot, SHARED_BENCHMARK_MANIFEST_REL);
143
+ if (!existsSync(path)) {
144
+ return null;
145
+ }
146
+ try {
147
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
148
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
149
+ return parsed;
150
+ }
151
+ return null;
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ /**
158
+ * When a #1584 shared-benchmark manifest exists under the project root, return it
159
+ * with the framework version pin wired in. When `persist` is true, write the
160
+ * wired manifest back to disk so subsequent consumers see the pin (#3215 Greptile).
161
+ */
162
+ export function applyVersionPinToSharedBenchmark(projectRoot, pin, options) {
163
+ const existing = loadSharedBenchmarkManifest(projectRoot);
164
+ if (existing === null) {
165
+ return { applied: false, persisted: false, manifest: null };
166
+ }
167
+ const manifest = wireFrameworkVersionIntoManifest(existing, pin);
168
+ const shouldPersist = options?.persist === true;
169
+ if (!shouldPersist) {
170
+ return { applied: true, persisted: false, manifest };
171
+ }
172
+ // #2980: product write sink routes through containedWrite.
173
+ containedWrite({
174
+ root: resolve(projectRoot),
175
+ target: SHARED_BENCHMARK_MANIFEST_REL,
176
+ data: `${JSON.stringify(manifest, null, 2)}\n`,
177
+ mode: "replace",
178
+ });
179
+ return { applied: true, persisted: true, manifest };
180
+ }
181
+ //# sourceMappingURL=version-pin.js.map
@@ -7,6 +7,7 @@
7
7
  * - CLEAN + bot merge allowed + --merge → optional wait-mergeable-and-merge
8
8
  * - Missing grants → fail closed BLOCKED
9
9
  */
10
+ import { type EnforceMergeApprovalHeadInput, type MergeApprovalHeadResult } from "../policy/merge-approval-head.js";
10
11
  import { evaluateAgentMerge } from "../policy/require-human-merge.js";
11
12
  import type { WatchOptions, WatchResult } from "../pr-watch/types.js";
12
13
  import { type PrFinishLoopResult } from "./types.js";
@@ -20,13 +21,23 @@ export interface PrFinishLoopOptions {
20
21
  /** Attempt merge when CLEAN and policy allows (default false). */
21
22
  readonly merge?: boolean;
22
23
  readonly skipGrantGate?: boolean;
24
+ /** Skip #3235 head-bound plan:approved gate (tests). */
25
+ readonly skipMergeApprovalHeadGate?: boolean;
23
26
  readonly env?: Readonly<Record<string, string | undefined>>;
24
27
  readonly now?: Date;
25
28
  /** Inject watch for tests. */
26
29
  readonly watchFn?: (prNumber: number, repo: string | null, options?: WatchOptions) => WatchResult;
27
- /** Inject merge cascade for tests; returns exit code. */
28
- readonly mergeFn?: (prNumber: number, repo: string | null) => number;
30
+ /**
31
+ * Inject merge cascade for tests; returns exit code.
32
+ * Optional `matchHeadCommit` pins the merge to the gated head (#3235).
33
+ */
34
+ readonly mergeFn?: (prNumber: number, repo: string | null, options?: {
35
+ readonly matchHeadCommit?: string | null;
36
+ }) => number;
29
37
  readonly agentMergeFn?: typeof evaluateAgentMerge;
38
+ readonly mergeApprovalHeadFn?: (input: EnforceMergeApprovalHeadInput) => MergeApprovalHeadResult;
39
+ /** Inject live HEAD fetch (tests); defaults to REST pulls head.sha. */
40
+ readonly fetchPrHeadShaFn?: (prNumber: number, repo: string | null) => string | null;
30
41
  readonly writeProgress?: boolean;
31
42
  readonly iteration?: number;
32
43
  }
@@ -7,6 +7,7 @@
7
7
  * - CLEAN + bot merge allowed + --merge → optional wait-mergeable-and-merge
8
8
  * - Missing grants → fail closed BLOCKED
9
9
  */
10
+ import { disablePullRequestAutoMerge, enforceMergeApprovalHead, fetchPrHeadShaRest, } from "../policy/merge-approval-head.js";
10
11
  import { evaluateAgentMerge } from "../policy/require-human-merge.js";
11
12
  import { EXIT_CLEAN, EXIT_NEW_P0_P1, EXIT_TERMINAL_ERROR, VERDICT_CLEAN, VERDICT_NEW_P0_P1, } from "../pr-watch/constants.js";
12
13
  import { watch } from "../pr-watch/watch.js";
@@ -70,7 +71,9 @@ export function runPrFinishLoop(options) {
70
71
  }
71
72
  // --- pr:watch ---
72
73
  const watchFn = options.watchFn ?? watch;
73
- const repo = options.repo ?? process.env.GH_REPO ?? null;
74
+ // Prefer explicit repo; GH_REPO next. Repo is required for head-bound approval
75
+ // scoping when plan:approved records exist (#3235 cross-repo collision).
76
+ const repo = options.repo ?? process.env.GH_REPO ?? process.env.GITHUB_REPOSITORY ?? null;
74
77
  let watchResult;
75
78
  try {
76
79
  watchResult = watchFn(prNumber, repo, {
@@ -129,6 +132,61 @@ export function runPrFinishLoop(options) {
129
132
  grantId: null,
130
133
  };
131
134
  }
135
+ // #3235: head-bound plan:approved vs LIVE PR HEAD (before merge path).
136
+ // Always re-fetch HEAD. Never fall back to the watch snapshot: a failed live
137
+ // read + snapshot A would retain auto-merge while GitHub head is B.
138
+ if (options.skipMergeApprovalHeadGate !== true) {
139
+ const fetchHead = options.fetchPrHeadShaFn ?? fetchPrHeadShaRest;
140
+ const liveHead = fetchHead(prNumber, repo);
141
+ if (liveHead === null || liveHead.trim() === "") {
142
+ disablePullRequestAutoMerge(prNumber, repo);
143
+ const message = `pr:finish-loop ACTION_REQUIRED on PR #${prNumber}: cannot read live HEAD ` +
144
+ "after CLEAN; auto-merge disabled (fail closed, #3235).";
145
+ log("merge", "stale-merge-approval", message);
146
+ return {
147
+ exitCode: EXIT_ACTION_REQUIRED,
148
+ haltReason: "stale-merge-approval",
149
+ message,
150
+ prNumber,
151
+ watchVerdict: VERDICT_CLEAN,
152
+ mergeAttempted: false,
153
+ mergeSkippedReason: "stale-merge-approval",
154
+ grantId: null,
155
+ };
156
+ }
157
+ const headGateFn = options.mergeApprovalHeadFn ?? enforceMergeApprovalHead;
158
+ const headGate = headGateFn({
159
+ prNumber,
160
+ repo,
161
+ projectRoot,
162
+ currentHeadSha: liveHead,
163
+ disableAutoMergeOnDeny: true,
164
+ });
165
+ if (!headGate.allowed) {
166
+ const message = [
167
+ `pr:finish-loop ACTION_REQUIRED on PR #${prNumber}: stale or unbound merge approval.`,
168
+ headGate.message,
169
+ headGate.recovery ?? "",
170
+ ]
171
+ .filter((line) => line.length > 0)
172
+ .join("\n");
173
+ log("merge", "stale-merge-approval", message, {
174
+ approved_head_sha: headGate.approved_head_sha,
175
+ current_head_sha: headGate.current_head_sha,
176
+ auto_merge_disabled: headGate.auto_merge_disabled,
177
+ });
178
+ return {
179
+ exitCode: EXIT_ACTION_REQUIRED,
180
+ haltReason: "stale-merge-approval",
181
+ message,
182
+ prNumber,
183
+ watchVerdict: VERDICT_CLEAN,
184
+ mergeAttempted: false,
185
+ mergeSkippedReason: "stale-merge-approval",
186
+ grantId: null,
187
+ };
188
+ }
189
+ }
132
190
  // CLEAN path
133
191
  if (options.merge !== true) {
134
192
  const message = `pr:finish-loop CLEAN on PR #${prNumber}. Merge not requested ` +
@@ -165,12 +223,62 @@ export function runPrFinishLoop(options) {
165
223
  grantId: null,
166
224
  };
167
225
  }
168
- // Optional merge when policy allows
226
+ // Optional merge when policy allows — re-fetch live HEAD (not watch snapshot)
227
+ // and pin matchHeadCommit (#3235 TOCTOU).
169
228
  if (options.mergeFn !== undefined) {
170
- const rc = options.mergeFn(prNumber, repo);
229
+ const fetchHead = options.fetchPrHeadShaFn ?? fetchPrHeadShaRest;
230
+ const liveHead = fetchHead(prNumber, repo);
231
+ if (options.skipMergeApprovalHeadGate !== true) {
232
+ if (liveHead === null || liveHead.trim() === "") {
233
+ disablePullRequestAutoMerge(prNumber, repo);
234
+ const message = `pr:finish-loop ACTION_REQUIRED on PR #${prNumber}: cannot read live HEAD ` +
235
+ "before merge; auto-merge disabled (fail closed, #3235).";
236
+ log("merge", "stale-merge-approval", message);
237
+ return {
238
+ exitCode: EXIT_ACTION_REQUIRED,
239
+ haltReason: "stale-merge-approval",
240
+ message,
241
+ prNumber,
242
+ watchVerdict: VERDICT_CLEAN,
243
+ mergeAttempted: false,
244
+ mergeSkippedReason: "stale-merge-approval",
245
+ grantId: null,
246
+ };
247
+ }
248
+ const headGateFn = options.mergeApprovalHeadFn ?? enforceMergeApprovalHead;
249
+ const recheck = headGateFn({
250
+ prNumber,
251
+ repo,
252
+ projectRoot,
253
+ currentHeadSha: liveHead,
254
+ disableAutoMergeOnDeny: true,
255
+ });
256
+ if (!recheck.allowed) {
257
+ const message = [
258
+ `pr:finish-loop ACTION_REQUIRED on PR #${prNumber}: stale merge approval at merge time.`,
259
+ recheck.message,
260
+ recheck.recovery ?? "",
261
+ ]
262
+ .filter((line) => line.length > 0)
263
+ .join("\n");
264
+ log("merge", "stale-merge-approval", message);
265
+ return {
266
+ exitCode: EXIT_ACTION_REQUIRED,
267
+ haltReason: "stale-merge-approval",
268
+ message,
269
+ prNumber,
270
+ watchVerdict: VERDICT_CLEAN,
271
+ mergeAttempted: false,
272
+ mergeSkippedReason: "stale-merge-approval",
273
+ grantId: null,
274
+ };
275
+ }
276
+ }
277
+ const pinnedHead = liveHead;
278
+ const rc = options.mergeFn(prNumber, repo, { matchHeadCommit: pinnedHead });
171
279
  if (rc === 0) {
172
280
  const message = `pr:finish-loop MERGED PR #${prNumber}`;
173
- log("merge", "merged", message);
281
+ log("merge", "merged", message, { matchHeadCommit: pinnedHead });
174
282
  return {
175
283
  exitCode: EXIT_OK,
176
284
  haltReason: "merged",
@@ -182,7 +290,10 @@ export function runPrFinishLoop(options) {
182
290
  grantId: null,
183
291
  };
184
292
  }
185
- const message = `pr:finish-loop merge attempt failed exit=${rc} for PR #${prNumber}`;
293
+ // Pin mismatch / merge fail: revoke auto-merge so unapproved head cannot land.
294
+ disablePullRequestAutoMerge(prNumber, repo);
295
+ const message = `pr:finish-loop merge attempt failed exit=${rc} for PR #${prNumber}; ` +
296
+ "disabled auto-merge after pinned-merge failure (#3235).";
186
297
  log("merge", "error", message);
187
298
  return {
188
299
  exitCode: EXIT_BLOCKED,
@@ -8,7 +8,7 @@ export declare const EXIT_OK = 0;
8
8
  export declare const EXIT_ACTION_REQUIRED = 1;
9
9
  /** BLOCKED (grant/gate), ERRORED, TIMEOUT, config. */
10
10
  export declare const EXIT_BLOCKED = 2;
11
- export type FinishLoopHaltReason = "empty-queue" | "grant-missing" | "grant-expired" | "grant-deny" | "gate-deny" | "max-iterations" | "require-human-merge" | "address-findings" | "agent-implement" | "clean" | "merged" | "error";
11
+ export type FinishLoopHaltReason = "empty-queue" | "grant-missing" | "grant-expired" | "grant-deny" | "gate-deny" | "max-iterations" | "require-human-merge" | "stale-merge-approval" | "address-findings" | "agent-implement" | "clean" | "merged" | "error";
12
12
  export type FinishLoopPhase = "gate" | "queue-scan" | "implement" | "pr-open" | "pr-watch" | "address" | "merge" | "halt";
13
13
  export interface FinishLoopProgressLine {
14
14
  readonly schemaVersion: 1;
@@ -142,11 +142,15 @@ export declare function decideHook(input: HookDispatchInput, seams?: HookPolicyS
142
142
  /**
143
143
  * Render host-facing hook output.
144
144
  *
145
- * Cursor deposits use `failClosed: true`. Cursor treats empty/null stdout as a
146
- * hook failure and blocks the tool — so Cursor allows must emit explicit
147
- * `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
148
- * flow is unchanged except session.start / session.compact soft re-bind
149
- * injection (#3171), which surfaces checklist text without a write tool.
145
+ * Cursor deposits use `failClosed: true` with a tool.before timeout above the
146
+ * gated-ritual / live agent-hook readiness budget
147
+ * (`CURSOR_TOOL_BEFORE_TIMEOUT_SECONDS` in init-deposit/agent-hooks; #3246).
148
+ * Cursor treats empty/null stdout (or a host timeout kill) as a hook failure
149
+ * and blocks the tool so Cursor allows must emit explicit
150
+ * `{"permission":"allow"}` within the deposit timeout. Other hosts keep empty
151
+ * allow so the host permission flow is unchanged — except session.start /
152
+ * session.compact soft re-bind injection (#3171), which surfaces checklist
153
+ * text without a write tool.
150
154
  *
151
155
  * Cursor stdout always includes `code` (stable machine-readable decision code)
152
156
  * so agents can distinguish policy denials from host-integration failures
@@ -184,10 +184,10 @@ export function isAllowlistedAssistScratchPath(projectRoot, targetPath) {
184
184
  export function isAssistScratchWrite(projectRoot, targetPath, payload, environ = process.env) {
185
185
  if (!isAllowlistedAssistScratchPath(projectRoot, targetPath))
186
186
  return false;
187
- // Structural classification only — compose with #3080 ephemeral markers.
187
+ // Structural classification only — compose with #3080 / #3259 ephemeral markers.
188
188
  if (isAssistPosture(payload, environ))
189
189
  return true;
190
- if (isEphemeralSpawn(payload))
190
+ if (isEphemeralSpawn(payload, environ))
191
191
  return true;
192
192
  return false;
193
193
  }
@@ -656,14 +656,18 @@ function inspectMutationGates(input, toolName, seams, options) {
656
656
  if (!outsideRoot || isSpawnTool(toolName)) {
657
657
  let proposedPathHint;
658
658
  if (isSpawnTool(toolName)) {
659
- // Multi-path recovery for implement-class spawns (#3080 AC4).
659
+ // Multi-path recovery for implement-class spawns (#3080 AC4 / #3259 honesty).
660
+ // Structural markers only — free-text prompt brackets are not sufficient.
660
661
  proposedPathHint =
661
662
  " Recovery: (1) Product implementation — run `deft scope:activate -- <path>` " +
662
663
  "for the approved xBRIEF, then re-run the pre-start_agent gate stack. " +
663
- "(2) Read-only research — spawn with `subagent_type`/`worker_role` explore. " +
664
- "(3) Ephemeral docs/analysisspawn with `worker_role: ephemeral` " +
665
- "(aliases: docs, assist; see commands.md), or continue in the parent without " +
666
- "a lifecycle story. Do not invent a fake scope only to satisfy this gate.";
664
+ "(2) Read-only research — spawn with structural `subagent_type`/`worker_role` explore. " +
665
+ "(3) Ephemeral docs/local-devset structural tool fields " +
666
+ "`worker_role`/`subagent_type` ∈ {ephemeral, docs, assist} (hosts that support them), " +
667
+ "or set session assist (`DEFT_SESSION_POSTURE=assist` or `DEFT_HOOK_ASSIST=1`), " +
668
+ "or run local-dev Shell (`docker compose` / `pnpm dev`) in the parent without a " +
669
+ "lifecycle story. Free-text markers such as `[worker_role: ephemeral]` in the " +
670
+ "prompt are NOT sufficient. Do not invent a fake scope only to satisfy this gate.";
667
671
  }
668
672
  else if (options.proposedLifecycleExempt &&
669
673
  relTarget !== null &&
@@ -895,9 +899,9 @@ export function decideHook(input, seams = {}) {
895
899
  scopePath: null,
896
900
  };
897
901
  }
898
- // Ephemeral/docs/assist: write-capable non-lifecycle spawn; no active xBRIEF (#3080).
899
- // Does not authorize push/merge/deploy — those remain on shell/MCP matchers.
900
- if (isEphemeralSpawn(input.payload)) {
902
+ // Ephemeral/docs/assist (+ session assist env #3259): non-lifecycle spawn;
903
+ // no active xBRIEF. Does not authorize push/merge/deploy — shell/MCP matchers.
904
+ if (isEphemeralSpawn(input.payload, environ)) {
901
905
  return {
902
906
  verdict: "allow",
903
907
  code: "spawn-ephemeral-ready",
@@ -951,11 +955,15 @@ function softAgentsRebindWireText(decision) {
951
955
  /**
952
956
  * Render host-facing hook output.
953
957
  *
954
- * Cursor deposits use `failClosed: true`. Cursor treats empty/null stdout as a
955
- * hook failure and blocks the tool — so Cursor allows must emit explicit
956
- * `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
957
- * flow is unchanged except session.start / session.compact soft re-bind
958
- * injection (#3171), which surfaces checklist text without a write tool.
958
+ * Cursor deposits use `failClosed: true` with a tool.before timeout above the
959
+ * gated-ritual / live agent-hook readiness budget
960
+ * (`CURSOR_TOOL_BEFORE_TIMEOUT_SECONDS` in init-deposit/agent-hooks; #3246).
961
+ * Cursor treats empty/null stdout (or a host timeout kill) as a hook failure
962
+ * and blocks the tool so Cursor allows must emit explicit
963
+ * `{"permission":"allow"}` within the deposit timeout. Other hosts keep empty
964
+ * allow so the host permission flow is unchanged — except session.start /
965
+ * session.compact soft re-bind injection (#3171), which surfaces checklist
966
+ * text without a write tool.
959
967
  *
960
968
  * Cursor stdout always includes `code` (stable machine-readable decision code)
961
969
  * so agents can distinguish policy denials from host-integration failures
@@ -13,9 +13,12 @@ export declare function isExploreSpawn(payload: unknown): boolean;
13
13
  */
14
14
  export declare function isAssistPosture(payload: unknown, environ?: NodeJS.ProcessEnv): boolean;
15
15
  /**
16
- * Ephemeral / assist / docs spawns skip active-xBRIEF implementation gates (#3080).
16
+ * Ephemeral / assist / docs spawns skip active-xBRIEF implementation gates (#3080 / #3259).
17
17
  * True only with an explicit allowlisted marker. Absent marker → false (fail closed).
18
- * When an ephemeral marker conflicts with implement envelope signals, implement wins.
18
+ * Markers: structural `worker_role`/`subagent_type` {ephemeral,docs,assist}, OR
19
+ * session assist env (`DEFT_SESSION_POSTURE` assist-set / `DEFT_HOOK_ASSIST=1`) for spawn.
20
+ * Free-text prompt strings are never classified. When a marker conflicts with implement
21
+ * envelope signals, implement wins.
19
22
  */
20
- export declare function isEphemeralSpawn(payload: unknown): boolean;
23
+ export declare function isEphemeralSpawn(payload: unknown, environ?: NodeJS.ProcessEnv): boolean;
21
24
  //# sourceMappingURL=readonly.d.ts.map